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..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 @@ -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 @@ -64,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 @@ -77,11 +79,13 @@ 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 import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.milliseconds @Composable internal fun MessageList( @@ -150,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. @@ -258,7 +263,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. @@ -420,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. @@ -562,6 +572,16 @@ 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. + 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 @@ -579,6 +599,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 @@ -610,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. * @@ -767,6 +836,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 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..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 @@ -97,6 +98,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, /** @@ -123,32 +126,46 @@ 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) + // 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 - // 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 } } @@ -275,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 @@ -376,7 +384,9 @@ internal fun MessageRow( index, item, messages, - separatorConfig + separatorConfig, + receiptAbove = receiptAbove, + receiptBelow = showReceipt, ), attention = attention, ) @@ -461,8 +471,6 @@ internal fun MessageRow( ) } } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) // 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 @@ -483,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, @@ -540,10 +551,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)), @@ -590,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 @@ -615,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 @@ -627,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/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index 82284637ac..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,15 +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.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 @@ -20,11 +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.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,107 +34,70 @@ 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( status: ReceiptStatus, readPointer: MessagePointer?, modifier: Modifier = Modifier, - 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 - } - } - - val deliveredSpec = ChatAnimations.delivered - Box( modifier = modifier.padding( top = CodeTheme.dimens.grid.x1, end = CodeTheme.dimens.grid.x2, ), ) { - AnimatedVisibility( - visible = 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/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..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,25 +26,18 @@ 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. */ -private fun receiptNeighbourBelow( - index: Int, - messages: LazyPagingItems, -): ChatListItem.ContentBubble? { - for (i in (index - 1) downTo 0) { - val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null - if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) 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( @@ -52,45 +45,40 @@ internal fun shouldShowReceiptLabel( item: ChatListItem.ContentBubble, messages: LazyPagingItems, otherReadPointer: MessagePointer?, + settlingKey: Any? = null, +): 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 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, + 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 - // 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 (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) - - // 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 peek = messages.peek(i) ?: break - val bubble = peek 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 peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble ?: 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/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/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 new file mode 100644 index 0000000000..ebb492667f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -0,0 +1,114 @@ +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 +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 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`() { + 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)) + } + + @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/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 3921cd81bd..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 @@ -19,9 +20,28 @@ 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 + // 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). + 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) @@ -68,9 +88,12 @@ 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)) + // 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%. @@ -80,9 +103,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/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() 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(