From 30b4d4522573e26958ca7e15688a8e9bd0168fe8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:52:45 -0400 Subject: [PATCH 01/15] feat(chat-ui): draw link cards in their bubble's shape A text bubble takes its corners from bubbleShape(position, isFromSelf), with the small corner on the sender's side where it meets a neighbour. Link cards ignored that: LinkCardView used CodeTheme.shapes.medium, BareLinkCard passed no position, and groupsWith broke the run on any card row. An invite, a card and then the note sent with it, read as two unrelated messages. LinkCardView now takes a shape, and BareLinkCard hands it bubbleShape(position, isFromSelf). Each kind cuts its pieces from that outline: the cash voucher's top half and stub, the ghost stub's drawn path, TokenCard's bill, and the invite card's frame. groupsWith breaks a run only on a bare emoji now, since a card has an edge to square against. GroupInviteLinkCard becomes public and takes its button label, so the group's empty state can draw it too. The transcript card says "View" instead of "Start Chatting". LinkCardShapeTest renders each card kind at every position and checks its outline against bubbleShape; BubblePositionTest covers a card joining the run on either side. --- .../com/flipcash/app/core/ui/TokenCard.kt | 4 +- .../core/src/main/res/values/strings.xml | 3 +- .../shared/chat/ui/GroupInviteLinkCard.kt | 38 ++++-- .../flipcash/shared/chat/ui/LinkCardView.kt | 73 +++++++--- .../flipcash/shared/chat/ui/MessageBubble.kt | 32 +++-- .../flipcash/shared/chat/ui/UserLinkCard.kt | 13 +- .../shared/chat/ui/BubblePositionTest.kt | 35 ++++- .../shared/chat/ui/LinkCardShapeTest.kt | 126 ++++++++++++++++++ 8 files changed, 273 insertions(+), 51 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt index b09823fd32..d40058f547 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText @@ -83,10 +84,11 @@ fun TokenCard( displayName: String = token.name, appreciationText: String? = null, height: Dp = 224.dp, + // A chat's link card passes its bubble's outline, so the bill corners like the run around it. + shape: Shape = CodeTheme.shapes.medium, onClick: (() -> Unit)? = null, ) { val isUsdf = token.address == Mint.usdf - val shape = CodeTheme.shapes.medium val brush = remember(token.billCustomizations, isUsdf) { if (isUsdf) UsdfBrush else billCardBrush(token.billCustomizations) } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9b52eedc84..9ce95ec526 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -983,7 +983,8 @@ Expired Group Chat - Start Chatting + View + Invite People Group Unavailable diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt index 2dc5a4335d..95d7240358 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -63,15 +64,21 @@ import com.getcode.ui.theme.CodeButton * * Nothing from the roster reaches here, so there are no member avatars and no member names, even as * a fallback title; an untitled group is named generically. + * + * Drawn in two places that want different things from the button, so [ctaLabel] and [onStart] come + * from the caller: a DM transcript's link card says "View" and opens the group, and the group's own + * empty state says "Invite People" and opens the invite sheet. [shape] is the transcript bubble's + * outline there, and the card's own radius where it stands alone. */ @Composable -internal fun GroupInviteLinkCard( +fun GroupInviteLinkCard( card: LinkCard.GroupInvite, minHeight: Dp, + ctaLabel: String, onStart: (() -> Unit)?, modifier: Modifier = Modifier, + shape: CornerBasedShape = GroupInviteCardDefaults.SHAPE, ) { - val shape = GroupInviteCardDefaults.SHAPE val state = card.state if (state is LinkCard.GroupInvite.State.Loading) { @@ -179,7 +186,7 @@ internal fun GroupInviteLinkCard( modifier = Modifier .fillMaxWidth() .height(GroupInviteCardDefaults.BUTTON_HEIGHT), - text = stringResource(R.string.action_linkCard_startChatting), + text = ctaLabel, buttonState = ButtonState.Filled, overrideContentPadding = true, contentPadding = GroupInviteCardDefaults.NO_PADDING, @@ -233,30 +240,36 @@ private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { } } -internal object GroupInviteCardDefaults { +object GroupInviteCardDefaults { /** The group header card's radius. */ val SHAPE = RoundedCornerShape(12.dp) /** The group header card's stroke: white at 10%. Also the avatar's ring. */ - val STROKE = Color.White.copy(alpha = 0.1f) + internal val STROKE = Color.White.copy(alpha = 0.1f) - val BAND_HEIGHT = 46.dp + internal val BAND_HEIGHT = 46.dp /** Provisional, pending design sign-off. */ - const val BAND_OPACITY = 0.28f + internal const val BAND_OPACITY = 0.28f - val AVATAR = 64.dp + internal val AVATAR = 64.dp /** Title to member count. */ - val MEMBER_COUNT_GAP = 2.dp + internal val MEMBER_COUNT_GAP = 2.dp /** The least gap between the text and the button. */ - val BUTTON_GAP = 16.dp + internal val BUTTON_GAP = 16.dp /** iOS's compact button height. Android has no compact style, so the standard one is sized. */ - val BUTTON_HEIGHT = 44.dp + internal val BUTTON_HEIGHT = 44.dp + + internal val NO_PADDING = PaddingValues(0.dp) - val NO_PADDING = PaddingValues(0.dp) + /** + * 224dp of card across 328dp of usable width, the wallet deck's proportions. The transcript + * measures an invite card at this; the group's empty state uses it so the two agree. + */ + const val ASPECT = 224f / 328f } // region Previews @@ -304,6 +317,7 @@ private fun PreviewCard(state: LinkCard.GroupInvite.State) { GroupInviteLinkCard( card = previewGroupCard(state), minHeight = 280.dp * 224f / 328f, + ctaLabel = "View", onStart = {}, ) } 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 7cd35393f0..4a36cfacff 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -45,7 +46,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.ui.TokenCard @@ -82,23 +87,29 @@ import com.getcode.ui.utils.ConstraintMode * [card] arrives with its lookup still to do and the card runs it — see [rememberResolvedCard]. * The transcript hands over what it can read off the message text, which is everything but the * amount and the claim, and is not held up by the rest. + * + * [shape] is the outline of the bubble the card stands in for, from [bubbleShape], so a card in a + * run squares the corners facing its neighbours exactly as a text bubble there would. Every kind + * cuts its own pieces from it: the voucher's two halves and the stub it leaves behind, the bill, + * the invite's frame. */ @Composable internal fun LinkCardView( card: LinkCard, + shape: CornerBasedShape, onClick: ((LinkCard) -> Unit)?, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, ) { val live = rememberResolvedCard(card) - val shape = CodeTheme.shapes.medium if (live is LinkCard.User) { // Hugs its text like a message rather than filling the row, and takes its own tap. UserLinkCard( card = live, onClick = onClick?.let { click -> { click(live) } }, - modifier = modifier, + modifier = modifier.semantics { linkCardShape = shape }, + shape = shape, onLongClick = onLongClick, ) return @@ -110,6 +121,7 @@ internal fun LinkCardView( BoxWithConstraints( modifier = modifier .fillMaxWidth() + .semantics { linkCardShape = shape } .addIf(onLongClick != null) { Modifier.pointerInput(onLongClick) { detectTapGestures(onLongPress = { onLongClick?.invoke() }) @@ -120,6 +132,10 @@ internal fun LinkCardView( card = live, minHeight = maxWidth * LinkCardDefaults.CARD_ASPECT, onStart = onClick?.let { click -> { click(live) } }, + // A transcript card opens the group named on it, so it says "View"; the group's + // own empty state draws this card with "Invite People". + ctaLabel = stringResource(R.string.action_linkCard_view), + shape = shape, ) } return @@ -131,6 +147,7 @@ internal fun LinkCardView( BoxWithConstraints( modifier = modifier .fillMaxWidth() + .semantics { linkCardShape = shape } // One target for the whole card in every state: an unresolved link is unresolved, not // broken, and a spent one still opens to the page that says so. .addIf(onClick != null || onLongClick != null) { @@ -144,8 +161,8 @@ internal fun LinkCardView( ) { val height: Dp = maxWidth * LinkCardDefaults.CARD_ASPECT when (live) { - is LinkCard.Cash -> CashLinkCard(card = live, height = height) - is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height) + is LinkCard.Cash -> CashLinkCard(card = live, height = height, shape = shape) + is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. is LinkCard.GroupInvite, is LinkCard.User -> Unit } @@ -194,6 +211,7 @@ private fun rememberResolvedCard(card: LinkCard): LinkCard { private fun CashLinkCard( card: LinkCard.Cash, height: Dp, + shape: CornerBasedShape, ) { // Unresolved is also the unavailable state: a lookup that failed, timed out or was switched off // renders here. The same voucher, with nothing filled in — same size, same chrome — so nothing @@ -209,6 +227,7 @@ private fun CashLinkCard( CashVoucher( height = height, + shape = shape, loading = loading, tokenName = state?.token?.brandedName() ?: stringResource(R.string.label_linkCard_cash), @@ -293,6 +312,7 @@ private fun TokenRow(tokenName: String, tokenImage: Any?) { private fun TokenLinkCard( card: LinkCard.TokenInfo, height: Dp, + shape: CornerBasedShape, ) { when (val state = card.state) { is LinkCard.TokenInfo.State.Resolved -> TokenCard( @@ -302,6 +322,7 @@ private fun TokenLinkCard( balanceText = "", displayName = state.token.brandedName(), height = height, + shape = shape, ) // The address is the one thing known about the mint before the network answers, and it is @@ -310,12 +331,14 @@ private fun TokenLinkCard( LinkCard.TokenInfo.State.Loading -> UnresolvedTokenCard( mint = card.mint, height = height, + shape = shape, loading = true, ) LinkCard.TokenInfo.State.Unresolved -> UnresolvedTokenCard( mint = card.mint, height = height, + shape = shape, loading = false, ) } @@ -334,9 +357,9 @@ private fun TokenLinkCard( private fun UnresolvedTokenCard( mint: Mint, height: Dp, + shape: CornerBasedShape, loading: Boolean, ) { - val shape = CodeTheme.shapes.medium Box( modifier = Modifier .fillMaxWidth() @@ -384,10 +407,14 @@ private const val ABBREVIATED_MINT_CHARS = 4 * Tearing costs no height. The stub's band is held open whether or not the stub is drawn in it, * because the claim state arrives from the lookup after the row is first drawn — a card that * changed size on resolve would shove the transcript around under the reader. + * + * The upper piece takes [shape]'s top corners and the stub its bottom ones, so the two together + * are the bubble's outline with a seam across it. */ @Composable private fun CashVoucher( height: Dp, + shape: CornerBasedShape, tokenName: String, tokenImage: Any?, amount: String?, @@ -396,7 +423,6 @@ private fun CashVoucher( loading: Boolean, stub: @Composable () -> Unit, ) { - val corner = CodeTheme.shapes.medium.topStart val square = CornerSize(0.dp) val inset = CodeTheme.dimens.inset val stubHeight = height * LinkCardDefaults.STUB_FRACTION @@ -408,7 +434,7 @@ private fun CashVoucher( ) { VoucherPiece( modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corner, corner, square, square), + shape = shape.copy(bottomEnd = square, bottomStart = square), spent = spent, notchAtBottom = true, // Nothing left to tear along once it has been torn. @@ -462,13 +488,14 @@ private fun CashVoucher( // [stub] standing inside it. GhostStub( modifier = Modifier.height(stubHeight), - corner = corner, + bottomStart = shape.bottomStart, + bottomEnd = shape.bottomEnd, content = stub, ) } else { VoucherPiece( modifier = Modifier.height(stubHeight), - shape = RoundedCornerShape(square, square, corner, corner), + shape = shape.copy(topStart = square, topEnd = square), spent = spent, notchAtBottom = false, scored = false, @@ -498,7 +525,8 @@ private fun CashVoucher( @Composable private fun GhostStub( modifier: Modifier, - corner: CornerSize, + bottomStart: CornerSize, + bottomEnd: CornerSize, content: @Composable () -> Unit, ) { val density = LocalDensity.current @@ -516,7 +544,13 @@ private fun GhostStub( .fillMaxWidth() .drawBehind { val r = notchRadius - val c = corner.toPx(size, this) + val start = bottomStart.toPx(size, this) + val end = bottomEnd.toPx(size, this) + val (left, right) = if (layoutDirection == LayoutDirection.Ltr) { + start to end + } else { + end to start + } val w = size.width val h = size.height val outline = Path().apply { @@ -525,10 +559,10 @@ private fun GhostStub( arcTo(Rect(Offset(-r, -r), Size(r * 2, r * 2)), 90f, -90f, false) lineTo(w - r, 0f) arcTo(Rect(Offset(w - r, -r), Size(r * 2, r * 2)), 180f, -90f, false) - lineTo(w, h - c) - arcTo(Rect(Offset(w - c * 2, h - c * 2), Size(c * 2, c * 2)), 0f, 90f, false) - lineTo(c, h) - arcTo(Rect(Offset(0f, h - c * 2), Size(c * 2, c * 2)), 90f, 90f, false) + lineTo(w, h - right) + arcTo(Rect(Offset(w - right * 2, h - right * 2), Size(right * 2, right * 2)), 0f, 90f, false) + lineTo(left, h) + arcTo(Rect(Offset(0f, h - left * 2), Size(left * 2, left * 2)), 90f, 90f, false) close() } drawPath( @@ -551,7 +585,7 @@ private fun GhostStub( @Composable private fun VoucherPiece( modifier: Modifier, - shape: RoundedCornerShape, + shape: Shape, spent: Boolean, notchAtBottom: Boolean, scored: Boolean, @@ -675,6 +709,13 @@ private fun StubPill(text: String) { ) } +/** + * The outline a card was drawn with, for tests: it is otherwise only visible as pixels, and the + * point of it is that it matches [bubbleShape] at the card's place in its run. + */ +internal val LinkCardShapeKey = SemanticsPropertyKey("LinkCardShape") +private var SemanticsPropertyReceiver.linkCardShape by LinkCardShapeKey + private object LinkCardDefaults { /** * 224dp of card across 328dp of usable width — the wallet deck's own numbers on a 360dp phone 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 27cc6cbe88..1ec4b7f952 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 @@ -270,7 +270,8 @@ fun ChatListItem.ContentBubble.rendersBareEmoji(): Boolean { /** * Whether this row draws with no bubble behind it: a bare emoji, or the card row of a split - * message. Either one breaks the bubble run and moves the "Edited" marker to the line under it. + * message. Either one moves the "Edited" marker to the line under it; only the emoji breaks the + * bubble run (see [groupsWith]). */ fun ChatListItem.ContentBubble.rendersBare(): Boolean = part == MessagePart.Card || rendersBareEmoji() @@ -522,9 +523,13 @@ internal const val JUMBO_EMOJI_TAG = "bubble_jumbo_emoji" /** * The card row of a split message, with no bubble behind it. * - * Everything the bubble would have contributed is already the card's: the fill, the rounded corners - * and the tap. What is not the card's is the flash a jump leaves on the message it landed on, which - * belongs to the transcript rather than to the bubble -- so this goes through [Bubble] as the jumbo + * Everything the bubble would have contributed is already the card's: the fill, the corners and the + * tap. The corners are still the bubble's -- [bubbleShape] at this row's [position] -- handed to the + * card so its frame, its pieces and its border all follow them, and it squares off against its + * neighbours in a run the way a text bubble does. + * + * What is not the card's is the flash a jump leaves on the message it landed on, which belongs to + * the transcript rather than to the bubble -- so this goes through [Bubble] as the jumbo * emoji does, with `bare` dropping the fill and the horizontal inset and leaving the flash, the * width ceiling and the corner clip where every other bubble already gets them. * @@ -546,11 +551,13 @@ private fun BareLinkCard( attention: () -> Float = { 0f }, ) { val onCardClick = rememberLinkCardClick() + val shape = bubbleShape(position, isFromSelf) Bubble( isFromSelf = isFromSelf, position = position, maxWidth = maxWidth, modifier = modifier, + shape = shape, bare = true, // The card runs to the full width the bubble would have had, and to the full height. Its // own inset is inside its frame, so padding here would be a second one -- and with no fill @@ -578,6 +585,7 @@ private fun BareLinkCard( LinkCardView( card = card, modifier = Modifier.addIf(card is LinkCard.User) { Modifier.fillMaxWidth() }, + shape = shape, // Dropped with the backdrop up, like every other target on the row. onClick = if (interactive) onCardClick else null, // The card takes the press for its own tap, so it has to hand the transcript's @@ -775,6 +783,7 @@ private fun Bubble( horizontalPadding: Dp = BubbleDefaults.paddingHorizontal, verticalPadding: Dp = BubbleDefaults.paddingVertical, bare: Boolean = false, + shape: Shape = bubbleShape(position, isFromSelf), attention: () -> Float = { 0f }, content: @Composable BoxScope.() -> Unit, ) { @@ -783,7 +792,6 @@ private fun Bubble( } else { CodeTheme.colors.chat.incomingBubble } - val shape = bubbleShape(position, isFromSelf) Box( modifier = modifier .widthIn(min = minWidth, max = maxWidth) @@ -826,7 +834,7 @@ private fun Bubble( } @Composable -fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { +fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerShape { val l = BubbleDefaults.cornerLarge val s = BubbleDefaults.cornerSmall @@ -874,10 +882,14 @@ private data class BubbleCorners( /** * Whether [item] tucks into the same run as the bubble [other] next to it. * - * A bare emoji or a card row breaks the run on both sides. It draws no bubble, so there is no edge for its + * A bare emoji breaks the run on both sides. It draws no bubble, so there is no edge for its * neighbour to square itself against, and a squared corner facing open space reads as half a bubble - * with the other half missing. Either side being bare is enough, so the message under an emoji + * with the other half missing. Either side being an emoji is enough, so the message under one * closes its top corners the same way the message above it closes its bottom ones. + * + * A card row does not break it. The card is drawn in the bubble's outline (see [BareLinkCard]), so + * it has the edge an emoji lacks, and an invite -- a card and then the note sent with it -- reads + * as one run rather than two unrelated messages. */ private fun groupsWith( item: ChatListItem.ContentBubble, @@ -886,8 +898,8 @@ private fun groupsWith( ): Boolean = other != null && item.isSameAuthorAs(other) && config.isGrouped(item.timestamp, other.timestamp) && - !item.rendersBare() && - !other.rendersBare() + !item.rendersBareEmoji() && + !other.rendersBareEmoji() fun bubblePositionOf( index: Int, 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 da5493fe45..318fdef210 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.isUnspecified import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity @@ -48,8 +49,9 @@ import kotlin.time.Instant * text needs up to the width it is given. Tapped as a whole; a card with no account behind it takes * no tap. * - * Built from the person's public profile only, and shares nothing with the tip card. The corner - * and border are the group invite card's; the rest is in [UserLinkCardDefaults]. + * Built from the person's public profile only, and shares nothing with the tip card. [shape] is + * the outline of the bubble the card stands in for, as for every link card; the border is the + * group invite card's, and the rest is in [UserLinkCardDefaults]. * * At accessibility font sizes the avatar moves above the text and every line wraps, where a row * would cut each one short. @@ -63,9 +65,9 @@ internal fun UserLinkCard( card: LinkCard.User, onClick: (() -> Unit)?, modifier: Modifier = Modifier, + shape: Shape = GroupInviteCardDefaults.SHAPE, onLongClick: (() -> Unit)? = null, ) { - val shape = GroupInviteCardDefaults.SHAPE val state = card.state if (state is LinkCard.User.State.Loading) { @@ -220,8 +222,9 @@ private fun Identity( } /** - * This card's own values, one for one with iOS's `LinkUserCardContent.Layout`. The corner radius - * and border are [GroupInviteCardDefaults.SHAPE] and [GroupInviteCardDefaults.STROKE]. + * This card's own values, one for one with iOS's `LinkUserCardContent.Layout`. In a transcript the + * outline is the bubble's; outside one it is [GroupInviteCardDefaults.SHAPE]. The border is + * [GroupInviteCardDefaults.STROKE]. */ internal object UserLinkCardDefaults { /** The black over the decoded BlurHash backdrop. Provisional, pending design sign-off. */ diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt index c8a8d49d25..353451f3ba 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LinkCard @@ -131,7 +132,7 @@ class BubblePositionTest { } @Test - fun `a card row breaks the run around it, as a bare emoji does`() { + fun `a card row joins the run around it, as a text bubble does`() { val link = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" val text = "before $link after" val carded = bubble(2, secondsIn = 10, senderId = alice.userId, text = text).copy( @@ -150,13 +151,35 @@ class BubblePositionTest { assertEquals( listOf( - BubblePosition.Last, // newest, grouped under the trailing row - BubblePosition.First, // trailing, its run closed above by the card - BubblePosition.Solo, // card, in no run - BubblePosition.Last, // leading, its run closed below by the card - BubblePosition.First, // oldest, grouped over the leading row + BubblePosition.Last, // newest + BubblePosition.Middle, // trailing text + BubblePosition.Middle, // card + BubblePosition.Middle, // leading text + BubblePosition.First, // oldest ), positionsOf(items), ) } + + @Test + fun `an invite's card and the note after it read as one run`() { + // Two messages: the invite link on its own, which draws as a card and nothing else, then + // the note typed with it. + val link = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + val invite = bubble(1, secondsIn = 0, senderId = alice.userId, text = link).copy( + linkCard = LinkCard.GroupInvite( + url = link, + start = 0, + end = link.length, + chatId = ChatId(ByteArray(16)), + state = LinkCard.GroupInvite.State.Loading, + ), + ).splitAroundLinkCard().single() + val items = listOf(bubble(2, secondsIn = 1, senderId = alice.userId), invite) + + assertEquals( + listOf(BubblePosition.Last, BubblePosition.First), + positionsOf(items), + ) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt new file mode 100644 index 0000000000..0ecac6623f --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt @@ -0,0 +1,126 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.splitAroundLinkCard +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * A link card is drawn in the outline a text bubble would have at the same place in its run, so + * a card squares off against its neighbours the way the bubbles around it do. + */ +@RunWith(RobolectricTestRunner::class) +class LinkCardShapeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val cashLink = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + private val inviteLink = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + + private fun cardRow(card: LinkCard, isFromSelf: Boolean) = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text(card.url), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + linkCard = card, + ).splitAroundLinkCard().single() + + private val cash = LinkCard.Cash( + url = cashLink, + start = 0, + end = cashLink.length, + entropy = "KNi8pQr1n5hRU65vKJGge3", + state = LinkCard.Cash.State.Unresolved, + ) + + private val invite = LinkCard.GroupInvite( + url = inviteLink, + start = 0, + end = inviteLink.length, + chatId = ChatId(ByteArray(16)), + state = LinkCard.GroupInvite.State.Unavailable, + ) + + private val personLink = "https://flipcash.com/someone" + + private val person = LinkCard.User( + url = personLink, + start = 0, + end = personLink.length, + identity = LinkCard.User.Identity.ByUsername("someone"), + state = LinkCard.User.State.NotFound, + ) + + private val hasShape = SemanticsMatcher.keyIsDefined(LinkCardShapeKey) + + /** Every position, from both sides, for [card]: the shape drawn and the one a bubble takes. */ + private fun assertCardShapesMatchBubbles(card: LinkCard) { + val cases = BubblePosition.entries.flatMap { position -> + listOf(true, false).map { isFromSelf -> position to isFromSelf } + } + val expected = arrayOfNulls(cases.size) + + composeTestRule.setContent { + DesignSystem { + Column { + cases.forEachIndexed { index, (position, isFromSelf) -> + expected[index] = bubbleShape(position, isFromSelf) + ContentBubble(item = cardRow(card, isFromSelf), position = position) + } + } + } + } + + val drawn = composeTestRule.onAllNodes(hasShape, useUnmergedTree = true) + .fetchSemanticsNodes() + .map { it.config[LinkCardShapeKey] } + + assertEquals(cases.size, drawn.size) + cases.forEachIndexed { index, case -> + assertEquals(expected[index], drawn[index], "shape at $case") + } + } + + @Test + fun `a cash card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(cash) + } + + @Test + fun `a group invite card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(invite) + } + + @Test + fun `a person card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(person) + } + + @Test + fun `positions in a run give a card different corners`() { + // Guards the comparison above: if every position resolved to the same outline, matching + // bubbleShape would prove nothing. + val shapes = mutableListOf() + composeTestRule.setContent { + DesignSystem { + BubblePosition.entries.forEach { shapes += bubbleShape(it, isFromSelf = true) } + } + } + composeTestRule.waitForIdle() + assertEquals(BubblePosition.entries.size, shapes.distinct().size) + } +} From 781957530ce55bcc64b581eb79ac6eca1e272d12 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:53:04 -0400 Subject: [PATCH 02/15] feat(messenger): show the invite card in an empty group An empty group showed ChatInfoCard with an "Invite People To Join" button under it. Node 10330:19164 replaces both with the group-invite card a DM transcript renders for the link, labelled "Invite People". MessageList draws GroupInviteLinkCard in the header slot when the group has no messages and the viewer has an invite URL. The CTA sends ChatAction.InviteToGroup, as the old button did. ChatSubject.Group builds the card from the chat's own record (title, picture, member count, rules), so it needs no link lookup. The card sits at 72% of the row, the frame's 290 of 402. Every other chat still gets ChatInfoCard. --- .../app/messenger/internal/ChatSubject.kt | 35 ++++++ .../screens/components/MessageList.kt | 119 ++++++++++-------- 2 files changed, 101 insertions(+), 53 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index d1a055e96e..dc6384f732 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.chat.models.LinkCard /** * What the messenger screen is a conversation *with*. @@ -156,3 +157,37 @@ internal data class RuleCurrency( val nameInRequirement: String? get() = name.takeUnless { isReserve } } + +/** + * The card an empty group shows in place of its info card: the same [LinkCard.GroupInvite] a + * transcript renders for an invite link, built from what this subject already knows rather than + * from a lookup, since a member looking at the empty group already has the chat's own record. + */ +internal fun ChatSubject.Group.toGroupInviteCard( + inviteUrl: String, + currencyName: String?, +): LinkCard.GroupInvite { + val balance = rules.balanceRequirement() + val staffOnly = rules.requiresStaff() + val requirement = if (balance != null || staffOnly) { + LinkCard.GroupInvite.Requirement( + amount = balance?.amount?.formatted(), + currencyName = currencyName, + staffOnly = staffOnly, + ) + } else { + null + } + return LinkCard.GroupInvite( + url = inviteUrl, + start = 0, + end = inviteUrl.length, + chatId = chatId, + state = LinkCard.GroupInvite.State.Resolved( + title = groupTitle?.takeIf { it.isNotBlank() }, + picture = picture, + memberCount = memberCount, + requirement = requirement, + ), + ) +} 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 43ea29d912..6185a1ac9d 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -41,8 +42,9 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.app.messenger.internal.toGroupInviteCard import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer @@ -55,8 +57,10 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner +import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.shared.chat.ui.GroupInviteCardDefaults +import com.flipcash.shared.chat.ui.GroupInviteLinkCard import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay @@ -68,6 +72,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull +// The empty group's invite card, as a share of the row: 290 of 402 in node 10330:19164. +private const val GROUP_INVITE_CARD_WIDTH = 0.72f + @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -381,37 +388,13 @@ internal fun MessageList( // This prevents these items from being the only content before messages // load, which would cause the list to start at the wrong scroll position. if (messages.itemCount > 0 || refreshSettled) { - // Emitted before the info card, and so drawn below it: this list is reverseLayout, - // where the last item is the topmost one. Both of the items below belong under the - // card the way the designs show them, and neither can appear while there are - // messages — one stands in for a transcript, the other says there isn't one yet. - if (messages.itemCount == 0) { - val inviteUrl = state.groupInviteUrl - when { - // Node 10127:117171. The blur over this list is what makes it a preview; - // see GatedTranscriptPlaceholder for why it is drawn rather than fetched. - state.obscuresTranscript -> item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) - } - - // Node 10127:118280 — the group the creator has just made. Inviting - // someone is the only thing to do with an empty group, so it is offered - // here rather than left to the profile screen. - inviteUrl != null -> item(key = "group-invite-action") { - Box( - modifier = Modifier - .fillParentMaxWidth() - .padding(top = CodeTheme.dimens.grid.x4), - contentAlignment = Alignment.Center, - ) { - CodeButton( - text = stringResource(R.string.action_invitePeopleToJoin), - shape = CircleShape, - enabled = !selecting, - onClick = { onAction(ChatAction.InviteToGroup) }, - ) - } - } + // Node 10127:117171. Emitted before the info card, and so drawn below it: this list + // is reverseLayout, where the last item is the topmost one. The blur over this list + // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn + // rather than fetched. + if (messages.itemCount == 0 && state.obscuresTranscript) { + item(key = "gated-transcript-placeholder") { + GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) } } @@ -438,27 +421,57 @@ internal fun MessageList( } } - // Chat start shows contact info container - item { - Box( - modifier = Modifier - .fillParentMaxWidth(), - contentAlignment = Alignment.Center, - ) { - ChatInfoCard( - subject = state.subject, + // Chat start shows contact info container. An empty group the viewer can invite to + // shows its invite card there instead (node 10330:19164): inviting someone is the + // only thing to do with an empty group, and it is the same card a DM transcript + // renders for the link, with a different CTA and destination. + val inviteUrl = state.groupInviteUrl + val inviteGroup = state.subject as? ChatSubject.Group + if (messages.itemCount == 0 && inviteUrl != null && inviteGroup != null) { + item(key = "group-invite-card") { + val card = remember(inviteGroup, inviteUrl, state.ruleCurrency) { + inviteGroup.toGroupInviteCard( + inviteUrl = inviteUrl, + currencyName = state.ruleCurrency?.nameInRequirement, + ) + } + Box( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center, + ) { + BoxWithConstraints(Modifier.fillMaxWidth(GROUP_INVITE_CARD_WIDTH)) { + GroupInviteLinkCard( + card = card, + minHeight = maxWidth * GroupInviteCardDefaults.ASPECT, + ctaLabel = stringResource(R.string.action_linkCard_invitePeople), + onStart = { onAction(ChatAction.InviteToGroup) } + .takeIf { !selecting }, + ) + } + } + } + } else { + item { + Box( modifier = Modifier - .fillMaxWidth(0.63f), - onRefreshContact = { onAction(ChatAction.RefreshContact) }, - // null hides the chevron and makes the card non-tappable when the - // profile isn't viewable (non-tip-DM chats). - onOpenProfile = if (canViewProfile && !selecting) { - { onAction(ChatAction.ViewProfile) } - } else { - null - }, - currencyName = state.ruleCurrency?.nameInRequirement, - ) + .fillParentMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ChatInfoCard( + subject = state.subject, + modifier = Modifier + .fillMaxWidth(0.63f), + onRefreshContact = { onAction(ChatAction.RefreshContact) }, + // null hides the chevron and makes the card non-tappable when the + // profile isn't viewable (non-tip-DM chats). + onOpenProfile = if (canViewProfile && !selecting) { + { onAction(ChatAction.ViewProfile) } + } else { + null + }, + currencyName = state.ruleCurrency?.nameInRequirement, + ) + } } } } From 3b3caedee8d1616a82411338ff125413b2cac5fc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 15:53:19 -0400 Subject: [PATCH 03/15] feat(messenger): invite people to a group from recent chats The invite sheet only offered Share and Copy. Nodes 10329:12104 and 10330:19549 add a multi-select list of the viewer's 1:1 chats and a message bar, so an invite can go straight into existing conversations. GroupInviteSheet is titled "Invite People" and draws Share and Copy Invite Link with the profile screen's ProfileShortcut, now internal. "Recent Chats" lists ChatCoordinator.feed(CONTACT_DM, TIP_DM). The "Add a message" field and white Invite button appear once a chat is picked. GroupInviteViewModel keeps the selection in tap order. Invite sends the link to each picked chat, then the trimmed message as a second message if it isn't blank. Chats are sent to concurrently and one failing does not stop the others; a failed send leaves its usual retryable row in that transcript. A chat whose link failed does not get the message, so a retry cannot put the card after its note. Once every send answers, the sheet dismisses and pushes the chat picked first. GroupInviteViewModelTest covers the bar's visibility, the link-then- message order, a blank message, landing on the first-picked chat, and failure isolation. --- .../core/src/main/res/values/strings.xml | 5 + .../flipcash/app/messenger/ChatFlowScreen.kt | 21 ++ .../internal/GroupInviteViewModel.kt | 135 ++++++++++ .../internal/screens/GroupInviteSheet.kt | 246 +++++++++++++++--- .../screens/profile/ProfileShortcuts.kt | 8 +- .../internal/GroupInviteViewModelTest.kt | 151 +++++++++++ 6 files changed, 528 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9ce95ec526..2c41077cd1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1118,6 +1118,11 @@ Invite People To Join Join %1$s on Flipcash and let\'s chat + + Invite People + Share + Recent Chats + Add a message Receive Tips From Everyone Add your name to receive tips Start Receiving Tips diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 87f878fa87..22e9aa2bce 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey @@ -22,6 +23,7 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.messenger.internal.StartSendCashOnceReady import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen @@ -271,15 +273,34 @@ private fun FlowInitPaymentScreen() { private fun FlowGroupInviteSheet() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val inviteViewModel = hiltViewModel() + val inviteState by inviteViewModel.state.collectAsStateWithLifecycle() // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates // down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current + LaunchedEffect(inviteViewModel) { + inviteViewModel.invited.collect { chatId -> + dismissSheet() + // Pushed through the conversation underneath, the same way a tapped invite card opens + // its group, so Back returns to the group the invites went out from. + viewModel.dispatchEvent( + ChatViewModel.Event.OpenScreen( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)) + ) + ) + } + } + GroupInviteSheet( inviteUrl = state.groupInviteUrl, group = state.subject as? ChatSubject.Group, + state = inviteState, onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteLinkShared) }, onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) }, + onToggle = inviteViewModel::toggle, + onMessageChanged = inviteViewModel::onMessageChanged, + onInvite = { state.groupInviteUrl?.let(inviteViewModel::invite) }, onDismiss = dismissSheet, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt new file mode 100644 index 0000000000..f6f7e21807 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -0,0 +1,135 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The invite sheet's "Recent Chats" (nodes 10330:19387, 10330:19549, 10329:12104): which 1:1 chats + * are picked, the message to go with the link, and sending both to each. + * + * Scoped to the sheet rather than kept on the conversation's view model: a selection means nothing + * once the sheet is gone, and the send has to outlive nothing but the sheet, which waits for it. + */ +@HiltViewModel +internal class GroupInviteViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val userManager: UserManager, + private val resources: ResourceHelper, +) : ViewModel() { + + data class State( + /** Null until the feed first emits, so an empty list means there are no 1:1 chats. */ + val recentChats: List? = null, + /** Picked chats in the order they were tapped. The first is where the send lands. */ + val selection: List = emptyList(), + val message: String = "", + val sending: Boolean = false, + ) { + /** The message bar only comes up once there is someone to send to (node 10330:19549). */ + val showsComposer: Boolean get() = selection.isNotEmpty() + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + // Buffered for the same reason as ReportViewModel.confirmed: it fires once, and dropping it + // would leave the sheet up after the invites went out. + private val _invited = Channel(Channel.BUFFERED) + + /** Emits the first-selected chat once every send has answered, which is where the sheet goes. */ + val invited: Flow = _invited.receiveAsFlow() + + init { + chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM) + .onEach { summaries -> + val selfId = userManager.accountId + val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) } + _state.update { it.copy(recentChats = chats) } + } + .launchIn(viewModelScope) + } + + fun toggle(chatId: ChatId) { + _state.update { state -> + if (state.sending) return@update state + val selection = if (chatId in state.selection) { + state.selection - chatId + } else { + state.selection + chatId + } + state.copy(selection = selection) + } + } + + fun onMessageChanged(message: String) { + _state.update { it.copy(message = message) } + } + + /** + * Sends [inviteUrl] to every selected chat, then the typed message after it as its own bubble + * when there is one (node 10330:23177). + * + * Each chat is sent to on its own, and one failing does not stop the rest. A failed send leaves + * a failed row in that chat's transcript with its retry, the same as a failed send from the + * composer. A chat whose link failed does not get the message: a retry would put the card after + * the text it was meant to introduce. + */ + fun invite(inviteUrl: String) { + val current = _state.value + val first = current.selection.firstOrNull() ?: return + if (current.sending) return + _state.update { it.copy(sending = true) } + + val message = current.message.trim() + viewModelScope.launch { + coroutineScope { + current.selection.map { chatId -> + async { sendInvite(chatId, inviteUrl, message) } + }.awaitAll() + } + _invited.send(first) + } + } + + private suspend fun sendInvite(chatId: ChatId, inviteUrl: String, message: String) { + val linkSent = send(chatId, inviteUrl) + if (linkSent && message.isNotEmpty()) send(chatId, message) + } + + private suspend fun send(chatId: ChatId, content: String): Boolean { + val result = try { + chatCoordinator.sendMessage(chatId, content) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + return result + .onFailure { trace("group invite failed to send - ${it.localizedMessage}") } + .isSuccess + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 993a220d37..f11c79ec0a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -1,48 +1,81 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.Icon +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.flipcash.app.core.media.rememberMediaUrl import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcut import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White05 +import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.ChoiceRow +import com.getcode.ui.components.TextInput import kotlinx.coroutines.launch /** - * Node 10127:118315 — the two ways to hand out the link to a group. + * Node 10329:12104 — handing out the link to a group, and sending it straight into 1:1 chats. * - * Both actions carry the same URL, built once by + * The Share and Copy tiles carry the same URL, built once by * [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id: there is no invite RPC, - * the id *is* the invite, and one builder is what keeps the shared link and the copied link - * identical. + * the id *is* the invite, and one builder is what keeps the shared link, the copied link and the + * sent link identical. * * @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not * joined — in which case the sheet is the title bar alone. Both entry points are gated on the same * value, so this is a race with a leave rather than a state to design for. * @param group what is being invited to, for the group's name and picture. The sheet only opens on * a group, so null is the frame before the subject resolves. + * @param state the Recent Chats selection and the message to send with the link. */ @Composable internal fun GroupInviteSheet( inviteUrl: String?, group: ChatSubject.Group?, + state: GroupInviteViewModel.State, onShare: () -> Unit, onCopy: () -> Unit, + onToggle: (ChatId) -> Unit, + onMessageChanged: (String) -> Unit, + onInvite: () -> Unit, onDismiss: () -> Unit, ) { val shareController = LocalShareController.current @@ -63,46 +96,187 @@ internal fun GroupInviteSheet( Column( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding(), + .navigationBarsPadding() + .imePadding(), ) { AppBarWithTitle( - title = stringResource(R.string.title_inviteToJoinGroup), + title = stringResource(R.string.title_invitePeople), titleAlignment = Alignment.CenterHorizontally, endContent = { AppBarDefaults.Close(onClick = onDismiss) }, ) - if (inviteUrl != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - ChoiceRow( - label = stringResource(R.string.action_sendInviteLink), - icon = R.drawable.ic_at, - onClick = { - onShare() - scope.launch { - shareController.present( - Shareable.GroupInvite( - url = inviteUrl, - title = group?.groupTitle, - imageUrl = pictureUrl.url, - imageCacheKey = picture - ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), - ) + if (inviteUrl == null) return@Column + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4), + horizontalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.staticGrid.x3, + Alignment.CenterHorizontally, + ), + ) { + ProfileShortcut( + label = stringResource(R.string.action_shareInviteLink), + onClick = { + onShare() + scope.launch { + shareController.present( + Shareable.GroupInvite( + url = inviteUrl, + title = group?.groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = picture + ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), ) - } - }, - ) - ChoiceRow( - label = stringResource(R.string.action_copyInviteLink), - icon = R.drawable.ic_copy, - onClick = onCopy, + ) + } + }, + ) { + ShortcutIcon(R.drawable.ic_share_os) + } + ProfileShortcut( + label = stringResource(R.string.action_copyInviteLink), + onClick = onCopy, + ) { + ShortcutIcon(R.drawable.ic_copy) + } + } + + val recentChats = state.recentChats.orEmpty() + if (recentChats.isNotEmpty()) { + Text( + modifier = Modifier.padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x2, + ), + text = stringResource(R.string.label_recentChats), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + + // Takes what is left of the sheet without claiming it, so a short list does not push the + // message bar to the bottom of the screen. + LazyColumn(modifier = Modifier.weight(1f, fill = false)) { + items(recentChats, key = { it.chatId.toString() }) { chat -> + RecentChatRow( + chat = chat, + selected = chat.chatId in state.selection, + enabled = !state.sending, + onClick = { onToggle(chat.chatId) }, ) } } + + if (state.showsComposer) { + InviteComposer( + sending = state.sending, + onMessageChanged = onMessageChanged, + onInvite = onInvite, + ) + } } } + +@Composable +private fun ShortcutIcon(icon: Int) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) +} + +/** Node 10330:19387 — one 1:1 chat, picked or not. The whole row takes the tap. */ +@Composable +private fun RecentChatRow( + chat: ConversationReference, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Checkbox, onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + ) + Text( + modifier = Modifier.weight(1f), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The same mark the token and currency pickers use, so this reads as the same kind of list. + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, + ) + } +} + +/** + * Node 10330:19549 — the message to go with the link, and the send. Only drawn once a chat is + * picked; the field keeps its own text, and the sheet's view model reads it at send time. + */ +@Composable +private fun InviteComposer( + sending: Boolean, + onMessageChanged: (String) -> Unit, + onInvite: () -> Unit, +) { + val message = rememberTextFieldState() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2) + .clip(CodeTheme.shapes.medium) + .background(White05) + .padding(CodeTheme.dimens.grid.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + TextInput( + modifier = Modifier.weight(1f), + state = message, + placeholder = stringResource(R.string.hint_addAMessage), + enabled = !sending, + onStateChanged = { onMessageChanged(message.text.toString()) }, + ) + Box( + modifier = Modifier + .clip(InviteButtonShape) + .background(White) + .clickable(enabled = !sending, role = Role.Button, onClick = onInvite) + .padding( + horizontal = CodeTheme.dimens.grid.x3, + vertical = CodeTheme.dimens.grid.x2, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.action_invite), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.background, + ) + } + } +} + +private val InviteButtonShape = RoundedCornerShape(6.dp) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 8157d37ead..3428e9ef6a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -103,17 +103,21 @@ internal fun ProfileShortcuts( } } +/** + * One labelled circle: the profile's shortcuts, and the invite sheet's Share and Copy tiles. + */ @Composable -private fun ProfileShortcut( +internal fun ProfileShortcut( label: String, onClick: () -> Unit, + modifier: Modifier = Modifier, glyph: @Composable () -> Unit, ) { // The whole column takes the tap, so the label is as much a target as the circle, but only the // circle shows the ripple. val interactionSource = remember { MutableInteractionSource() } Column( - modifier = Modifier + modifier = modifier .width(CodeTheme.dimens.staticGrid.x20) .clickable( interactionSource = interactionSource, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt new file mode 100644 index 0000000000..89fba49a5a --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -0,0 +1,151 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The invite sheet's selection and send (nodes 10330:19549, 10330:23177): the bar waits for a + * pick, each chat gets the link and then the message, one chat failing leaves the rest alone, and + * the sheet lands on the chat picked first. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GroupInviteViewModelTest { + + private val chatCoordinator = mockk(relaxed = true) { + every { feed(*anyVararg()) } returns flowOf(emptyList()) + coEvery { sendMessage(any(), any(), any()) } returns Result.success(mockk()) + } + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + + private val scheduler = TestCoroutineScheduler() + + private val alice = ChatId(ByteArray(32) { 1 }) + private val bob = ChatId(ByteArray(32) { 2 }) + private val carol = ChatId(ByteArray(32) { 3 }) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = GroupInviteViewModel(chatCoordinator, userManager, resources) + + @Test + fun `the message bar is hidden until a chat is picked`() { + val model = viewModel() + assertFalse(model.state.value.showsComposer) + + model.toggle(alice) + assertTrue(model.state.value.showsComposer) + + model.toggle(alice) + assertFalse(model.state.value.showsComposer) + } + + @Test + fun `each chat gets the link and then the message`() = runTest(scheduler) { + val model = viewModel() + model.toggle(alice) + model.toggle(bob) + model.onMessageChanged("You should join this chat!") + + model.invite(INVITE_URL) + advanceUntilIdle() + + coVerifyOrder { + chatCoordinator.sendMessage(alice, INVITE_URL, null) + chatCoordinator.sendMessage(alice, "You should join this chat!", null) + } + coVerifyOrder { + chatCoordinator.sendMessage(bob, INVITE_URL, null) + chatCoordinator.sendMessage(bob, "You should join this chat!", null) + } + } + + @Test + fun `a blank message sends only the link`() = runTest(scheduler) { + val model = viewModel() + model.toggle(alice) + model.onMessageChanged(" ") + + model.invite(INVITE_URL) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.sendMessage(alice, any(), any()) } + coVerify { chatCoordinator.sendMessage(alice, INVITE_URL, null) } + } + + @Test + fun `the sheet lands on the chat picked first, not the one listed first`() = runTest(scheduler) { + val model = viewModel() + model.toggle(carol) + model.toggle(alice) + + model.invite(INVITE_URL) + + assertEquals(carol, model.invited.first()) + } + + @Test + fun `one chat failing does not stop the others`() = runTest(scheduler) { + coEvery { chatCoordinator.sendMessage(alice, any(), any()) } returns + Result.failure(IllegalStateException("offline")) + coEvery { chatCoordinator.sendMessage(bob, any(), any()) } throws IllegalStateException("boom") + val model = viewModel() + model.toggle(alice) + model.toggle(bob) + model.toggle(carol) + model.onMessageChanged("hi") + + model.invite(INVITE_URL) + + // Still navigates, to the first pick, even though its own send failed. + assertEquals(alice, model.invited.first()) + coVerify { chatCoordinator.sendMessage(carol, INVITE_URL, null) } + coVerify { chatCoordinator.sendMessage(carol, "hi", null) } + // A chat whose link failed is not sent the message that was meant to follow it. + coVerify(exactly = 0) { chatCoordinator.sendMessage(alice, "hi", any()) } + coVerify(exactly = 0) { chatCoordinator.sendMessage(bob, "hi", any()) } + } + + @Test + fun `nothing is sent with nobody picked`() = runTest(scheduler) { + viewModel().invite(INVITE_URL) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.sendMessage(any(), any(), any()) } + } + + private companion object { + const val INVITE_URL = "https://app.flipcash.com/chat/6f1c3a9e" + } +} From e22c118d0eee8de2d2258a06625b2ae450def953 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:51:06 -0400 Subject: [PATCH 04/15] fix(messenger): keep the invite sheet's bar and Copy label on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Seeker, picking a chat in the invite sheet showed the message bar mostly below the screen edge, and the Copy tile read "Copy Invite L…". InviteToGroup was a WrapContentSheet. A wrap-content body is measured at its full height and then clipped, so a long chat list pushed the bar under the gesture area. As a full sheet the body is bounded: the list scrolls and the bar stays above it. ProfileShortcut fixed its width at staticGrid.x20, too narrow for "Copy Invite Link" on one line. It now takes that as a minimum, so the profile's one-word labels sit where they did and the longer label widens its tile. --- .../com/flipcash/app/core/chat/ChatStep.kt | 17 +++++++++++------ .../screens/profile/ProfileShortcuts.kt | 7 ++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index ca747ce83b..591dcae8bc 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -44,16 +44,21 @@ sealed interface ChatStep : FlowStep, Parcelable { ChatStep, NavigationRetVal, Sheet, WrapContentSheet /** - * Node 10127:118315 — the two ways to hand out a group's invite link. + * Nodes 10329:12104 and 10330:19549 — share or copy a group's invite link, or send it to + * recent 1:1 chats. * - * A [WrapContentSheet] over the conversation rather than a screen of its own: it is two rows, - * and inviting is something you do *from* the group, not a place you go. Reached from the empty - * transcript's CTA and from the group's profile, which is why it hangs off the chat flow rather - * than the create flow that used to end on it. + * A sheet over the conversation rather than a screen of its own: inviting is something you do + * *from* the group, not a place you go. Reached from the empty transcript's CTA and from the + * group's profile, which is why it hangs off the chat flow rather than the create flow that + * used to end on it. + * + * Not a [WrapContentSheet]: the chat list can run past the screen, and a wrap-content body is + * measured at its full height and then clipped, taking the message bar under it off the bottom. + * A full sheet bounds the body, so the list scrolls and the bar stays on screen. */ @Parcelize @Serializable - data object InviteToGroup : ChatStep, Sheet, WrapContentSheet + data object InviteToGroup : ChatStep, Sheet @Parcelize @Serializable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 3428e9ef6a..7abc3c3527 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -114,11 +114,12 @@ internal fun ProfileShortcut( glyph: @Composable () -> Unit, ) { // The whole column takes the tap, so the label is as much a target as the circle, but only the - // circle shows the ripple. + // circle shows the ripple. A minimum rather than a width: the profile's one-word labels sit in + // the same slot, and the invite sheet's "Copy Invite Link" widens it instead of ellipsizing. val interactionSource = remember { MutableInteractionSource() } Column( modifier = modifier - .width(CodeTheme.dimens.staticGrid.x20) + .widthIn(min = CodeTheme.dimens.staticGrid.x20) .clickable( interactionSource = interactionSource, indication = null, From 6cb09915628146adb5c171a0ec7883b4c61b10de Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 17:58:10 -0400 Subject: [PATCH 05/15] fix(messenger): draw the invite composer as one pill The invite sheet's message bar drew TextInput's own rounded, bordered field inside the pill, with the hint flush against that field's edge. Node 10329:11963 has one 14dp pill: a bare "Add a message" hint inset 16dp at 40% white, and a 34dp white Invite button inset 8dp. The field now takes transparent background and border colours and the button's height, so the hint and the button label share a baseline, and the pill carries the node's padding and corner radius. --- .../internal/screens/GroupInviteSheet.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index f11c79ec0a..e444d2387f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -25,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -46,6 +48,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.White50 +import com.getcode.theme.inputColors import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.TextInput @@ -232,8 +235,11 @@ private fun RecentChatRow( } /** - * Node 10330:19549 — the message to go with the link, and the send. Only drawn once a chat is + * Node 10329:11963 — the message to go with the link, and the send. Only drawn once a chat is * picked; the field keeps its own text, and the sheet's view model reads it at send time. + * + * One pill holds both: the field draws no box of its own, so the hint sits on the pill's inset + * like the design's bare "Add a message" label rather than in a second rounded field. */ @Composable private fun InviteComposer( @@ -246,9 +252,9 @@ private fun InviteComposer( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2) - .clip(CodeTheme.shapes.medium) + .clip(ComposerShape) .background(White05) - .padding(CodeTheme.dimens.grid.x1), + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { @@ -257,17 +263,21 @@ private fun InviteComposer( state = message, placeholder = stringResource(R.string.hint_addAMessage), enabled = !sending, + minHeight = InviteButtonHeight, + colors = inputColors( + borderColor = Color.Transparent, + backgroundColor = Color.Transparent, + placeholderColor = CodeTheme.colors.textMain.copy(alpha = 0.4f), + ), onStateChanged = { onMessageChanged(message.text.toString()) }, ) Box( modifier = Modifier + .height(InviteButtonHeight) .clip(InviteButtonShape) .background(White) .clickable(enabled = !sending, role = Role.Button, onClick = onInvite) - .padding( - horizontal = CodeTheme.dimens.grid.x3, - vertical = CodeTheme.dimens.grid.x2, - ), + .padding(horizontal = CodeTheme.dimens.grid.x3), contentAlignment = Alignment.Center, ) { Text( @@ -279,4 +289,8 @@ private fun InviteComposer( } } +// Node 10329:11963's measurements. The theme has no 14dp shape, and the button's 34dp height is +// what lines the field's text up with the button's label. +private val ComposerShape = RoundedCornerShape(14.dp) private val InviteButtonShape = RoundedCornerShape(6.dp) +private val InviteButtonHeight = 34.dp From 63f440e81166deb0bac3ff6ae477e6efa221620d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 18:16:15 -0400 Subject: [PATCH 06/15] fix(messenger): hold the invite list still and send from the chat composer The Recent Chats list followed the live DM feed, so a chat receiving a message while the sheet was open jumped to the top and moved rows under the viewer's finger. The view model now reads the feed once when the sheet opens. Rows get the dividers from node 10330:19585, inset to start at the name, and the header is ruled off below its label. The message bar is now the chat screen's ChatInput. A new ChatInputSubmit.Action shows a labelled button ("Invite") whether or not anything is typed, since the link goes out with or without a message. The list scrolls under the composer and blurs progressively behind it, as in node 10330:19709. --- .../internal/GroupInviteViewModel.kt | 18 +- .../internal/screens/GroupInviteSheet.kt | 226 ++++++++++-------- .../internal/GroupInviteViewModelTest.kt | 17 ++ .../getcode/ui/components/chat/ChatInput.kt | 34 ++- 4 files changed, 189 insertions(+), 106 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt index f6f7e21807..c8217baeac 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -20,8 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -64,13 +63,14 @@ internal class GroupInviteViewModel @Inject constructor( val invited: Flow = _invited.receiveAsFlow() init { - chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM) - .onEach { summaries -> - val selfId = userManager.accountId - val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) } - _state.update { it.copy(recentChats = chats) } - } - .launchIn(viewModelScope) + // One read, not a subscription: a chat receiving a message while the sheet is up would + // otherwise jump to the top and move the row out from under the viewer's finger. + viewModelScope.launch { + val summaries = chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM).first() + val selfId = userManager.accountId + val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) } + _state.update { it.copy(recentChats = chats) } + } } fun toggle(chatId: ChatId) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index e444d2387f..a60fffdb03 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -1,14 +1,15 @@ package com.flipcash.app.messenger.internal.screens import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -17,16 +18,23 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow 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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -45,13 +53,18 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme -import com.getcode.theme.White -import com.getcode.theme.White05 import com.getcode.theme.White50 -import com.getcode.theme.inputColors import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.TextInput +import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.components.chat.ChatInputSubmit +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch /** @@ -147,38 +160,46 @@ internal fun GroupInviteSheet( } val recentChats = state.recentChats.orEmpty() - if (recentChats.isNotEmpty()) { - Text( - modifier = Modifier.padding( - horizontal = CodeTheme.dimens.inset, - vertical = CodeTheme.dimens.grid.x2, + val hazeState = rememberHazeState() + val density = LocalDensity.current + var composerHeight by remember { mutableStateOf(0.dp) } + + // The list runs the rest of the sheet and scrolls under the message bar, which blurs what + // passes behind it more the lower it goes (node 10330:19709), rather than stopping above it. + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + contentPadding = PaddingValues( + bottom = if (state.showsComposer) composerHeight else 0.dp, ), - text = stringResource(R.string.label_recentChats), - style = CodeTheme.typography.textSmall, - color = White50, - ) - } + ) { + if (recentChats.isNotEmpty()) { + item(key = "header") { RecentChatsHeader() } + } + items(recentChats, key = { it.chatId.toString() }) { chat -> + RecentChatRow( + chat = chat, + selected = chat.chatId in state.selection, + enabled = !state.sending, + onClick = { onToggle(chat.chatId) }, + ) + } + } - // Takes what is left of the sheet without claiming it, so a short list does not push the - // message bar to the bottom of the screen. - LazyColumn(modifier = Modifier.weight(1f, fill = false)) { - items(recentChats, key = { it.chatId.toString() }) { chat -> - RecentChatRow( - chat = chat, - selected = chat.chatId in state.selection, - enabled = !state.sending, - onClick = { onToggle(chat.chatId) }, + if (state.showsComposer) { + InviteComposer( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { composerHeight = with(density) { it.height.toDp() } }, + hazeState = hazeState, + sending = state.sending, + onMessageChanged = onMessageChanged, + onInvite = onInvite, ) } } - - if (state.showsComposer) { - InviteComposer( - sending = state.sending, - onMessageChanged = onMessageChanged, - onInvite = onInvite, - ) - } } } @@ -192,7 +213,24 @@ private fun ShortcutIcon(icon: Int) { ) } -/** Node 10330:19387 — one 1:1 chat, picked or not. The whole row takes the tap. */ +/** Node 10330:19568 — the list's label, ruled off from the rows below it. */ +@Composable +private fun RecentChatsHeader() { + Column(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) { + Text( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.label_recentChats), + style = CodeTheme.typography.textSmall, + color = White50, + ) + HorizontalDivider(color = CodeTheme.colors.divider) + } +} + +/** + * Node 10330:19387 — one 1:1 chat, picked or not. The whole row takes the tap. Its divider starts + * at the name, not the avatar, as in node 10330:19585. + */ @Composable private fun RecentChatRow( chat: ConversationReference, @@ -204,9 +242,9 @@ private fun RecentChatRow( modifier = Modifier .fillMaxWidth() .clickable(enabled = enabled, role = Role.Checkbox, onClick = onClick) - .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + .padding(horizontal = CodeTheme.dimens.inset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(RowGap), ) { ContactAvatar( image = chat.image, @@ -216,21 +254,30 @@ private fun RecentChatRow( .requiredSize(CodeTheme.dimens.staticGrid.x6) .clip(CircleShape), ) - Text( - modifier = Modifier.weight(1f), - text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - // The same mark the token and currency pickers use, so this reads as the same kind of list. - Image( - painter = painterResource( - if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked - ), - contentDescription = null, - ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The same mark the token and currency pickers use, so this reads as the same kind + // of list. + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, + ) + } + HorizontalDivider(color = CodeTheme.colors.divider) + } } } @@ -238,59 +285,50 @@ private fun RecentChatRow( * Node 10329:11963 — the message to go with the link, and the send. Only drawn once a chat is * picked; the field keeps its own text, and the sheet's view model reads it at send time. * - * One pill holds both: the field draws no box of its own, so the hint sits on the pill's inset - * like the design's bare "Add a message" label rather than in a second rounded field. + * The chat screen's own composer, so typing here feels like typing in a chat, with an Invite label + * in place of the send arrow because the link goes out whether or not a message is typed. Behind + * it, the list blurs in from nothing at the top edge to full at the bottom. */ @Composable private fun InviteComposer( + hazeState: HazeState, sending: Boolean, onMessageChanged: (String) -> Unit, onInvite: () -> Unit, + modifier: Modifier = Modifier, ) { val message = rememberTextFieldState() - Row( - modifier = Modifier + LaunchedEffect(message) { + snapshotFlow { message.text.toString() }.collect(onMessageChanged) + } + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + Box( + modifier = modifier .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2) - .clip(ComposerShape) - .background(White05) - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + .hazeBlur( + input = HazeInput.Sources(hazeState), + style = material.then { + progressive(HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f)) + }, + ) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2), ) { - TextInput( - modifier = Modifier.weight(1f), - state = message, - placeholder = stringResource(R.string.hint_addAMessage), - enabled = !sending, - minHeight = InviteButtonHeight, - colors = inputColors( - borderColor = Color.Transparent, - backgroundColor = Color.Transparent, - placeholderColor = CodeTheme.colors.textMain.copy(alpha = 0.4f), + ChatInput( + modifier = Modifier.border( + CodeTheme.dimens.border, + CodeTheme.colors.divider, + CodeTheme.shapes.medium, ), - onStateChanged = { onMessageChanged(message.text.toString()) }, + enabled = !sending, + hint = stringResource(R.string.hint_addAMessage), + state = message, + submit = ChatInputSubmit.Action(label = stringResource(R.string.action_invite)) { + onInvite() + }, ) - Box( - modifier = Modifier - .height(InviteButtonHeight) - .clip(InviteButtonShape) - .background(White) - .clickable(enabled = !sending, role = Role.Button, onClick = onInvite) - .padding(horizontal = CodeTheme.dimens.grid.x3), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.action_invite), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.background, - ) - } } } -// Node 10329:11963's measurements. The theme has no 14dp shape, and the button's 34dp height is -// what lines the field's text up with the button's label. -private val ComposerShape = RoundedCornerShape(14.dp) -private val InviteButtonShape = RoundedCornerShape(6.dp) -private val InviteButtonHeight = 34.dp +// Node 10330:19572: the gap between avatar and name, which is also where the row's divider starts. +private val RowGap = 16.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index 89fba49a5a..adfee32fea 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.coVerify @@ -11,6 +12,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScheduler @@ -137,6 +139,21 @@ class GroupInviteViewModelTest { coVerify(exactly = 0) { chatCoordinator.sendMessage(bob, "hi", any()) } } + @Test + fun `the list is the feed as the sheet opened, not later updates`() = runTest(scheduler) { + val feed = MutableSharedFlow>(replay = 1) + every { chatCoordinator.feed(*anyVararg()) } returns feed + feed.emit(emptyList()) + + val model = viewModel() + advanceUntilIdle() + assertEquals(emptyList(), model.state.value.recentChats) + + feed.emit(listOf(mockk(relaxed = true))) + advanceUntilIdle() + assertEquals(emptyList(), model.state.value.recentChats) + } + @Test fun `nothing is sent with nobody picked`() = runTest(scheduler) { viewModel().invite(INVITE_URL) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 7a086384a2..9a27bb96e4 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -63,6 +64,12 @@ sealed interface ChatInputSubmit { /** Confirms an edit of a message already in the transcript. */ data class ConfirmEdit(override val perform: () -> Unit) : ChatInputSubmit + + /** + * A named action that does not need the field's text, such as sending a group invite with an + * optional message. Shown as [label] whether or not anything is typed. + */ + data class Action(val label: String, override val perform: () -> Unit) : ChatInputSubmit } @Composable @@ -75,7 +82,7 @@ fun ChatInput( submit: ChatInputSubmit, ) { val shape = CodeTheme.shapes.medium - val sendVisible = state.text.isNotEmpty() + val sendVisible = state.text.isNotEmpty() || submit is ChatInputSubmit.Action val sendSpec = spring(dampingRatio = 0.66f, stiffness = 4000f) val sendAlpha by animateFloatAsState( targetValue = if (sendVisible) 1f else 0f, @@ -139,7 +146,7 @@ fun ChatInput( .clip(CodeTheme.shapes.extraSmall) // Reads the current submit rather than the one the crossfade happens to // be showing, so a tap mid-transition does what the composer is now for. - .clickable(enabled = sendVisible) { submit.perform() } + .clickable(enabled = sendVisible && enabled) { submit.perform() } .padding(CodeTheme.dimens.staticGrid.x1), ) { // The button stays put and only its glyph changes, so entering and leaving edit @@ -156,7 +163,14 @@ fun ChatInput( }, label = "send glyph", ) { target -> - if (target is ChatInputSubmit.ConfirmEdit) { + if (target is ChatInputSubmit.Action) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.staticGrid.x2), + text = target.label, + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + } else if (target is ChatInputSubmit.ConfirmEdit) { Icon( modifier = Modifier .testTag("chat_confirm_edit_icon") @@ -209,6 +223,20 @@ private fun Preview_ChatInput_Typing() { } } +@Preview +@Composable +private fun Preview_ChatInput_Action() { + DesignSystem { + Box(modifier = Modifier.background(Color(0xFF19191A))) { + ChatInput( + modifier = Modifier.padding(15.dp), + hint = "Add a message", + submit = ChatInputSubmit.Action(label = "Invite") {}, + ) + } + } +} + @Preview @Composable private fun Preview_ChatInput_Editing() { From 8adaab70be25a8479951ccf2466f1fcd77b938b5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 19:03:10 -0400 Subject: [PATCH 07/15] feat(messenger): offer groups in the invite sheet's recent chats The sheet only listed 1:1 chats, so a group link could not be passed to another group without leaving the sheet to share it. The feed now includes GROUP chats, mixed with DMs in the feed's own order. The group being invited to is left out of its own list: the sheet names it through GroupInviteViewModel.inviteTo, which also stops it from being picked. Group rows already take their title and picture from toConversationReference. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 4 ++ .../internal/GroupInviteViewModel.kt | 22 ++++++++--- .../internal/screens/GroupInviteSheet.kt | 2 +- .../internal/GroupInviteViewModelTest.kt | 37 +++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 22e9aa2bce..988ca8f291 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -279,6 +279,10 @@ private fun FlowGroupInviteSheet() { // down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current + LaunchedEffect(inviteViewModel, state.chatId) { + state.chatId?.let(inviteViewModel::inviteTo) + } + LaunchedEffect(inviteViewModel) { inviteViewModel.invited.collect { chatId -> dismissSheet() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt index c8217baeac..1ab92ddfe1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -27,8 +27,8 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * The invite sheet's "Recent Chats" (nodes 10330:19387, 10330:19549, 10329:12104): which 1:1 chats - * are picked, the message to go with the link, and sending both to each. + * The invite sheet's "Recent Chats" (nodes 10330:19387, 10330:19549, 10329:12104): which chats, + * 1:1 or group, are picked, the message to go with the link, and sending both to each. * * Scoped to the sheet rather than kept on the conversation's view model: a selection means nothing * once the sheet is gone, and the send has to outlive nothing but the sheet, which waits for it. @@ -41,8 +41,10 @@ internal class GroupInviteViewModel @Inject constructor( ) : ViewModel() { data class State( - /** Null until the feed first emits, so an empty list means there are no 1:1 chats. */ + /** Null until the feed first emits, so an empty list means there are no chats to offer. */ val recentChats: List? = null, + /** The group the invites are for, which is left out of its own list. */ + val groupId: ChatId? = null, /** Picked chats in the order they were tapped. The first is where the send lands. */ val selection: List = emptyList(), val message: String = "", @@ -50,6 +52,10 @@ internal class GroupInviteViewModel @Inject constructor( ) { /** The message bar only comes up once there is someone to send to (node 10330:19549). */ val showsComposer: Boolean get() = selection.isNotEmpty() + + /** [recentChats] without the group being invited to: sending a group its own link is a no-op. */ + val invitable: List? + get() = recentChats?.filterNot { it.chatId == groupId } } private val _state = MutableStateFlow(State()) @@ -66,16 +72,22 @@ internal class GroupInviteViewModel @Inject constructor( // One read, not a subscription: a chat receiving a message while the sheet is up would // otherwise jump to the top and move the row out from under the viewer's finger. viewModelScope.launch { - val summaries = chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM).first() + val summaries = chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM, ChatType.GROUP) + .first() val selfId = userManager.accountId val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) } _state.update { it.copy(recentChats = chats) } } } + /** Names the group the sheet invites to, so it drops out of the list and cannot be picked. */ + fun inviteTo(groupId: ChatId) { + _state.update { it.copy(groupId = groupId, selection = it.selection - groupId) } + } + fun toggle(chatId: ChatId) { _state.update { state -> - if (state.sending) return@update state + if (state.sending || chatId == state.groupId) return@update state val selection = if (chatId in state.selection) { state.selection - chatId } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index a60fffdb03..286cdddece 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -159,7 +159,7 @@ internal fun GroupInviteSheet( } } - val recentChats = state.recentChats.orEmpty() + val recentChats = state.invitable.orEmpty() val hazeState = rememberHazeState() val density = LocalDensity.current var composerHeight by remember { mutableStateOf(0.dp) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index adfee32fea..e44f1cefa6 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatSummary @@ -10,6 +12,8 @@ import io.mockk.coVerify import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlin.time.Instant import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -154,6 +158,28 @@ class GroupInviteViewModelTest { assertEquals(emptyList(), model.state.value.recentChats) } + @Test + fun `the list offers groups as well as 1-1 chats`() { + viewModel() + verify { chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM, ChatType.GROUP) } + } + + @Test + fun `the group being invited to is left out and cannot be picked`() = runTest(scheduler) { + val group = ChatId(ByteArray(32) { 9 }) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf(summary(alice, ChatType.CONTACT_DM), summary(group, ChatType.GROUP)), + ) + val model = viewModel() + advanceUntilIdle() + + model.inviteTo(group) + model.toggle(group) + + assertEquals(listOf(alice), model.state.value.invitable?.map { it.chatId }) + assertEquals(emptyList(), model.state.value.selection) + } + @Test fun `nothing is sent with nobody picked`() = runTest(scheduler) { viewModel().invite(INVITE_URL) @@ -165,4 +191,15 @@ class GroupInviteViewModelTest { private companion object { const val INVITE_URL = "https://app.flipcash.com/chat/6f1c3a9e" } + + private fun summary(chatId: ChatId, type: ChatType) = ChatSummary( + metadata = ChatMetadata( + chatId = chatId, + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + ), + unreadCount = 0, + ) } From 896cca7d81b0576a8a7bff9fa6ff50b628ac4170 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 20:08:01 -0400 Subject: [PATCH 08/15] fix(chat-ui): pad the group invite card to iOS's metrics The card's padding and gaps came from the theme's inset and grid, which grow on larger phones, so the card drew taller than iOS's GroupCardView. It now uses iOS's fixed values: 12dp side and bottom padding, 13dp above the title and 11dp above each requirement line. --- .../screens/profile/ProfileShortcuts.kt | 15 +++++-------- .../shared/chat/ui/GroupInviteLinkCard.kt | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 7abc3c3527..8157d37ead 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -103,23 +103,18 @@ internal fun ProfileShortcuts( } } -/** - * One labelled circle: the profile's shortcuts, and the invite sheet's Share and Copy tiles. - */ @Composable -internal fun ProfileShortcut( +private fun ProfileShortcut( label: String, onClick: () -> Unit, - modifier: Modifier = Modifier, glyph: @Composable () -> Unit, ) { // The whole column takes the tap, so the label is as much a target as the circle, but only the - // circle shows the ripple. A minimum rather than a width: the profile's one-word labels sit in - // the same slot, and the invite sheet's "Copy Invite Link" widens it instead of ellipsizing. + // circle shows the ripple. val interactionSource = remember { MutableInteractionSource() } Column( - modifier = modifier - .widthIn(min = CodeTheme.dimens.staticGrid.x20) + modifier = Modifier + .width(CodeTheme.dimens.staticGrid.x20) .clickable( interactionSource = interactionSource, indication = null, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt index 95d7240358..c1d9062781 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -131,11 +131,11 @@ fun GroupInviteLinkCard( modifier = Modifier .fillMaxSize() .padding( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, + start = GroupInviteCardDefaults.PADDING, + end = GroupInviteCardDefaults.PADDING, // The avatar's centre sits on the band's bottom edge. top = GroupInviteCardDefaults.BAND_HEIGHT - GroupInviteCardDefaults.AVATAR / 2, - bottom = CodeTheme.dimens.inset, + bottom = GroupInviteCardDefaults.PADDING, ), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -153,7 +153,7 @@ fun GroupInviteLinkCard( ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.TITLE_GAP), text = title, style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, @@ -218,7 +218,7 @@ fun GroupInviteLinkCard( private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { requirement.amount?.let { amount -> Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.REQUIREMENT_GAP), text = if (requirement.currencyName != null) { stringResource(R.string.label_chat_balanceRequirement, amount, requirement.currencyName) } else { @@ -231,7 +231,7 @@ private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { } if (requirement.staffOnly) { Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.REQUIREMENT_GAP), text = stringResource(R.string.label_chat_staffRequirement), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, @@ -249,6 +249,15 @@ object GroupInviteCardDefaults { internal val BAND_HEIGHT = 46.dp + /** + * iOS's `GroupCardView.Layout`: the card's side and bottom padding, and the gaps above the + * title and the requirement. Fixed, not the theme's inset, which widens on larger phones and + * made the card taller than iOS's. + */ + internal val PADDING = 12.dp + internal val TITLE_GAP = 13.dp + internal val REQUIREMENT_GAP = 11.dp + /** Provisional, pending design sign-off. */ internal const val BAND_OPACITY = 0.28f From bb317f0ff3e1bc33a078171bc572ff3248e34ea3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 20:08:01 -0400 Subject: [PATCH 09/15] feat(messenger): float the invite sheet's title and composer over its list This ports the iOS sheet (code-payments/code-ios-app#873, node 10330:19387). The sheet no longer pins a header: the title bar, with a glass close button, and the message composer float over one list that scrolls under both. - "Recent Chats" is an ordinary list row with a divider under it. - Share and Copy are their own tiles per node 10330:19400: two equal 95dp tiles at 5% white with wrapping labels. Copy turns into "Copied" with a check. ProfileShortcut goes back to private and its fixed width. - The composer appears once a chat is picked and slides up on a spring rather than fading, since a fading glass field shows the list through it. It has no outline, and ChatInputSubmit.Action gains a busy flag that swaps the Invite label for a spinner while sending. - Both edges fade to the sheet's surface colour, the same scrim the Chats list draws under its title bar, instead of blurring. The bottom fade is drawn whether or not the composer is up, so it holds still while the composer slides through it. --- .../core/src/main/res/values/strings.xml | 1 + .../internal/screens/GroupInviteSheet.kt | 353 ++++++++++++------ .../getcode/ui/components/chat/ChatInput.kt | 51 ++- 3 files changed, 280 insertions(+), 125 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 2c41077cd1..68da137f42 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1115,6 +1115,7 @@ Invite to Join Group Send Invite Link Copy Invite Link + Copied Invite People To Join Join %1$s on Flipcash and let\'s chat diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 286cdddece..bd7226d890 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -1,23 +1,34 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image -import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.Icon import androidx.compose.material.Text @@ -33,18 +44,23 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.flipcash.app.core.media.rememberMediaUrl import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.GroupInviteViewModel -import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcut import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R @@ -53,14 +69,15 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit import dev.chrisbanes.haze.HazeInput -import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeSource @@ -109,72 +126,86 @@ internal fun GroupInviteSheet( ?: BlobAccessContext.Owned, ) - Column( + val hazeState = rememberHazeState() + val density = LocalDensity.current + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + val scrimColor = CodeTheme.colors.surface + var headerHeight by remember { mutableStateOf(0.dp) } + var fieldHeight by remember { mutableStateOf(0.dp) } + var copied by remember { mutableStateOf(false) } + + // Held above the gesture bar, and dropped once the keyboard is up: the keyboard already lifts + // the whole sheet clear of it, so counting both would leave a gap over the keys. + val navigationBar = WindowInsets.navigationBars + .exclude(WindowInsets.ime) + .asPaddingValues() + .calculateBottomPadding() + // The field sits inside the bottom scrim, which is as tall as the header, so the two edges fade + // the same depth. iOS reaches the screen edge; here the gesture bar keeps it higher. + val composerBottom = maxOf(headerHeight - fieldHeight, ComposerMinBottom, navigationBar) + val recentChats = state.invitable.orEmpty() + + // No pinned header: the title bar and, once a chat is picked, the message field float over the + // one list, which scrolls under both and fades out at each edge (node 10330:19387). + Box( modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() + .fillMaxSize() .imePadding(), ) { - AppBarWithTitle( - title = stringResource(R.string.title_invitePeople), - titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close(onClick = onDismiss) }, - ) - - if (inviteUrl == null) return@Column - - Row( + LazyColumn( modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4), - horizontalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.staticGrid.x3, - Alignment.CenterHorizontally, + .fillMaxSize() + .hazeSource(hazeState), + contentPadding = PaddingValues( + top = headerHeight, + bottom = if (state.showsComposer) { + maxOf(headerHeight, composerBottom + fieldHeight) + } else { + headerHeight + }, ), ) { - ProfileShortcut( - label = stringResource(R.string.action_shareInviteLink), - onClick = { - onShare() - scope.launch { - shareController.present( - Shareable.GroupInvite( - url = inviteUrl, - title = group?.groupTitle, - imageUrl = pictureUrl.url, - imageCacheKey = picture - ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), - ) + if (inviteUrl != null) { + item(key = "links") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ListInset, vertical = TileRowVertical), + horizontalArrangement = Arrangement.spacedBy(TileGap), + ) { + InviteLinkTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_share_os, + label = stringResource(R.string.action_shareInviteLink), + onClick = { + onShare() + scope.launch { + shareController.present( + Shareable.GroupInvite( + url = inviteUrl, + title = group?.groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = picture + ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), + ) + ) + } + }, + ) + InviteLinkTile( + modifier = Modifier.weight(1f), + icon = if (copied) R.drawable.ic_check else R.drawable.ic_copy, + label = stringResource( + if (copied) R.string.action_inviteLinkCopied + else R.string.action_copyInviteLink + ), + onClick = { + onCopy() + copied = true + }, ) } - }, - ) { - ShortcutIcon(R.drawable.ic_share_os) - } - ProfileShortcut( - label = stringResource(R.string.action_copyInviteLink), - onClick = onCopy, - ) { - ShortcutIcon(R.drawable.ic_copy) - } - } - - val recentChats = state.invitable.orEmpty() - val hazeState = rememberHazeState() - val density = LocalDensity.current - var composerHeight by remember { mutableStateOf(0.dp) } - - // The list runs the rest of the sheet and scrolls under the message bar, which blurs what - // passes behind it more the lower it goes (node 10330:19709), rather than stopping above it. - Box(modifier = Modifier.weight(1f)) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .hazeSource(hazeState), - contentPadding = PaddingValues( - bottom = if (state.showsComposer) composerHeight else 0.dp, - ), - ) { + } if (recentChats.isNotEmpty()) { item(key = "header") { RecentChatsHeader() } } @@ -187,38 +218,118 @@ internal fun GroupInviteSheet( ) } } + } - if (state.showsComposer) { - InviteComposer( - modifier = Modifier - .align(Alignment.BottomCenter) - .onSizeChanged { composerHeight = with(density) { it.height.toDp() } }, - hazeState = hazeState, - sending = state.sending, - onMessageChanged = onMessageChanged, - onInvite = onInvite, - ) - } + // Always drawn, whether or not the field is up, so the edge stays still while the field + // slides through it. A colour scrim like the top one, not a blur. + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(headerHeight) + .drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, scrimColor), + startY = size.height - scrimHeight, + endY = size.height, + ), + topLeft = Offset(0f, size.height - scrimHeight), + size = size.copy(height = scrimHeight), + ) + }, + ) + + AppBarWithTitle( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + // The Chats list's scrim: the surface colour fading out a little past the bar, + // drawn behind it so the tail stays out of the bar's measured height. + .drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(scrimColor, Color.Transparent), + startY = 0f, + endY = scrimHeight, + ), + size = size.copy(height = scrimHeight), + ) + } + .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, + title = stringResource(R.string.title_invitePeople), + titleAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(HeaderPadding), + endContent = { AppBarDefaults.Close(hazeState = hazeState, onClick = onDismiss) }, + ) + + // Slides rather than fades: a fading blur shows the unblurred list through it for the + // length of the fade. + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = inviteUrl != null && state.showsComposer, + enter = slideInVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, + exit = slideOutVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, + ) { + InviteComposer( + modifier = Modifier + .padding(horizontal = ListInset) + .padding(bottom = composerBottom) + .onSizeChanged { fieldHeight = with(density) { it.height.toDp() } }, + hazeState = hazeState, + material = material, + sending = state.sending, + onMessageChanged = onMessageChanged, + onInvite = onInvite, + ) } } } +/** + * Node 10330:19400 — Share or Copy: a glyph over its label, the pair splitting the row. The label + * wraps rather than truncates, so the tile grows past its minimum height instead. + */ @Composable -private fun ShortcutIcon(icon: Int) { - Icon( - painter = painterResource(icon), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) +private fun InviteLinkTile( + icon: Int, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .heightIn(min = TileMinHeight) + .clip(TileShape) + .background(White05) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = TilePaddingHorizontal, vertical = TilePaddingVertical), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TileIconGap, Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(TileIconSize), + ) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = White50, + textAlign = TextAlign.Center, + ) + } } -/** Node 10330:19568 — the list's label, ruled off from the rows below it. */ +/** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ @Composable private fun RecentChatsHeader() { - Column(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) { + Column(modifier = Modifier.padding(horizontal = ListInset)) { Text( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), + modifier = Modifier.padding(vertical = HeaderRowVertical), text = stringResource(R.string.label_recentChats), style = CodeTheme.typography.textSmall, color = White50, @@ -228,7 +339,7 @@ private fun RecentChatsHeader() { } /** - * Node 10330:19387 — one 1:1 chat, picked or not. The whole row takes the tap. Its divider starts + * Node 10330:19387 — one chat, 1:1 or group, picked or not. The whole row takes the tap. Its divider starts * at the name, not the avatar, as in node 10330:19585. */ @Composable @@ -242,7 +353,7 @@ private fun RecentChatRow( modifier = Modifier .fillMaxWidth() .clickable(enabled = enabled, role = Role.Checkbox, onClick = onClick) - .padding(horizontal = CodeTheme.dimens.inset), + .padding(horizontal = ListInset), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(RowGap), ) { @@ -282,16 +393,17 @@ private fun RecentChatRow( } /** - * Node 10329:11963 — the message to go with the link, and the send. Only drawn once a chat is - * picked; the field keeps its own text, and the sheet's view model reads it at send time. + * Node 10329:11963 — the message to go with the link, and the send. The field keeps its own text, + * and the sheet's view model reads it at send time. * - * The chat screen's own composer, so typing here feels like typing in a chat, with an Invite label - * in place of the send arrow because the link goes out whether or not a message is typed. Behind - * it, the list blurs in from nothing at the top edge to full at the bottom. + * The chat screen's own composer and glass, so typing here feels like typing in a chat, but + * without its outline, since the Figma pill has none. An Invite label replaces the send arrow + * because the link goes out whether or not a message is typed. */ @Composable private fun InviteComposer( hazeState: HazeState, + material: HazeBlurStyle, sending: Boolean, onMessageChanged: (String) -> Unit, onInvite: () -> Unit, @@ -301,34 +413,41 @@ private fun InviteComposer( LaunchedEffect(message) { snapshotFlow { message.text.toString() }.collect(onMessageChanged) } - val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) - Box( - modifier = modifier - .fillMaxWidth() - .hazeBlur( - input = HazeInput.Sources(hazeState), - style = material.then { - progressive(HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f)) - }, - ) - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2), - ) { - ChatInput( - modifier = Modifier.border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CodeTheme.shapes.medium, - ), - enabled = !sending, - hint = stringResource(R.string.hint_addAMessage), - state = message, - submit = ChatInputSubmit.Action(label = stringResource(R.string.action_invite)) { - onInvite() - }, - ) - } + ChatInput( + modifier = modifier.hazeBlur(HazeInput.Sources(hazeState), material), + enabled = !sending, + hint = stringResource(R.string.hint_addAMessage), + state = message, + submit = ChatInputSubmit.Action( + label = stringResource(R.string.action_invite), + busy = sending, + perform = onInvite, + ), + ) } +// Node 10330:19387 and its children. Fixed rather than the theme's inset, which narrows on small +// screens, because the design holds these at 20 at every width. +private val ListInset = 20.dp +private val HeaderPadding = 16.dp +private val HeaderRowVertical = 16.dp +private val TileRowVertical = 12.dp +private val TileGap = 10.dp +private val TileMinHeight = 95.dp +private val TileShape = RoundedCornerShape(6.dp) +private val TilePaddingHorizontal = 8.dp +private val TilePaddingVertical = 16.dp +private val TileIconSize = 28.dp +private val TileIconGap = 6.dp + // Node 10330:19572: the gap between avatar and name, which is also where the row's divider starts. private val RowGap = 16.dp + +private val ComposerMinBottom = 8.dp + +/** How far each edge scrim runs past its bar, as on the Chats list. */ +private val ScrimTail = 48.dp +// Far enough that the field starts fully below the sheet's bottom edge. +private val ComposerTravel = 120.dp +// iOS's spring(response: 0.35): stiffness (2π / 0.35)² and its default damping. +private val ComposerSpring = spring(dampingRatio = 0.825f, stiffness = 322f) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 9a27bb96e4..5202550fa3 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -67,9 +68,14 @@ sealed interface ChatInputSubmit { /** * A named action that does not need the field's text, such as sending a group invite with an - * optional message. Shown as [label] whether or not anything is typed. + * optional message. Shown as [label] whether or not anything is typed, or as a spinner in the + * label's place while [busy], so the button keeps its width as the action runs. */ - data class Action(val label: String, override val perform: () -> Unit) : ChatInputSubmit + data class Action( + val label: String, + val busy: Boolean = false, + override val perform: () -> Unit, + ) : ChatInputSubmit } @Composable @@ -164,12 +170,26 @@ fun ChatInput( label = "send glyph", ) { target -> if (target is ChatInputSubmit.Action) { - Text( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.staticGrid.x2), - text = target.label, - style = CodeTheme.typography.textMedium, - color = Color.Black, - ) + // Reads the live submit, not the crossfade's captured target: the busy + // flag changes without changing the kind, so no crossfade runs for it. + val busy = (submit as? ChatInputSubmit.Action)?.busy == true + Box(contentAlignment = Alignment.Center) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.staticGrid.x2) + .graphicsLayer { alpha = if (busy) 0f else 1f }, + text = target.label, + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + color = Color.Black, + strokeWidth = 2.dp, + ) + } + } } else if (target is ChatInputSubmit.ConfirmEdit) { Icon( modifier = Modifier @@ -237,6 +257,21 @@ private fun Preview_ChatInput_Action() { } } +@Preview +@Composable +private fun Preview_ChatInput_ActionBusy() { + DesignSystem { + Box(modifier = Modifier.background(Color(0xFF19191A))) { + ChatInput( + modifier = Modifier.padding(15.dp), + hint = "Add a message", + enabled = false, + submit = ChatInputSubmit.Action(label = "Invite", busy = true) {}, + ) + } + } +} + @Preview @Composable private fun Preview_ChatInput_Editing() { From 76df4e52ddd0c5088996025955fb3f4c93d4033d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 20:39:40 -0400 Subject: [PATCH 10/15] fix(messenger): clip the invite composer's glass to its pill ChatInput clips to its rounded shape and then applies the caller's modifier, so the padding the sheet passed in put the rounded clip on the padded box and left the blur square inside it. The square corners showed past the pill. The inset now sits on a wrapper. The pill also drew ChatInput's 68% fill over the haze tint, which made it opaque. It now takes one fill, node 10329:11963's 46% #1E1E1E, over the blur. ChatInput gains a containerColor parameter for callers that draw their own glass; its default is the old fill, so the chat screen is unchanged. --- .../internal/screens/GroupInviteSheet.kt | 39 +++++++++++++------ .../getcode/ui/components/chat/ChatInput.kt | 8 +++- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index bd7226d890..8ed0f9d3eb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -78,6 +78,7 @@ import com.getcode.ui.components.chat.ChatInputSubmit import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeSource @@ -273,17 +274,22 @@ internal fun GroupInviteSheet( enter = slideInVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, exit = slideOutVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, ) { - InviteComposer( + // Inset in a wrapper: ChatInput clips to its pill before applying its modifier, so + // padding passed to it would round the padded box while the glass drew square inside. + Box( modifier = Modifier .padding(horizontal = ListInset) - .padding(bottom = composerBottom) - .onSizeChanged { fieldHeight = with(density) { it.height.toDp() } }, - hazeState = hazeState, - material = material, - sending = state.sending, - onMessageChanged = onMessageChanged, - onInvite = onInvite, - ) + .padding(bottom = composerBottom), + ) { + InviteComposer( + modifier = Modifier + .onSizeChanged { fieldHeight = with(density) { it.height.toDp() } }, + hazeState = hazeState, + sending = state.sending, + onMessageChanged = onMessageChanged, + onInvite = onInvite, + ) + } } } } @@ -403,18 +409,27 @@ private fun RecentChatRow( @Composable private fun InviteComposer( hazeState: HazeState, - material: HazeBlurStyle, sending: Boolean, onMessageChanged: (String) -> Unit, onInvite: () -> Unit, modifier: Modifier = Modifier, ) { + // One fill, node 10329:11963's 46% #1E1E1E, over a blur of the rows beneath. ChatInput's own + // fill is dropped because stacking it on the tint made the pill opaque and hid its edges. + // The HazeBlurStyle builder is not a @Composable scope, so the theme read is hoisted above it. + val backdrop = CodeTheme.colors.surface + val glass = HazeBlurStyle { + blurRadius(ComposerBlurRadius) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(ComposerFill))) + } val message = rememberTextFieldState() LaunchedEffect(message) { snapshotFlow { message.text.toString() }.collect(onMessageChanged) } ChatInput( - modifier = modifier.hazeBlur(HazeInput.Sources(hazeState), material), + modifier = modifier.hazeBlur(HazeInput.Sources(hazeState), glass), + containerColor = Color.Transparent, enabled = !sending, hint = stringResource(R.string.hint_addAMessage), state = message, @@ -429,6 +444,8 @@ private fun InviteComposer( // Node 10330:19387 and its children. Fixed rather than the theme's inset, which narrows on small // screens, because the design holds these at 20 at every width. private val ListInset = 20.dp +private val ComposerFill = Color(0x751E1E1E) +private val ComposerBlurRadius = 24.dp private val HeaderPadding = 16.dp private val HeaderRowVertical = 16.dp private val TileRowVertical = 12.dp diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 5202550fa3..748c563cfe 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -78,6 +78,11 @@ sealed interface ChatInputSubmit { ) : ChatInputSubmit } +object ChatInputDefaults { + /** The field's own fill. A caller that draws its own glass behind the field passes transparent. */ + val ContainerColor = Color(0xAD1E1E1E) +} + @Composable fun ChatInput( modifier: Modifier = Modifier, @@ -85,6 +90,7 @@ fun ChatInput( hint: String = "", state: TextFieldState = rememberTextFieldState(), focusRequester: FocusRequester = remember { FocusRequester() }, + containerColor: Color = ChatInputDefaults.ContainerColor, submit: ChatInputSubmit, ) { val shape = CodeTheme.shapes.medium @@ -131,7 +137,7 @@ fun ChatInput( bottom = CodeTheme.dimens.staticGrid.x2, ), colors = inputColors( - backgroundColor = Color(0xAD1E1E1E), + backgroundColor = containerColor, borderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), From 72c62a9b2c311e4bf6d7e76cee1f16efdbcac287 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 21:02:19 -0400 Subject: [PATCH 11/15] fix(messenger): keep the invite sheet open when a fling hits the list's top A fling that reached the top of Recent Chats carried on into the bottom sheet and dismissed it. The list now uses sheetResignmentBehavior, as the other scrolling sheets do, so leftover momentum stops at the top. A fresh drag down from the top still closes the sheet. --- .../app/messenger/internal/screens/GroupInviteSheet.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 8ed0f9d3eb..1ac1014ae0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState @@ -75,6 +76,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit +import com.getcode.ui.utils.sheetResignmentBehavior import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle @@ -86,7 +88,8 @@ import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch /** - * Node 10329:12104 — handing out the link to a group, and sending it straight into 1:1 chats. + * Node 10329:12104 — handing out the link to a group, and sending it straight into the + * viewer's other chats. * * The Share and Copy tiles carry the same URL, built once by * [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id: there is no invite RPC, @@ -128,6 +131,7 @@ internal fun GroupInviteSheet( ) val hazeState = rememberHazeState() + val listState = rememberLazyListState() val density = LocalDensity.current val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) val scrimColor = CodeTheme.colors.surface @@ -156,7 +160,11 @@ internal fun GroupInviteSheet( LazyColumn( modifier = Modifier .fillMaxSize() + // Without this, a fling that reaches the top of the list carries on into the + // sheet and dismisses it. + .sheetResignmentBehavior(listState) .hazeSource(hazeState), + state = listState, contentPadding = PaddingValues( top = headerHeight, bottom = if (state.showsComposer) { From 80b6064996eba9aa7af96283415074474a5c6d27 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 21:02:20 -0400 Subject: [PATCH 12/15] fix(messenger): list the Chats screen's chats in the invite sheet The sheet read CONTACT_DM alongside TIP_DM and GROUP. Contact DMs have their own inbox, so a person with both kinds of DM appeared twice, and contact rows without a name showed blank. The sheet now reads chatListFeed(), the same TIP_DM and GROUP set the Chats screen shows. --- .../app/messenger/internal/GroupInviteViewModel.kt | 7 ++++--- .../app/messenger/internal/GroupInviteViewModelTest.kt | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt index 1ab92ddfe1..9355064c9e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -3,9 +3,9 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.chatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference import com.getcode.util.resources.ResourceHelper @@ -69,11 +69,12 @@ internal class GroupInviteViewModel @Inject constructor( val invited: Flow = _invited.receiveAsFlow() init { + // The Chats list's own set of chats. Contact DMs have their own inbox, and taking them too + // listed a person twice when they had both kinds of DM, plus nameless contact rows. // One read, not a subscription: a chat receiving a message while the sheet is up would // otherwise jump to the top and move the row out from under the viewer's finger. viewModelScope.launch { - val summaries = chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM, ChatType.GROUP) - .first() + val summaries = chatCoordinator.chatListFeed().first() val selfId = userManager.accountId val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) } _state.update { it.copy(recentChats = chats) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index e44f1cefa6..a90113796d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -159,16 +159,16 @@ class GroupInviteViewModelTest { } @Test - fun `the list offers groups as well as 1-1 chats`() { + fun `the list offers the chats the Chats list shows`() { viewModel() - verify { chatCoordinator.feed(ChatType.CONTACT_DM, ChatType.TIP_DM, ChatType.GROUP) } + verify { chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP) } } @Test fun `the group being invited to is left out and cannot be picked`() = runTest(scheduler) { val group = ChatId(ByteArray(32) { 9 }) every { chatCoordinator.feed(*anyVararg()) } returns flowOf( - listOf(summary(alice, ChatType.CONTACT_DM), summary(group, ChatType.GROUP)), + listOf(summary(alice, ChatType.TIP_DM), summary(group, ChatType.GROUP)), ) val model = viewModel() advanceUntilIdle() From 87e6de570c106dec5fc6a7ec53994b4b7b90a2ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 21:10:02 -0400 Subject: [PATCH 13/15] fix(messenger): navigate to the first-picked chat after inviting The invite sheet closed after sending but never opened the chat picked first. It closed itself and then dispatched the navigation through the conversation's event collector, which is only composed while the transcript is on screen. Opened from the group's profile, nothing collected the event, so it was dropped. InviteToGroup now returns a GroupInviteResult with that chat's id, and openGroupInvite() navigates from the result callback of whichever screen opened the sheet: the transcript or the group's profile. The keyboard is hidden before the sheet returns so the screen behind doesn't shift. --- .../com/flipcash/app/core/chat/ChatStep.kt | 15 +++++++++++- .../flipcash/app/messenger/ChatFlowScreen.kt | 15 ++++++------ .../internal/screens/GroupInviteSheet.kt | 23 +++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 2 +- .../screens/profile/GroupProfileScreen.kt | 3 ++- 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 591dcae8bc..ae32609a1b 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable +import com.flipcash.services.models.chat.ChatId import com.getcode.navigation.HalfSheet import com.getcode.navigation.Sheet import com.getcode.navigation.WrapContentSheet @@ -13,6 +14,18 @@ import kotlinx.serialization.Serializable @Serializable data object ChatSendResult : Parcelable +/** + * The chat the invite sheet sends the viewer to once its invites are out: the first one picked. + * + * Returned rather than navigated to from the sheet, because the sheet's own navigation raced its + * dismissal and was dropped when the conversation's event collector was not on screen (the sheet + * opened from the group's profile). The screen that opened the sheet is still composed when the + * result arrives, so it navigates. + */ +@Parcelize +@Serializable +data class GroupInviteResult(val chatId: ChatId) : Parcelable + @Serializable sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @@ -58,7 +71,7 @@ sealed interface ChatStep : FlowStep, Parcelable { */ @Parcelize @Serializable - data object InviteToGroup : ChatStep, Sheet + data object InviteToGroup : ChatStep, NavigationRetVal, Sheet @Parcelize @Serializable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 988ca8f291..0af571aa84 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -20,6 +20,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel @@ -283,16 +284,14 @@ private fun FlowGroupInviteSheet() { state.chatId?.let(inviteViewModel::inviteTo) } + // Returned to whichever screen opened the sheet (see openGroupInvite), which navigates. The + // keyboard goes first: the message field has it up, and leaving with it still open drags the + // screen behind out from under it. + val keyboard = rememberKeyboardController() + val resultBack = resultBackNavigator(exit = dismissSheet) LaunchedEffect(inviteViewModel) { inviteViewModel.invited.collect { chatId -> - dismissSheet() - // Pushed through the conversation underneath, the same way a tapped invite card opens - // its group, so Back returns to the group the invites went out from. - viewModel.dispatchEvent( - ChatViewModel.Event.OpenScreen( - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)) - ) - ) + keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 1ac1014ae0..e5adf3cc0f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -58,6 +58,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.app.core.media.rememberMediaUrl import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.messenger.internal.ChatSubject @@ -69,6 +73,9 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White50 @@ -87,6 +94,22 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch +/** + * Opens the invite sheet over the current step and, once its invites are out, the chat picked + * first. + * + * The sheet returns that chat rather than navigating itself. It is opened from the transcript and + * from the group's profile, and only the screen that opened it is sure to be composed when the + * sends finish, so that screen does the navigating. + */ +internal fun CodeNavigator.openGroupInvite() { + navigateForResult(ChatStep.InviteToGroup) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + navigate(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(result.value.chatId))) + } + } +} + /** * Node 10329:12104 — handing out the link to a group, and sending it straight into the * viewer's other chats. 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 9b66896d82..3bc1d3c932 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 @@ -198,7 +198,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // and going straight to the system share picker would bury it. The sheet reads the // url off the same state the CTA that got here is gated on. viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT)) - keyboard.hideIfVisible { navigator.push(ChatStep.InviteToGroup) } + keyboard.hideIfVisible { navigator.openGroupInvite() } } is ChatAction.ViewProfile -> { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 3d647ba321..4111aba867 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -35,6 +35,7 @@ import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.screens.openGroupInvite import com.getcode.navigation.core.LocalCodeNavigator import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList @@ -145,7 +146,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent( ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE) ) - flowNavigator.navigateTo(ChatStep.InviteToGroup) + navigator.openGroupInvite() } // Both muting and unmuting go through the picker, which is why this row // navigates either way rather than acting on one of them here. The outer From f8d2cbd043b883d63235ea5b7aa4e3da3a0eccb9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 21:16:39 -0400 Subject: [PATCH 14/15] fix(messenger): head a solo group with its invite card, messages or not The invite card only replaced the group's info card while the transcript was empty, so a group whose only member had posted anything fell back to the old header. iOS decides on who is in the chat instead: the card heads the transcript while the viewer is a member and nobody else has joined (ConversationScreen.showsGroupInvite). State.showsGroupInviteCard now carries that rule and MessageList reads it. Once someone joins, the header goes back to the info card and the invite stays on the group's profile. --- .../app/messenger/internal/ChatViewModel.kt | 10 ++++++++++ .../screens/components/MessageList.kt | 8 ++++---- .../internal/ChatViewModelStateTest.kt | 20 +++++++++++++++++-- 3 files changed, 32 insertions(+), 6 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 e9854270c8..0b736967d6 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 @@ -467,6 +467,16 @@ internal class ChatViewModel @Inject constructor( get() = (subject as? ChatSubject.Group) ?.takeIf { it.isMember == true } ?.let { Linkify.groupChatInvite(it.chatId) } + + /** + * Whether the group's invite card heads the transcript in place of its info card: a group + * the viewer has joined that nobody else has (node 10330:19164). Decided by who is in the + * chat, not by whether it has messages, so posting into a solo group keeps the card. Once + * someone joins, the invite moves to the group's profile, as on iOS. + */ + val showsGroupInviteCard: Boolean + get() = groupInviteUrl != null && + (subject as? ChatSubject.Group)?.memberCount?.let { it <= 1 } == true } /** 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 6185a1ac9d..b687005db8 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 @@ -421,13 +421,13 @@ internal fun MessageList( } } - // Chat start shows contact info container. An empty group the viewer can invite to - // shows its invite card there instead (node 10330:19164): inviting someone is the - // only thing to do with an empty group, and it is the same card a DM transcript + // Chat start shows contact info container. A group nobody else has joined shows its + // invite card there instead (node 10330:19164), whether or not it has messages: + // inviting someone is the thing left to do, and it is the same card a DM transcript // renders for the link, with a different CTA and destination. val inviteUrl = state.groupInviteUrl val inviteGroup = state.subject as? ChatSubject.Group - if (messages.itemCount == 0 && inviteUrl != null && inviteGroup != null) { + if (state.showsGroupInviteCard && inviteUrl != null && inviteGroup != null) { item(key = "group-invite-card") { val card = remember(inviteGroup, inviteUrl, state.ruleCurrency) { inviteGroup.toGroupInviteCard( diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 20f1d048e5..40414880db 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -43,11 +43,15 @@ class ChatViewModelStateTest { private val gatedRules = ChatRules(listener = listOf(unmet), speaker = emptyList()) - private fun group(isMember: Boolean?, rules: ChatRules? = gatedRules) = ChatSubject.Group( + private fun group( + isMember: Boolean?, + rules: ChatRules? = gatedRules, + memberCount: Long = 1L, + ) = ChatSubject.Group( chatId = groupChatId, groupTitle = "Ballers", picture = null, - memberCount = 1L, + memberCount = memberCount, rules = rules, isMember = isMember, ) @@ -239,6 +243,18 @@ class ChatViewModelStateTest { assertNull(ChatViewModel.State().groupInviteUrl) } + @Test + fun `the invite card heads a group only while the viewer is its only member`() { + // Messages have no say: a solo group the viewer has posted in still has nobody to talk to. + assertTrue(ChatViewModel.State(subject = group(isMember = true)).showsGroupInviteCard) + assertFalse( + ChatViewModel.State(subject = group(isMember = true, memberCount = 2L)).showsGroupInviteCard, + ) + assertFalse(ChatViewModel.State(subject = group(isMember = false)).showsGroupInviteCard) + assertFalse(ChatViewModel.State(subject = group(isMember = null)).showsGroupInviteCard) + assertFalse(ChatViewModel.State(subject = dm).showsGroupInviteCard) + } + @Test fun `a chat id with no uuid form yields no link rather than a malformed one`() { // A DM's id is a 32-byte hash. Reaching this through a Group subject is unreachable in the From afaa5faff4cdfff69a512707bee763c9df4cf654 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 27 Sep 2026 21:28:29 -0400 Subject: [PATCH 15/15] fix(chat-ui): size link cards off the transcript's full width Link cards and the solo group's head card came out about 30dp narrower than iOS's on the same screen. iOS takes 78% of the transcript's full width for both (ChatViewController.maxBubbleWidthFraction). Android took 78% of the row after its side insets for link cards, and 72% for the head card. transcriptCardWidth() adds the insets back before applying the fraction, and both the card rows in ContentBubble and the head card in MessageList use it. Text and cash bubbles keep their existing widths. --- .../internal/screens/components/MessageList.kt | 17 +++++++++++------ .../flipcash/shared/chat/ui/MessageBubble.kt | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 7 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 b687005db8..3598efaf21 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState @@ -60,6 +61,7 @@ import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner 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.transcriptCardWidth import com.getcode.theme.CodeTheme import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator @@ -72,9 +74,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull -// The empty group's invite card, as a share of the row: 290 of 402 in node 10330:19164. -private const val GROUP_INVITE_CARD_WIDTH = 0.72f - @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -435,14 +434,20 @@ internal fun MessageList( currencyName = state.ruleCurrency?.nameInRequirement, ) } - Box( + BoxWithConstraints( modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.Center, ) { - BoxWithConstraints(Modifier.fillMaxWidth(GROUP_INVITE_CARD_WIDTH)) { + // 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( + rowWidth = maxWidth, + rowInset = CodeTheme.dimens.inset, + ) + Box(Modifier.width(cardWidth)) { GroupInviteLinkCard( card = card, - minHeight = maxWidth * GroupInviteCardDefaults.ASPECT, + minHeight = cardWidth * GroupInviteCardDefaults.ASPECT, ctaLabel = stringResource(R.string.action_linkCard_invitePeople), onStart = { onAction(ChatAction.InviteToGroup) } .takeIf { !selecting }, 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 1ec4b7f952..75b11d1e18 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 @@ -82,6 +82,18 @@ import com.getcode.ui.core.addIf 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 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. + */ +fun transcriptCardWidth(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 @@ -106,7 +118,10 @@ fun ContentBubble( val actionHandler = LocalChatActionHandler.current val jumbo = remember(item) { item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val bubbleMaxWidth = when (item.content) { + val isCardRow = item.linkCard != null && item.part == MessagePart.Card + val bubbleMaxWidth = if (isCardRow) { + transcriptCardWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) + } else when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Deleted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION