From 200d8391183c5a823f6b7f1a7bd043188dc08456 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 12:50:14 -0400 Subject: [PATCH 1/6] feat(chat): float the message bar over a fade and retune chat motion Ports code-ios-app#907 and the tuning values from code-ios-app#908. The message bar drops its own background and sits on a fade that reaches 0.9 opacity. With the keyboard down it narrows to a grid.x6 side margin and sinks grid.x2 into the navigation bar area; both follow the keyboard's open fraction, so the bar resizes with the keyboard instead of after it. The transcript moves grid.x2 closer to the bar while the keyboard is up, on the same fraction. New messages use iOS's retuned insertion spring (0.27s, bounce 0.15), start at 0.9 scale from the sender's bottom corner, and rise 35% of their height plus the gap below them. The receipt line fades out in 80ms while its row closes on a no-bounce 0.35s spring, which the reaction reflow now shares. Bubble fills go to 0.10 (outgoing) and 0.05 (incoming) white, and the swipe-to-reply arrow starts at 0.35. The $ button stays "$" whenever a message field sits next to it, so it no longer flashes "Send $" in contact DMs as text comes and goes. --- .../screens/components/ChatBottomBar.kt | 105 ++++++++++++++++-- .../screens/components/MessageList.kt | 13 ++- .../internal/screens/components/MessageRow.kt | 35 +++--- .../screens/components/SendCashButton.kt | 12 +- .../flipcash/shared/chat/ui/ChatAnimations.kt | 32 ++++-- .../theme/internal/FlipcashDesignSystem.kt | 4 +- 6 files changed, 149 insertions(+), 52 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 3c6c2a8ac9..e8f23e3c62 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,11 +1,12 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.SizeTransform import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -17,10 +18,17 @@ 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.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize @@ -39,26 +47,30 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.balanceRequirement import com.flipcash.app.messenger.internal.requiresStaff +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.ui.ChatAnimations -import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.BlobAccessContext -import com.flipcash.features.messenger.R -import com.getcode.theme.CodeTheme import com.flipcash.shared.chat.ui.ComposerReplyStrip +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit -import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.ui.components.chat.TypingIndicator import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -68,6 +80,27 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.blur.materials.HazeMaterials +/** The ramp's shape: clear for most of its height, then a late rise into the background. */ +private val ComposerFadeCurve = CubicBezierEasing(0.42f, 0.18f, 0.17f, 1.04f) + +/** Short of opaque, so a bubble behind the bar's bottom edge still shows faintly. */ +private const val ComposerFadeEndAlpha = 0.9f + +private const val ComposerFadeSamples = 32 + +/** Sampled once: the curve has no gradient-stop equivalent, so it is laid down as stops. */ +private val ComposerFadeStops: List> = (0..ComposerFadeSamples).map { i -> + val t = i / ComposerFadeSamples.toFloat() + t to (ComposerFadeEndAlpha * ComposerFadeCurve.transform(t)).coerceIn(0f, 1f) +} + +/** The dissolve from the transcript into [color] behind the composer, from [startY] to [endY]. */ +private fun composerFade(color: Color, startY: Float, endY: Float): Brush = Brush.verticalGradient( + colorStops = ComposerFadeStops.map { (t, alpha) -> t to color.copy(alpha = alpha) }.toTypedArray(), + startY = startY, + endY = endY, +) + @Composable internal fun UserControlBottomBar( state: ChatViewModel.State, @@ -154,20 +187,51 @@ internal fun UserControlBottomBar( } } } + // Compact at rest: with the keyboard down the composer sits narrower and a little lower, into + // the navigation bar's inset, and opens out to the normal margins as the keyboard comes up. + // Only once there is a composer; the full-width Send Cash button keeps its width. + // + // Tracks how far open the keyboard is rather than springing on a keyboard up/down flag, so + // the bar changes size in step with the keyboard's own motion: not ahead of it (a flag that + // flips as the hide starts), and not after it (one that waits for the hide to finish). The + // composer appearing at all (typingConstraints resolving a frame after the bar is revealed) + // doesn't move anything, since nothing here animates on its own. + val keyboardOpen = keyboardOpenFraction() + val hasComposer = state.typingConstraints.enabled + val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset) + val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset + val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp + // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks + // into it once the keyboard has cleared it. + val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime) Box { + // The transcript runs under the bar and dissolves into the background here, from the + // bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no + // surface of its own and floats over it. + val fadeColor = CodeTheme.colors.background Box( modifier = Modifier .fillMaxWidth() .height(buttonHeight) .align(Alignment.BottomCenter) .drawWithGradient( - color = CodeTheme.colors.background, + brush = { startY, endY -> composerFade(fadeColor, startY, endY) }, startY = { 0f }, ), ) AnimatedContent( modifier = Modifier .measured { buttonHeight = it.height } + // Reports itself shorter by the drop and lets the controls run past its bottom + // edge, so the bar sits that far into the navigation bar's inset and the + // transcript's bottom padding (measured from this height) follows it down. + .layout { measurable, constraints -> + val drop = restingDrop.roundToPx().coerceAtMost(restingRoom.getBottom(this)) + val placeable = measurable.measure(constraints) + layout(placeable.width, (placeable.height - drop).coerceAtLeast(0)) { + placeable.place(0, 0) + } + } .padding(vertical = CodeTheme.dimens.grid.x3) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after @@ -231,8 +295,8 @@ internal fun UserControlBottomBar( hazeState = hazeState, modifier = Modifier // Inset to the composer row's own margins, so the card's edges - // line up with the field it sits above. - .padding(horizontal = CodeTheme.dimens.inset) + // line up with the field it sits above, and narrows with it. + .padding(horizontal = sideInset) .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("composer_reply_strip"), ) @@ -241,7 +305,7 @@ internal fun UserControlBottomBar( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), + .padding(horizontal = sideInset), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.Bottom, ) { @@ -375,4 +439,21 @@ private fun DeactivatedChatBottomBar() { textAlign = TextAlign.Center, ) } -} \ No newline at end of file +} + +/** + * How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide + * animations frame by frame. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun keyboardOpenFraction(): Float { + val density = LocalDensity.current + val now = WindowInsets.ime.getBottom(density) + val full = maxOf( + WindowInsets.imeAnimationSource.getBottom(density), + WindowInsets.imeAnimationTarget.getBottom(density), + now, + ) + return if (full <= 0) 0f else now.toFloat() / full +} 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 0527d2def6..0c5f005a4e 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 @@ -36,15 +36,16 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.lerp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -258,7 +259,11 @@ internal fun MessageList( // shortening the list from below — whether the keyboard came up for the edit or because the // composer was tapped with the selection bar still showing. See FocusPin. val imeInsets = WindowInsets.ime - val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() + // With the keyboard up the newest message sits a little closer to the composer, which no + // longer rests into the navigation bar's inset. Follows the keyboard the same way the + // composer does (see ChatBottomBar), so the two move together. + val raisedDrop = lerp(0.dp, CodeTheme.dimens.grid.x2, keyboardOpenFraction()) + val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() - raisedDrop val listBottomPadPx = with(LocalDensity.current) { listBottomPad.roundToPx() } val focusPin = rememberFocusPin(listState, messages, focusedMessageId, imeInsets, listBottomPadPx) // Read live from the auto-scroll effect below, which outlives the composition it launched in. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index cbee9ba68f..24eb8cc9ca 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -127,28 +127,29 @@ internal fun MessageRow( val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false - // Message insertion animation — scale from 0.95 + opacity with edge anchor. + // Message insertion: the row starts a little below its slot, faded and scaled down about its + // bottom corner on the sender's side, and rides up into place — pushed in from below rather + // than fading in on top of the row above. var appeared by remember(item.itemKey) { mutableStateOf(!animateInsertion) } LaunchedEffect(Unit) { if (!appeared) appeared = true } - val insertionAlpha by animateFloatAsState( + val insertionProgress by animateFloatAsState( targetValue = if (appeared) 1f else 0f, animationSpec = ChatAnimations.insertion, - label = "insertAlpha", - ) - val insertionScale by animateFloatAsState( - targetValue = if (appeared) 1f else 0.95f, - animationSpec = ChatAnimations.insertion, - label = "insertScale", + label = "insertProgress", ) val insertionModifier = Modifier.graphicsLayer { - alpha = insertionAlpha - scaleX = insertionScale - scaleY = insertionScale + val remaining = 1f - insertionProgress + alpha = insertionProgress.coerceIn(0f, 1f) + val scale = 1f - (1f - ChatAnimations.insertionScale) * remaining + scaleX = scale + scaleY = scale + // As on iOS, the rise covers the row and the gap below it. + translationY = (size.height + bottomSpacing.toPx()) * ChatAnimations.insertionRise * remaining transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) // anchor trailing + TransformOrigin(1f, 1f) // bottom trailing } else { - TransformOrigin(0f, 0.5f) // anchor leading + TransformOrigin(0f, 1f) // bottom leading } } @@ -540,10 +541,10 @@ private fun SwipeToReplyAffordance( val fraction = progress() translationX = pullBackPx() alpha = fraction - // Never from nothing: the circle is already most of its size when it starts to - // show, so it reads as arriving rather than as inflating. - scaleX = 0.6f + 0.4f * fraction - scaleY = 0.6f + 0.4f * fraction + // Grows from about a third of its size to full as the drag reaches the threshold, + // matching iOS ChatSwipeToReply.arrowStartScale. + scaleX = 0.35f + 0.65f * fraction + scaleY = 0.35f + 0.65f * fraction } .clip(CircleShape) .background(Color.White.copy(alpha = 0.12f)), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index f9fb8d4429..69a0408d84 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -58,10 +58,8 @@ internal fun RowScope.SendCashButton( hazeMaterial: HazeBlurStyle, onClick: () -> Unit, ) { - // Tip chats use the minimized (dark, symbol-only) button. The normal send flow keeps the - // expanded "Send $" presentation and only collapses to the symbol once the user starts typing. - // chatType resolves from the fast local contact lookup, so a tip DM condenses immediately rather - // than waiting on the server profile. + // Beside a composer the button is always the condensed (dark, symbol-only) "$". It used to expand + // to a white "Send $" whenever the composer was empty, which flashed as text came and went. val isTipChat = state.chatType == ChatType.TIP_DM val canType = state.typingConstraints.enabled @@ -69,11 +67,9 @@ internal fun RowScope.SendCashButton( // is the entire bar. Condensing it there would leave a full-width transparent "$"; what the // chat actually needs is its one call to action, so it stays white and says what it does. val isCallToAction = isTipChat && !canType - // A group is condensed like a tip chat: the group is not one counterparty to "Send $" to, and - // the expanded pill would take the row from a composer that is always open there. + // A group is condensed too: the group is not one counterparty to "Send $" to. val isGroupChat = state.chatType == ChatType.GROUP - val isTyping = !isCallToAction && - (isTipChat || isGroupChat || state.chatInputState.text.isNotEmpty()) + val isTyping = !isCallToAction && (canType || isGroupChat) // Colors ease slowly and independently of the width/label so the fill change reads as one calm // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 3921cd81bd..49b2f2e857 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -19,9 +19,20 @@ import androidx.compose.ui.graphics.Color // All chat animation spring specs in one place. object ChatAnimations { - // Message bubble insertion — scale from 0.95 + opacity. - // Matches iOS insertionSpring: .spring(duration: 0.23, bounce: 0.27). - val insertion: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) + // Message bubble insertion — scale from [insertionScale] about the bottom corner on the sender's + // side, plus opacity and [insertionRise]. + // Matches iOS insertion: .spring(duration: 0.27, bounce: 0.15). + val insertion: SpringSpec = spring(dampingRatio = 0.85f, stiffness = 542f) + const val insertionScale = 0.9f + // How far below its slot a newly appended row starts, as a share of its own height. It rides + // up into place rather than fading in over the row above. iOS insertionRise. + const val insertionRise = 0.35f + + // Rows resizing in place (a receipt moving on, a reaction): glides to the new layout and never + // bounces. Matches iOS reflow: .spring(duration: 0.35, bounce: 0). + private const val ReflowStiffness = 322f + private val reflowIntSize: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) // Typing indicator entry/exit — scale from 0.95 + opacity. val typingIndicator: SpringSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) @@ -68,9 +79,10 @@ object ChatAnimations { const val attentionHoldMs = 250L val attentionFade: TweenSpec = tween(durationMillis = 750, easing = LinearEasing) - // Receipt label exit when a new message is sent — fade out + collapse. - private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) - val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) + // Receipt label exit when a new message is sent — the row closes the gap on [reflow] while the + // line fades out fast enough to be gone before the bubble below reaches it. iOS + // receiptExitFade: 0.08 s. + val receiptExit: ExitTransition = shrinkVertically(reflowIntSize) + fadeOut(tween(durationMillis = 80)) // Reaction pills, from iOS ChatMotion's reaction springs and scales. // A pill arriving — iOS reaction: .spring(duration: 0.32, bounce: 0.35), from 40%. @@ -80,9 +92,11 @@ object ChatAnimations { // its arrival and without overshoot, so it doesn't read as the pill coming back. val reactionExit: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 987f) const val reactionExitScale = 0.6f - // Pills sliding to make room and the row resizing — iOS reactionReflow, which is insertion. - val reactionReflowOffset: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) - val reactionReflowHeight: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) + // Pills sliding to make room and the row resizing — iOS reactionReflow, which is reflow. + val reactionReflowOffset: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) + val reactionReflowHeight: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) // A count or selected state changing in place — iOS reactionChange: .spring(duration: 0.24, bounce: 0). val reactionChange: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) val reactionChangeColor: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 685f) diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index ebc0c0fdac..fc57115c2f 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -69,11 +69,11 @@ object Flipcash2ColorSpec { val chatColors = ChatColors( incomingBubble = Bubble( - background = Color.White.copy(alpha = 0.02f), + background = Color.White.copy(alpha = 0.05f), border = Color.White.copy(alpha = 0.03f), ), outgoingBubble = Bubble( - background = Color.White.copy(alpha = 0.08f), + background = Color.White.copy(alpha = 0.10f), border = Color.White.copy(alpha = 0.03f), ), typingIndicator = TypingIndicator( From 6322d10ff81975a5f4a6f751f231947e80d7eaa8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 12:57:51 -0400 Subject: [PATCH 2/6] feat(chat): push the transcript up when a message arrives at the bottom A new message at index 0 used to land by requestScrollToItem for our own messages, a one-frame jump, and by animateScrollToItem for incoming ones, on its own curve. When the transcript is resting on the newest message, it now scrolls by the new row's measured height on insertionPush, the insertion stiffness without the bounce. The older rows ride up while the new row rises into its slot, as on iOS. animateItem can't do this: the move is a scroll, and a jump in scroll position resets placement animations. The scroll ends with a snap to index 0 if the row above resized during it. --- .../screens/components/MessageList.kt | 24 +++++++++++++++++++ .../flipcash/shared/chat/ui/ChatAnimations.kt | 4 ++++ 2 files changed, 28 insertions(+) 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 0c5f005a4e..14af8cb401 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 @@ -567,6 +567,11 @@ internal fun MessageList( .filterNotNull() .distinctUntilChanged() .collectLatest { + // The list holds its position by key, so a message arriving at index 0 leaves + // the one that was newest (now index 1) where it sat. Sitting at offset 0 means + // the transcript was resting on the newest message. + val atBottom = listState.firstVisibleItemIndex == 1 && + listState.firstVisibleItemScrollOffset == 0 // Always scroll for own messages; only near-bottom for incoming val nearBottom = listState.firstVisibleItemIndex <= 5 val newest = messages.peek(0) as? ChatListItem.ContentBubble @@ -584,6 +589,25 @@ internal fun MessageList( } return@collectLatest } + // Resting on the newest message, the arrival pushes the transcript up as iOS + // does: every row rides up by the new row's height on the insertion spring, + // while the new row rises into its slot. Placement animations can't do this — + // the move is a scroll, and a jump in scroll position resets them. + if (atBottom && (newest?.isFromSelf == true || nearBottom)) { + val height = listState.measuredHeight(0) + if (height != null) { + // Negative is toward index 0. + listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush) + // The row above can resize under the scroll (its receipt moving on), so + // settle exactly on the newest message rather than a few pixels short. + if (listState.firstVisibleItemIndex != 0 || + listState.firstVisibleItemScrollOffset != 0 + ) { + listState.requestScrollToItem(0, 0) + } + return@collectLatest + } + } when { // Own message: anchor the new bubble during the next measure pass // rather than animating to it. An animated scroll resolves its target diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 49b2f2e857..333421081f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -27,6 +27,10 @@ object ChatAnimations { // How far below its slot a newly appended row starts, as a share of its own height. It rides // up into place rather than fading in over the row above. iOS insertionRise. const val insertionRise = 0.35f + // The transcript riding up to make room for a new row. The same stiffness as [insertion] so the + // rows and the new message move together, but without the bounce: the list can't scroll past + // its newest message, so an overshoot would be clipped and the swing back would leave it short. + val insertionPush: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 542f) // Rows resizing in place (a receipt moving on, a reaction): glides to the new layout and never // bounces. Matches iOS reflow: .spring(duration: 0.35, bounce: 0). From 8873d898c99265c5f931be8e7fb8a2d0da6ea152 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:05:12 -0400 Subject: [PATCH 3/6] fix(chat): wait for the new row's layout before pushing the transcript The newest-message flow fires when the paged snapshot changes, before the list lays it out, so the scroll position still described the old list: firstVisibleItemIndex read 0 rather than 1, the push was skipped, and our own messages fell through to requestScrollToItem's one-frame jump. Wait for the next layout pass, with a 250ms guard, before reading it. --- .../messenger/internal/screens/components/MessageList.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 14af8cb401..76fc40de8b 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 @@ -78,6 +78,7 @@ import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -567,6 +568,11 @@ internal fun MessageList( .filterNotNull() .distinctUntilChanged() .collectLatest { + // The paged snapshot changes before the list lays it out, so wait for the next + // layout pass; until then the scroll position describes the old list. + withTimeoutOrNull(NextLayoutTimeoutMs) { + snapshotFlow { listState.layoutInfo }.drop(1).first() + } // The list holds its position by key, so a message arriving at index 0 leaves // the one that was newest (now index 1) where it sat. Sitting at offset 0 means // the transcript was resting on the newest message. @@ -796,6 +802,9 @@ private fun LazyListLayoutInfo.centeringBand(): IntRange = /** Handed to every row that is not the one a jump just landed on. */ private val NoAttention: () -> Float = { 0f } +// A guard on waiting for the list to lay out a newly arrived row: the scroll goes ahead without the +// pass rather than waiting on one that doesn't come. +private const val NextLayoutTimeoutMs = 250L private const val JUMP_PAGE_SIZE = 50 private const val MAX_JUMP_ITEMS = 5_000 private const val JUMP_STEP_TIMEOUT_MS = 2_000L From 197b7085da4df1d80886941299f764224ff1a23d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:12:14 -0400 Subject: [PATCH 4/6] feat(chat): hand the receipt to a new send in one step The previous message's receipt dropped when a send was confirmed and the new one's line appeared 700ms later, so the line hopped. The newest send now holds the receipt on the row above until it is confirmed and 700ms have passed, then the line moves down as the old one leaves, matching iOS #908. A failed send still shows its own line straight away. --- .../screens/components/MessageList.kt | 34 ++++++++++ .../internal/screens/components/MessageRow.kt | 4 +- .../screens/components/ReceiptLabel.kt | 32 ++-------- .../screens/components/ReceiptRules.kt | 39 +++++++++--- .../screens/components/ReceiptHandoffTest.kt | 63 +++++++++++++++++++ 5 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt 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 76fc40de8b..7146656712 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -65,6 +65,7 @@ import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.LocalLinkCardResolution +import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner import com.flipcash.shared.chat.ui.ChatAnimations @@ -84,6 +85,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.milliseconds @Composable internal fun MessageList( @@ -152,6 +154,7 @@ internal fun MessageList( // Keys that have already played their insertion animation — persists // across item disposal so scrolling away and back doesn't replay. val animatedKeys = remember { mutableSetOf() } + val settlingKey = rememberSettlingSend(messages) // The backdrop, read once for everything it covers: the rows, the bubbles' own targets, // and the contact card at the start of history all stop taking taps together. @@ -426,6 +429,7 @@ internal fun MessageList( messages = messages, separatorConfig = separatorConfig, otherReadPointer = otherReadPointer, + settlingKey = settlingKey, selecting = selecting, focused = focused, // The pressed row is also the only one that lifts, as iOS lifts only the held bubble. @@ -645,6 +649,36 @@ internal fun MessageList( } // CompositionLocalProvider } +/** How long a send holds the receipt above it, at least; iOS's settle floor. */ +private val ReceiptSettleFloor = 700.milliseconds + +/** + * The viewer's newest send while it settles, or null. A send settles from the moment it lands at + * the bottom of the transcript until it is confirmed and [ReceiptSettleFloor] has passed, whichever + * is later; until then the receipt stays on the row above (see [shouldShowReceiptLabel]). A send + * that fails, or that a reply lands under, releases once the floor has passed. + */ +@Composable +private fun rememberSettlingSend(messages: LazyPagingItems): Any? { + val newest = if (messages.itemCount > 0) messages.peek(0) as? ChatListItem.ContentBubble else null + val currentNewest by rememberUpdatedState(newest) + var sendKey by remember { mutableStateOf(null) } + if (newest != null && newest.isFromSelf && newest.receiptStatus == ReceiptStatus.SENDING) { + sendKey = newest.messageKey + } + var settling by remember { mutableStateOf(null) } + LaunchedEffect(sendKey) { + val key = sendKey ?: return@LaunchedEffect + settling = key + delay(ReceiptSettleFloor) + snapshotFlow { currentNewest }.first { row -> + row?.messageKey != key || row.receiptStatus != ReceiptStatus.SENDING + } + settling = null + } + return settling +} + /** * How many items are actually loaded, as opposed to standing in as placeholders. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 24eb8cc9ca..2f9266037c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -97,6 +97,8 @@ internal fun MessageRow( messages: LazyPagingItems, separatorConfig: SeparatorConfig, otherReadPointer: MessagePointer?, + /** The viewer's newest send while it settles; see [shouldShowReceiptLabel]. */ + settlingKey: Any? = null, selecting: Boolean, focused: Boolean, /** @@ -463,7 +465,7 @@ internal fun MessageRow( } } val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) + shouldShowReceiptLabel(index, item, messages, otherReadPointer, settlingKey) // An emoji-only message, or a card row, has no bubble, so its "Edited" // marker has nowhere to sit inside the message and comes out here // instead — on the same line as the receipt, and ahead of it, so the two diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index 82284637ac..e574d1f962 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal.screens.components import android.text.format.DateFormat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.snap import com.flipcash.shared.chat.ui.ChatAnimations import androidx.compose.animation.expandVertically @@ -20,11 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -43,10 +40,6 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Instant -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds - -private val DELIVERED_DELAY = 700.milliseconds @Composable internal fun ReceiptLabel( @@ -56,23 +49,10 @@ internal fun ReceiptLabel( animateEntrance: Boolean = false, onRetryFailed: (() -> Unit)? = null, ) { - // iOS: "Delivered" hides instantly on send, then appears after 700ms with - // scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12). - // "Read" swaps in immediately (no delay). - // - // animateEntrance: true only when the message is still SENDING at composition - // time, so the label animates in on the SENDING→SENT transition. When opening - // a chat or scrolling an already-delivered/read message into view, we skip - // the enter animation entirely. - var deliveredVisible by remember { mutableStateOf(!animateEntrance) } - LaunchedEffect(status) { - if (animateEntrance && status == ReceiptStatus.SENT) { - delay(DELIVERED_DELAY) - deliveredVisible = true - } else { - deliveredVisible = true - } - } + // animateEntrance: true only for a line arriving on the viewer's own send, which it does once + // the send has settled (see rememberSettlingSend) -- the same beat the line above leaves on. + // Opening a chat or scrolling a delivered/read message into view skips the entrance. + val deliveredVisible = remember { MutableTransitionState(!animateEntrance).apply { targetState = true } } val deliveredSpec = ChatAnimations.delivered @@ -83,7 +63,7 @@ internal fun ReceiptLabel( ), ) { AnimatedVisibility( - visible = deliveredVisible, + visibleState = deliveredVisible, // Expand + opacity, no scale: iOS un-hides the label and cross-fades its text in, // letting the cell self-size. The line itself never scales. enter = if (animateEntrance) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt index c47be1adbf..10cffb43aa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -34,24 +34,47 @@ internal fun effectiveReceiptStatus( * At group boundaries, labels are suppressed when the nearest self-group * below already shows the same status (avoids duplicate "Read" labels). */ -/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ +/** + * The nearest bubble below [index], stepping over the viewer's own tombstones and the send that is + * still settling ([settlingKey]): neither has taken the receipt yet, so the line stays above them. + */ private fun receiptNeighbourBelow( index: Int, - messages: LazyPagingItems, + peek: (Int) -> ChatListItem?, + settlingKey: Any?, ): ChatListItem.ContentBubble? { for (i in (index - 1) downTo 0) { - val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null + val bubble = peek(i) as? ChatListItem.ContentBubble ?: return null if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue + if (bubble.isSettling(settlingKey)) continue return bubble } return null } +private fun ChatListItem.ContentBubble.isSettling(settlingKey: Any?): Boolean = + settlingKey != null && messageKey == settlingKey + internal fun shouldShowReceiptLabel( index: Int, item: ChatListItem.ContentBubble, messages: LazyPagingItems, otherReadPointer: MessagePointer?, + settlingKey: Any? = null, +): Boolean = shouldShowReceiptLabel(index, item, messages::peek, otherReadPointer, settlingKey) + +/** + * [settlingKey] is the viewer's newest send while it settles: from the moment it is sent until it + * is confirmed and the settle floor has passed. It shows no line of its own in that time, and the + * line above it stays put, so the receipt moves down in one step when the hold releases rather + * than dropping at confirmation and reappearing a beat later. + */ +internal fun shouldShowReceiptLabel( + index: Int, + item: ChatListItem.ContentBubble, + peek: (Int) -> ChatListItem?, + otherReadPointer: MessagePointer?, + settlingKey: Any? = null, ): Boolean { if (!item.carriesReceipt) return false // A split message's receipt goes under its last row only. The rows above share its status, so @@ -59,11 +82,12 @@ internal fun shouldShowReceiptLabel( if (!item.isLastRow) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false if (status == ReceiptStatus.FAILED) return true + if (item.isSettling(settlingKey)) return false if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. - val belowBubble = receiptNeighbourBelow(index, messages) + val belowBubble = receiptNeighbourBelow(index, peek, settlingKey) // Within a self-group: show at intra-group status boundaries only if (belowBubble != null && belowBubble.isFromSelf) { @@ -74,8 +98,7 @@ internal fun shouldShowReceiptLabel( var selfGroups = 0 var prevWasSelf = false for (i in 0 until index) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble + val bubble = (peek(i) ?: break) as? ChatListItem.ContentBubble val isSelf = bubble != null && bubble.isFromSelf if (isSelf && !prevWasSelf) selfGroups++ prevWasSelf = isSelf @@ -88,8 +111,8 @@ internal fun shouldShowReceiptLabel( // For group 2: show only if status differs from the nearest self-group below for (i in (index - 1) downTo 0) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble ?: continue + val bubble = (peek(i) ?: break) as? ChatListItem.ContentBubble ?: continue + if (bubble.isSettling(settlingKey)) continue if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status } return true diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt new file mode 100644 index 0000000000..f110773276 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.messenger.internal.screens.components + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ReceiptStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * While the viewer's newest send settles, the receipt stays on the row above it, and moves down in + * one step when the hold releases. Rows are newest first, as the reversed transcript holds them. + */ +class ReceiptHandoffTest { + + private fun mine(id: Long, status: ReceiptStatus, clientId: String? = null) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = true, + timestamp = Instant.fromEpochSeconds(id * 60), + receiptStatus = status, + pendingClientIdHex = clientId, + ) + + private fun shown(rows: List, settlingKey: Any?): List = + rows.indices.map { i -> + shouldShowReceiptLabel(i, rows[i], { rows.getOrNull(it) }, otherReadPointer = null, settlingKey) + } + + @Test + fun `a confirmed send that is still settling leaves the line on the row above`() { + val send = mine(2, ReceiptStatus.SENT, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(false, true), shown(rows, settlingKey = send.messageKey)) + } + + @Test + fun `once the hold releases the line is on the new row only`() { + val rows = listOf(mine(2, ReceiptStatus.SENT, clientId = "c2"), mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, false), shown(rows, settlingKey = null)) + } + + @Test + fun `a send still in flight behind the hold leaves the line above as it was`() { + val send = mine(0, ReceiptStatus.SENDING, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(false, true), shown(rows, settlingKey = send.messageKey)) + } + + @Test + fun `a failed send shows its own line during the hold and keeps the one above`() { + val send = mine(0, ReceiptStatus.FAILED, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, true), shown(rows, settlingKey = send.messageKey)) + } + + @Test + fun `a first send that is settling shows no line anywhere`() { + val send = mine(1, ReceiptStatus.SENT, clientId = "c1") + assertEquals(listOf(false), shown(listOf(send), settlingKey = send.messageKey)) + } +} From a530365085f722b9b3db6b820fb8a9a75474dbf3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:29:27 -0400 Subject: [PATCH 5/6] feat(chat): port #908's typing linger, Read/Delivered rule and receipt grouping - Typing dots stay 800ms after STOPPED, and the typist's own message clears them straight away. - A row carries a status line if it is the newest confirmed send or the newest of mine they have read, so Read and Delivered can show together. - A bubble carrying a line ends its run: round corners and the normal gap until the line moves on. --- .../internal/screens/components/MessageRow.kt | 28 ++++-- .../screens/components/ReceiptRules.kt | 89 ++++++------------- .../app/messenger/internal/RowGapTest.kt | 5 ++ .../screens/components/ReceiptHandoffTest.kt | 59 +++++++++++- .../flipcash/shared/chat/ui/MessageBubble.kt | 10 ++- .../internal/delegates/EventStreamDelegate.kt | 1 + .../delegates/TypingIndicatorTracker.kt | 24 ++++- .../shared/chat/TypingIndicatorTrackerTest.kt | 28 +++++- 8 files changed, 166 insertions(+), 78 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 2f9266037c..dc37605a35 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -125,7 +125,15 @@ internal fun MessageRow( val onAction = LocalChatActionHandler.current val vibrator = LocalVibrator.current val keyboard = rememberKeyboardController() - val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) + // A status line sits between its bubble and the next, so it ends the bubble run there: the + // two keep their round corners and the normal gap until the line moves on (iOS #908). + fun carriesReceipt(at: Int): Boolean { + val bubble = messages.peek(at) as? ChatListItem.ContentBubble ?: return false + return shouldShowReceiptLabel(at, bubble, messages, otherReadPointer, settlingKey) + } + val showReceipt = item is ChatListItem.ContentBubble && carriesReceipt(index) + val receiptAbove = index + 1 < messages.itemCount && carriesReceipt(index + 1) + val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig, showReceipt) val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false @@ -379,7 +387,9 @@ internal fun MessageRow( index, item, messages, - separatorConfig + separatorConfig, + receiptAbove = receiptAbove, + receiptBelow = showReceipt, ), attention = attention, ) @@ -464,8 +474,6 @@ internal fun MessageRow( ) } } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer, settlingKey) // An emoji-only message, or a card row, has no bubble, so its "Edited" // marker has nowhere to sit inside the message and comes out here // instead — on the same line as the receipt, and ahead of it, so the two @@ -593,9 +601,10 @@ private fun bottomSpacingFor( item: ChatListItem, messages: LazyPagingItems, config: SeparatorConfig, + carriesReceipt: Boolean, ): Dp { // index-1 is the item below (newer) in reverseLayout - val gap = rowGapBelow(item, if (index > 0) messages.peek(index - 1) else null, config) + val gap = rowGapBelow(item, if (index > 0) messages.peek(index - 1) else null, config, carriesReceipt) return when (gap) { RowGap.Tight -> CodeTheme.dimens.grid.x1 RowGap.Normal -> CodeTheme.dimens.grid.x2 @@ -618,7 +627,12 @@ internal enum class RowGap { Tight, Normal, Wide } * Pure so it can be tested without a `PagingData`, like [startsSenderRun]: the peek belongs to the * caller. */ -internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: SeparatorConfig): RowGap { +internal fun rowGapBelow( + item: ChatListItem, + below: ChatListItem?, + config: SeparatorConfig, + carriesReceipt: Boolean = false, +): RowGap { below ?: return RowGap.Tight // Separator or unread divider adjacent → normal gap @@ -630,6 +644,8 @@ internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: Separ !item.isSameAuthorAs(below) -> RowGap.Wide // Same sender, outside grouping window → normal !config.isGrouped(item.timestamp, below.timestamp) -> RowGap.Normal + // A status line under [item] ends the run, so the pair takes the normal gap + carriesReceipt -> RowGap.Normal // Same sender, close together → tight else -> RowGap.Tight } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt index 10cffb43aa..a7ff452d22 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -26,35 +26,20 @@ internal fun effectiveReceiptStatus( return base } -/** - * Show a receipt label below a self-message only within the last 2 contiguous - * self-message groups. In reverseLayout, index 0 is the newest message. - * - * Within a group, labels appear at status boundaries (SENT↔READ). - * At group boundaries, labels are suppressed when the nearest self-group - * below already shows the same status (avoids duplicate "Read" labels). - */ -/** - * The nearest bubble below [index], stepping over the viewer's own tombstones and the send that is - * still settling ([settlingKey]): neither has taken the receipt yet, so the line stays above them. - */ -private fun receiptNeighbourBelow( - index: Int, - peek: (Int) -> ChatListItem?, - settlingKey: Any?, -): ChatListItem.ContentBubble? { - for (i in (index - 1) downTo 0) { - val bubble = peek(i) as? ChatListItem.ContentBubble ?: return null - if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue - if (bubble.isSettling(settlingKey)) continue - return bubble - } - return null -} - private fun ChatListItem.ContentBubble.isSettling(settlingKey: Any?): Boolean = settlingKey != null && messageKey == settlingKey +/** A confirmed send of the viewer's that can hold a line: not a tombstone, not settling. */ +private fun ChatListItem.confirmedStatus( + otherReadPointer: MessagePointer?, + settlingKey: Any?, +): ReceiptStatus? { + val bubble = this as? ChatListItem.ContentBubble ?: return null + if (bubble.isSettling(settlingKey)) return null + return effectiveReceiptStatus(bubble, otherReadPointer) + ?.takeIf { it == ReceiptStatus.SENT || it == ReceiptStatus.READ } +} + internal fun shouldShowReceiptLabel( index: Int, item: ChatListItem.ContentBubble, @@ -64,10 +49,16 @@ internal fun shouldShowReceiptLabel( ): Boolean = shouldShowReceiptLabel(index, item, messages::peek, otherReadPointer, settlingKey) /** + * Whether [item] carries a status line, following iOS: the viewer's newest confirmed send shows + * its status, and the newest one the counterpart has read shows "Read" even when a newer one is + * only delivered, so the two lines sit under their own bubbles. A failed send always shows its own. + * * [settlingKey] is the viewer's newest send while it settles: from the moment it is sent until it - * is confirmed and the settle floor has passed. It shows no line of its own in that time, and the - * line above it stays put, so the receipt moves down in one step when the hold releases rather - * than dropping at confirmation and reappearing a beat later. + * is confirmed and the settle floor has passed. It takes no line in that time, and the line above + * it stays put, so the receipt moves down in one step when the hold releases rather than dropping + * at confirmation and reappearing a beat later. + * + * Rows are newest first, as the reversed transcript holds them: [peek] of `index - 1` is newer. */ internal fun shouldShowReceiptLabel( index: Int, @@ -77,43 +68,17 @@ internal fun shouldShowReceiptLabel( settlingKey: Any? = null, ): Boolean { if (!item.carriesReceipt) return false - // A split message's receipt goes under its last row only. The rows above share its status, so - // the rules below would already hide them -- except a failure, which shows everywhere it can. + // A split message's receipt goes under its last row only; the rows above share its status. if (!item.isLastRow) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false if (status == ReceiptStatus.FAILED) return true - if (item.isSettling(settlingKey)) return false - if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false - - // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self - // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. - val belowBubble = receiptNeighbourBelow(index, peek, settlingKey) - - // Within a self-group: show at intra-group status boundaries only - if (belowBubble != null && belowBubble.isFromSelf) { - return effectiveReceiptStatus(belowBubble, otherReadPointer) != status - } - - // At a group boundary — count which self-group this is. - var selfGroups = 0 - var prevWasSelf = false - for (i in 0 until index) { - val bubble = (peek(i) ?: break) as? ChatListItem.ContentBubble - val isSelf = bubble != null && bubble.isFromSelf - if (isSelf && !prevWasSelf) selfGroups++ - prevWasSelf = isSelf - } - if (!prevWasSelf) selfGroups++ // current item starts a new group - if (selfGroups > 2) return false - - // Bottommost self-group always shows its label - if (selfGroups == 1) return true + val confirmed = item.confirmedStatus(otherReadPointer, settlingKey) ?: return false - // For group 2: show only if status differs from the nearest self-group below - for (i in (index - 1) downTo 0) { - val bubble = (peek(i) ?: break) as? ChatListItem.ContentBubble ?: continue - if (bubble.isSettling(settlingKey)) continue - if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status + // Walking toward the newest: it keeps its line while nothing newer takes it -- no newer send + // at all, or, for a read one, only newer sends that are delivered. + for (i in index - 1 downTo 0) { + val newer = peek(i)?.confirmedStatus(otherReadPointer, settlingKey) ?: continue + if (newer == ReceiptStatus.READ || confirmed != ReceiptStatus.READ) return false } return true } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt index 7e8fba524e..8a5bd22ab2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt @@ -87,4 +87,9 @@ class RowGapTest { fun `the newest row has nothing under it`() { assertEquals(RowGap.Tight, rowGapBelow(bubble(alice), below = null, config = config)) } + + @Test + fun `a status line under a bubble ends the run with a normal gap`() { + assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), bubble(alice), config, carriesReceipt = true)) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt index f110773276..ebb492667f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.components import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.ReceiptStatus import kotlin.test.Test @@ -23,10 +25,28 @@ class ReceiptHandoffTest { pendingClientIdHex = clientId, ) - private fun shown(rows: List, settlingKey: Any?): List = - rows.indices.map { i -> - shouldShowReceiptLabel(i, rows[i], { rows.getOrNull(it) }, otherReadPointer = null, settlingKey) - } + private fun theirs(id: Long) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("t$id"), + isFromSelf = false, + timestamp = Instant.fromEpochSeconds(id * 60), + ) + + private fun readUpTo(id: Long) = MessagePointer( + type = PointerType.READ, + userId = listOf(2.toByte()), + value = id, + timestamp = Instant.fromEpochSeconds(0), + ) + + private fun shown( + rows: List, + settlingKey: Any? = null, + read: MessagePointer? = null, + ): List = rows.indices.map { i -> + shouldShowReceiptLabel(i, rows[i], { rows.getOrNull(it) }, read, settlingKey) + } @Test fun `a confirmed send that is still settling leaves the line on the row above`() { @@ -60,4 +80,35 @@ class ReceiptHandoffTest { val send = mine(1, ReceiptStatus.SENT, clientId = "c1") assertEquals(listOf(false), shown(listOf(send), settlingKey = send.messageKey)) } + + @Test + fun `Read stays on the last read message while a newer one shows Delivered`() { + val rows = listOf(mine(3, ReceiptStatus.SENT), mine(2, ReceiptStatus.SENT), mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, true, false), shown(rows, read = readUpTo(2))) + } + + @Test + fun `when the newest message is read it carries the only line`() { + val rows = listOf(mine(2, ReceiptStatus.SENT), mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, false), shown(rows, read = readUpTo(2))) + } + + @Test + fun `Read skips the counterpart's messages to land on the last read one of mine`() { + val rows = listOf(mine(3, ReceiptStatus.SENT), theirs(2), mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, false, true), shown(rows, read = readUpTo(2))) + } + + @Test + fun `older delivered sends show nothing, however far back`() { + val rows = listOf(mine(5, ReceiptStatus.SENT), theirs(4), mine(3, ReceiptStatus.SENT), theirs(2), mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, false, false, false, false), shown(rows)) + } + + @Test + fun `a settling send keeps the Read line where it is`() { + val send = mine(2, ReceiptStatus.SENT, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(false, true), shown(rows, settlingKey = send.messageKey, read = readUpTo(1))) + } } 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 9134f3e42c..4822f98b1f 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 @@ -997,11 +997,17 @@ private fun groupsWith( !item.rendersBareEmoji() && !other.rendersBareEmoji() +/** + * [receiptAbove] and [receiptBelow] say whether a status line sits between [item] and the bubble + * above or below it. A line ends the run there, so the two keep their round corners. + */ fun bubblePositionOf( index: Int, item: ChatListItem.ContentBubble, messages: LazyPagingItems, config: SeparatorConfig, + receiptAbove: Boolean = false, + receiptBelow: Boolean = false, ): BubblePosition { val above = if (index + 1 < messages.itemCount) { messages.peek(index + 1) as? ChatListItem.ContentBubble @@ -1010,8 +1016,8 @@ fun bubblePositionOf( messages.peek(index - 1) as? ChatListItem.ContentBubble } else null - val groupedAbove = groupsWith(item, above, config) - val groupedBelow = groupsWith(item, below, config) + val groupedAbove = groupsWith(item, above, config) && !receiptAbove + val groupedBelow = groupsWith(item, below, config) && !receiptBelow return when { groupedAbove && groupedBelow -> BubblePosition.Middle diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index b02ed6dd41..281b19903c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -362,6 +362,7 @@ class EventStreamDelegate @Inject constructor( // Waited on so a link card lands already sized; see [MessageLinkPrefetch]. linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT) messageDataSource.upsert(chatId, resolvedMessages) + typingTracker.messageArrived(chatId, resolvedMessages.mapNotNull { it.senderId }) resolvedMessages.maxByOrNull { it.messageId } } else null diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/TypingIndicatorTracker.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/TypingIndicatorTracker.kt index bcdb270ff0..709dbe5e32 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/TypingIndicatorTracker.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/TypingIndicatorTracker.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import kotlin.time.Clock import kotlin.time.ComparableTimeMark import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import kotlin.time.TimeSource @@ -36,6 +37,7 @@ internal class TypingIndicatorTracker( private val stateHolder: ChatStateHolder, private val timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, private val expiry: Duration = INCOMING_EXPIRY, + private val stoppedLinger: Duration = STOPPED_LINGER, private val now: () -> Instant = { Clock.System.now() }, ) { @@ -45,6 +47,12 @@ internal class TypingIndicatorTracker( // Both clients send STILL every 3s while typing, so this rides out a couple of lost ones. // Same value as iOS's `incomingExpiry`. val INCOMING_EXPIRY = 10.seconds + + // The sender's composer empties on submit, which sends STOPPED before the message itself + // goes out, so STOPPED routinely lands first. Holding the dots through that gap lets the + // message take them away ([messageArrived]); a STOPPED with nothing behind it (the draft + // was deleted) just leaves this much later. iOS's `defaultStoppedLinger`. + val STOPPED_LINGER = 800.milliseconds } private class Typist(val since: Instant, val deadline: ComparableTimeMark) @@ -69,7 +77,12 @@ internal class TypingIndicatorTracker( val since = chat[notification.userId]?.since ?: now() chat[notification.userId] = Typist(since, timeSource.markNow() + expiry) } - TypingState.STOPPED_TYPING, TypingState.TYPING_TIMED_OUT -> remove(chatId, notification.userId) + TypingState.STOPPED_TYPING, TypingState.TYPING_TIMED_OUT -> { + // Lingers rather than leaving at once; someone not typing is never added back. + val chat = typists[chatId] ?: continue + val typist = chat[notification.userId] ?: continue + chat[notification.userId] = Typist(typist.since, timeSource.markNow() + stoppedLinger) + } TypingState.UNKNOWN -> Unit } } @@ -77,6 +90,15 @@ internal class TypingIndicatorTracker( scheduleExpiry() } + /** Takes [senderIds] out of [chatId]'s typists as their messages arrive, ending any linger. */ + fun messageArrived(chatId: ChatId, senderIds: Collection) = synchronized(lock) { + val chat = typists[chatId] ?: return@synchronized + if (senderIds.none { it in chat }) return@synchronized + senderIds.forEach { remove(chatId, it) } + publish() + scheduleExpiry() + } + /** Drops every typist and stops the expiry job. */ fun clear() = synchronized(lock) { typists.clear() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/TypingIndicatorTrackerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/TypingIndicatorTrackerTest.kt index e8e8b3f1e4..efacf0ffba 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/TypingIndicatorTrackerTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/TypingIndicatorTrackerTest.kt @@ -108,19 +108,41 @@ class TypingIndicatorTrackerTest { } @Test - fun `STOPPED and TIMED_OUT remove the typist`() = runTest { + fun `STOPPED and TIMED_OUT keep the typist for the linger, then drop them`() = runTest { val h = harness() h.send(chat, alice, TypingState.STARTED_TYPING) h.send(chat, bob, TypingState.STARTED_TYPING) runCurrent() h.send(chat, alice, TypingState.STOPPED_TYPING) - assertEquals(listOf(bob), h.typists(chat).map { it.userId }) - h.send(chat, bob, TypingState.TYPING_TIMED_OUT) + advance(799.milliseconds) + assertEquals(listOf(alice, bob), h.typists(chat).map { it.userId }) + + advance(1.milliseconds) assertTrue(chat !in h.stateHolder.current.typingIndicators) } + @Test + fun `a STOPPED for someone not typing adds nobody`() = runTest { + val h = harness() + h.send(chat, alice, TypingState.STOPPED_TYPING) + runCurrent() + assertTrue(chat !in h.stateHolder.current.typingIndicators) + } + + @Test + fun `the typist's message ends the linger at once`() = runTest { + val h = harness() + h.send(chat, alice, TypingState.STARTED_TYPING) + h.send(chat, bob, TypingState.STARTED_TYPING) + h.send(chat, alice, TypingState.STOPPED_TYPING) + advance(100.milliseconds) + + h.tracker.messageArrived(chat, listOf(alice)) + assertEquals(listOf(bob), h.typists(chat).map { it.userId }) + } + @Test fun `the signed-in user's own typing is ignored`() = runTest { val h = harness() From de209ef471f75d29560f58af629b0c8ef6694c5f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:42:29 -0400 Subject: [PATCH 6/6] fix(chat): move the receipt line without a jump At the handoff the new line drew at full height for a frame before its own expand started, and it grew on a different spring than the old line shrank on, while the old row's gap snapped from x2 to x1. The transcript jumped about 20px and snapped back. The row's AnimatedVisibility now owns the entrance on the reflow spring the exit uses, so one row grows as the other shrinks, and the row gap animates on the same spring. ReceiptLabel loses its own entrance. --- .../internal/screens/components/MessageRow.kt | 24 ++-- .../screens/components/ReceiptLabel.kt | 106 +++++++----------- .../flipcash/shared/chat/ui/ChatAnimations.kt | 7 ++ 3 files changed, 59 insertions(+), 78 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index dc37605a35..418fbec089 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip @@ -133,7 +134,12 @@ internal fun MessageRow( } val showReceipt = item is ChatListItem.ContentBubble && carriesReceipt(index) val receiptAbove = index + 1 < messages.itemCount && carriesReceipt(index + 1) - val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig, showReceipt) + // Animated so the gap closes with the line leaving it, instead of snapping a frame ahead of it. + val bottomSpacing by animateDpAsState( + targetValue = bottomSpacingFor(index, item, messages, separatorConfig, showReceipt), + animationSpec = ChatAnimations.reflowDp, + label = "rowGap", + ) val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false @@ -286,15 +292,6 @@ internal fun MessageRow( is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) - // Track whether this item was ever seen as SENDING so we - // can animate the receipt label entrance on the - // SENDING→SENT transition. This remember persists across - // recompositions of the same item (keyed by LazyColumn), - // surviving the status change that gates the label. - var wasSending by remember { mutableStateOf(false) } - if (item.receiptStatus == ReceiptStatus.SENDING) { - wasSending = true - } // Bound to a local: `sender` is a property of another module's public API, so // Kotlin will not smart-cast it to non-null inside the branches below. val sender = item.sender @@ -494,14 +491,17 @@ internal fun MessageRow( } AnimatedVisibility( visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, + // Only a line arriving on a row already on screen animates: a row + // composed with its line (opening the chat, scrolling back) starts + // visible. It grows on the same spring the line above shrinks on, + // so the two rows trade height and the transcript holds still. + enter = ChatAnimations.receiptEnter, exit = ChatAnimations.receiptExit, ) { if (effectiveStatus != null) { ReceiptLabel( status = effectiveStatus, readPointer = otherReadPointer, - animateEntrance = wasSending, onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { { onAction(ChatAction.RetryMessage(item)) } } else null, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index e574d1f962..a313076253 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -2,16 +2,11 @@ package com.flipcash.app.messenger.internal.screens.components import android.text.format.DateFormat import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.snap import com.flipcash.shared.chat.ui.ChatAnimations -import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -46,84 +40,64 @@ internal fun ReceiptLabel( status: ReceiptStatus, readPointer: MessagePointer?, modifier: Modifier = Modifier, - animateEntrance: Boolean = false, onRetryFailed: (() -> Unit)? = null, ) { - // animateEntrance: true only for a line arriving on the viewer's own send, which it does once - // the send has settled (see rememberSettlingSend) -- the same beat the line above leaves on. - // Opening a chat or scrolling a delivered/read message into view skips the entrance. - val deliveredVisible = remember { MutableTransitionState(!animateEntrance).apply { targetState = true } } - - val deliveredSpec = ChatAnimations.delivered - Box( modifier = modifier.padding( top = CodeTheme.dimens.grid.x1, end = CodeTheme.dimens.grid.x2, ), ) { - AnimatedVisibility( - visibleState = deliveredVisible, - // Expand + opacity, no scale: iOS un-hides the label and cross-fades its text in, - // letting the cell self-size. The line itself never scales. - enter = if (animateEntrance) { - expandVertically() + fadeIn(deliveredSpec) - } else { - expandVertically(snap()) + fadeIn(snap()) + // Delivered -> Read directional swap with scale + val readSwapSpec = ChatAnimations.readSwap + AnimatedContent( + targetState = status, + transitionSpec = { + (scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith + (scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec)) }, - exit = shrinkVertically(snap()) + fadeOut(snap()), - ) { - // Delivered -> Read directional swap with scale - val readSwapSpec = ChatAnimations.readSwap - AnimatedContent( - targetState = status, - transitionSpec = { - (scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith - (scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec)) - }, - label = "receiptStatus", - ) { animatedStatus -> - val text = when (animatedStatus) { - ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) - ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) - ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent) - else -> return@AnimatedContent - } + label = "receiptStatus", + ) { animatedStatus -> + val text = when (animatedStatus) { + ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) + ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) + ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent) + else -> return@AnimatedContent + } - val readAtFormatted = - readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" + val readAtFormatted = + readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" - Row( - modifier = if (animatedStatus == ReceiptStatus.FAILED && onRetryFailed != null) { - Modifier.clickable(onClick = onRetryFailed) + Row( + modifier = if (animatedStatus == ReceiptStatus.FAILED && onRetryFailed != null) { + Modifier.clickable(onClick = onRetryFailed) + } else { + Modifier + }, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + modifier = Modifier.alignByBaseline(), + text = text, + style = CodeTheme.typography.caption.copy( + fontWeight = FontWeight.Bold, + ), + color = if (animatedStatus == ReceiptStatus.FAILED) { + CodeTheme.colors.errorText } else { - Modifier + CodeTheme.colors.textSecondary }, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { + ) + + if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) { Text( modifier = Modifier.alignByBaseline(), - text = text, + text = readAtFormatted, style = CodeTheme.typography.caption.copy( - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, ), - color = if (animatedStatus == ReceiptStatus.FAILED) { - CodeTheme.colors.errorText - } else { - CodeTheme.colors.textSecondary - }, + color = CodeTheme.colors.textSecondary, ) - - if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) { - Text( - modifier = Modifier.alignByBaseline(), - text = readAtFormatted, - style = CodeTheme.typography.caption.copy( - fontWeight = FontWeight.Medium, - ), - color = CodeTheme.colors.textSecondary, - ) - } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 333421081f..5a5f2da402 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.ui.Alignment @@ -37,6 +38,10 @@ object ChatAnimations { private const val ReflowStiffness = 322f private val reflowIntSize: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) + private val reflowFloat: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) + val reflowDp: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) // Typing indicator entry/exit — scale from 0.95 + opacity. val typingIndicator: SpringSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) @@ -87,6 +92,8 @@ object ChatAnimations { // line fades out fast enough to be gone before the bubble below reaches it. iOS // receiptExitFade: 0.08 s. val receiptExit: ExitTransition = shrinkVertically(reflowIntSize) + fadeOut(tween(durationMillis = 80)) + // Its arrival on the row below, on the same spring, so one row grows as the other shrinks. + val receiptEnter: EnterTransition = expandVertically(reflowIntSize) + fadeIn(reflowFloat) // Reaction pills, from iOS ChatMotion's reaction springs and scales. // A pill arriving — iOS reaction: .spring(duration: 0.32, bounce: 0.35), from 40%.