From c158122730ce279206f6b398035e31fb08a5a56c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 15:14:39 -0400 Subject: [PATCH 1/2] feat(chat): draw the typing dots as the newest transcript row The typing dots move out of the message bar and sit under the newest message, as iOS draws them since code-ios-app#908. When the only typist's message lands, its bubble grows out of the dots bubble instead of being inserted beside it. The dots are drawn inside the newest row rather than as a lazy item of their own, so list indices keep matching paging indices for the helpers that assume it. The newest row opens and closes by the dots' height on the insertion push spring while the list rests on it, and scrolled up the scroll offset absorbs the change so nothing on screen moves. Which arrival takes the dots follows the shared rule in the orchestrator's cross-platform-parity doc: the newest arrival, if its sender was typing and nobody is still typing after it. The tracker now holds an arrived typist for up to 1.5s so the dots are still there when the message reaches the paged list. --- .../screens/components/ChatBottomBar.kt | 51 --- .../screens/components/MessageList.kt | 153 +++++++- .../internal/screens/components/MessageRow.kt | 51 ++- .../internal/screens/components/TypingRow.kt | 348 ++++++++++++++++++ .../messenger/internal/TypingHandoffTest.kt | 105 ++++++ .../flipcash/shared/chat/ui/ChatAnimations.kt | 14 + .../com/flipcash/shared/chat/ChatState.kt | 5 + .../delegates/TypingIndicatorTracker.kt | 34 +- .../shared/chat/TypingIndicatorTrackerTest.kt | 54 ++- .../ui/components/chat/TypingIndicator.kt | 41 ++- 10 files changed, 767 insertions(+), 89 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingHandoffTest.kt 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 e8f23e3c62..6a4a3c0d6d 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 @@ -5,13 +5,7 @@ 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 -import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -49,7 +43,6 @@ 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 @@ -62,16 +55,13 @@ 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.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.getcode.ui.components.chat.TypingIndicator import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured import com.getcode.ui.utils.rememberKeyboardController @@ -146,47 +136,6 @@ internal fun UserControlBottomBar( modifier = Modifier .fillMaxWidth(), ) { - // Typing indicator entry/exit — scale from 0.95 anchored leading + opacity - // (same as message bubble insertion, no vertical slide) - AnimatedContent( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - targetState = state.typists.isNotEmpty(), - transitionSpec = { - (scaleIn( - ChatAnimations.typingIndicator, - initialScale = 0.95f, - transformOrigin = TransformOrigin(0f, 0.5f) - ) + fadeIn(ChatAnimations.typingIndicator)) togetherWith - (scaleOut( - ChatAnimations.typingIndicator, - targetScale = 0.95f, - transformOrigin = TransformOrigin(0f, 0.5f) - ) + fadeOut(ChatAnimations.typingIndicator)) using - // The container shrinks to nothing as the indicator leaves; unclipped, the - // indicator fades out whole instead of being cut off by the shrinking bounds. - SizeTransform(clip = false) - } - ) { show -> - if (show) { - TypingIndicator( - typists = state.typingAvatars, - key = { it.key }, - modifier = Modifier - .hazeBlur(HazeInput.Sources(hazeState), material), - ) { typist -> - // Drawn the way the transcript's sender gutter draws the same member, so the - // face beside the dots is the face beside their messages. A typist whose - // profile hasn't resolved goes through the same call with no picture and no - // name, rather than a separate unknown-person glyph. - val profile = typist.profile - ContactAvatar( - image = profile?.profilePicture, - displayName = profile?.displayName.orEmpty(), - access = BlobAccessContext.profile(typist.userId), - ) - } - } - } // 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. 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 7146656712..4bf0a8edef 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 @@ -1,6 +1,14 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.core.Animatable +import com.getcode.ui.core.addIf +import kotlin.math.roundToInt +import kotlinx.coroutines.launch +import kotlinx.coroutines.coroutineScope +import androidx.compose.foundation.layout.Column +import androidx.compose.animation.core.tween +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.SideEffect import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.gestures.animateScrollBy @@ -349,6 +357,70 @@ internal fun MessageList( mutableStateMapOf() } + // The typing dots, drawn under the newest row rather than as a row of their own: the paged + // indices stay the lazy indices every helper here assumes, and the newest row opening and + // closing at its lower edge moves the transcript the way the insertion push does. See + // TypingRowModel for which arrival grows out of them. + val typingRow = remember { TypingRowModel() } + val dotsPresence = remember { Animatable(0f) } + val typingScope = rememberCoroutineScope() + val density = LocalDensity.current + val rowGaps = CodeTheme.dimens.grid.let { grid -> + with(density) { Triple(grid.x1.roundToPx(), grid.x2.roundToPx(), grid.x3.roundToPx()) } + } + fun RowGap.px() = when (this) { + RowGap.Tight -> rowGaps.first + RowGap.Normal -> rowGaps.second + RowGap.Wide -> rowGaps.third + } + val typingFrame = typingRow.update( + typing = state.typists, + messages = messages, + dotsHeightPx = { + Snapshot.withoutReadObservation { (typingRow.slotPx * dotsPresence.value).roundToInt() } + }, + resting = { Snapshot.withoutReadObservation { listState.restsOnNewest() } }, + gapPx = { item, below -> rowGapBelow(item, below, separatorConfig).px() }, + newestGapPx = RowGap.Tight.px(), + ) + if (typingFrame.anchorNewest) { + // Before the layout that places the arrival, which would otherwise hold the previous + // newest row where it sat and leave the arrival under the bottom edge. + SideEffect { listState.requestScrollToItem(0, 0) } + } + if (typingFrame.handedOff) { + // The message took the dots' place, so there is nothing left of them to close. Not an + // effect keyed on the frame, which the next recomposition would cancel. + SideEffect { typingScope.launch { dotsPresence.snapTo(0f) } } + } + val showDots = typingFrame.typists.isNotEmpty() + val dotsVisible by remember { derivedStateOf { dotsPresence.value > 0f } } + val dotsAvatars = if (isGroup) { + state.typingAvatars.filter { it.userId in typingFrame.typists } + } else { + emptyList() + } + LaunchedEffect(showDots) { + val target = if (showDots) 1f else 0f + if (dotsPresence.value == target) return@LaunchedEffect + if (listState.restsOnNewest()) { + // Resting on the newest row, the dots open and close it, and the rows above ride + // with them on the insertion push's spring. + dotsPresence.animateTo(target, ChatAnimations.typingRowHeight) + } else { + // Scrolled up, nothing on screen moves for them: only the newest row changes + // height, and the scroll takes that change back. + val delta = ((target - dotsPresence.value) * typingRow.slotPx).roundToInt() + dotsPresence.snapTo(target) + if (listState.firstVisibleItemIndex == 0 && delta != 0) { + listState.requestScrollToItem( + 0, + (listState.firstVisibleItemScrollOffset + delta).coerceAtLeast(0), + ) + } + } + } + LazyColumn( // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The // conversation is a full-screen destination, not a sheet, so there is no dismiss drag @@ -393,7 +465,11 @@ internal fun MessageList( // Cross-item bookkeeping, so it stays with the list rather than the row: a message // animates in once, the first time it is laid out after the initial page. - val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys + // The newest row opening from under the typing dots: see TypingRowModel. + val grow = typingFrame.grow?.takeIf { index == 0 && it.key == item.itemKey } + val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && + // A message growing out of the dots doesn't rise in as well. + grow?.handoff == null if (animateInsertion) animatedKeys.add(item.itemKey) val bubble = item as? ChatListItem.ContentBubble @@ -423,6 +499,52 @@ internal fun MessageList( } val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey + var growing by remember { mutableStateOf(grow != null) } + val growProgress = remember { Animatable(if (grow != null) 0f else 1f) } + val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) } + val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) } + LaunchedEffect(grow) { + grow ?: return@LaunchedEffect + coroutineScope { + val spec = if (grow.handoff != null) { + ChatAnimations.fromTyping + } else { + ChatAnimations.insertionPush + } + launch { growProgress.animateTo(1f, spec) } + if (grow.handoff != null) { + launch { + contentAlpha.animateTo( + 1f, + tween( + durationMillis = ChatAnimations.fromTypingTextFadeMs, + delayMillis = ChatAnimations.fromTypingTextDelayMs, + ), + ) + } + launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) } + } + } + growing = false + typingRow.finished(grow.key) + } + val handoff = grow?.handoff?.takeIf { growing }?.let { from -> + remember(from) { + TypingHandoff( + from = from, + progress = { growProgress.value }, + contentAlpha = { contentAlpha.value }, + dotsAlpha = { dotsAlpha.value }, + ) + } + } + + // Every item is wrapped alike, so a row keeps its state as it moves off index 0. + Column( + modifier = Modifier.addIf(grow != null && growing) { + Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value } + }, + ) { MessageRow( index = index, item = item, @@ -453,7 +575,27 @@ internal fun MessageList( } else { NoAttention }, + typingHandoff = handoff, ) + + // Under the newest row, not in place of it. A message growing out of the dots + // draws what is left of them itself. + if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) { + TypingRow( + avatars = dotsAvatars, + // The newest row already keeps the tight gap under it; the dots add the rest. + gapAbove = when (gapAboveTypingRow(item, typingFrame.typists)) { + RowGap.Tight -> 0.dp + RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 + RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 + }, + model = typingRow, + presence = { dotsPresence.value }, + // Dots carried under a newer message keep their place. + enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f }, + ) + } + } } // Show trailing separator and contact info once messages are available @@ -571,12 +713,15 @@ internal fun MessageList( } .filterNotNull() .distinctUntilChanged() - .collectLatest { + .collectLatest { newestKey -> // 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() } + // Opening from under the typing dots already moves the transcript. Checked after + // the layout, by which point the composition that decided it has run. + if (typingRow.grows(newestKey)) return@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. @@ -768,6 +913,10 @@ private suspend fun LazyListState.alignBelowTopBar(index: Int) { scrollToItem(index, scrollOffset = -(band.last - band.first - height).coerceAtLeast(0)) } +/** Whether the transcript rests on its newest row, with nothing of it scrolled out of view. */ +private fun LazyListState.restsOnNewest(): Boolean = + firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 + /** The presented index of [messageId], or `null` while it is still unloaded. */ private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? = (0 until messages.itemCount).firstOrNull { i -> 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 418fbec089..016d600126 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 @@ -37,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import com.flipcash.shared.chat.ui.BubblePosition +import com.getcode.ui.components.chat.TypingDots import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.draw.BlurredEdgeTreatment @@ -122,6 +124,8 @@ internal fun MessageRow( /** Where the top bar ends, from the window's top; the strip stays below it. */ topBarBottom: Dp = 0.dp, attention: () -> Float = { 0f }, + /** Set while this row's bubble grows out of the typing dots it replaced; see [TypingHandoff]. */ + typingHandoff: TypingHandoff? = null, ) { val onAction = LocalChatActionHandler.current val vibrator = LocalVibrator.current @@ -365,11 +369,17 @@ internal fun MessageRow( // The strip lines up with the bubble as drawn, which sits // inside a full-width layout, so it's measured here rather than // taken from the popup's anchor. - modifier = Modifier.addIf(onBubbleBounds != null) { - Modifier.onGloballyPositioned { - onBubbleBounds?.invoke(it.boundsInWindow().roundToIntRect()) + modifier = Modifier + .addIf(onBubbleBounds != null) { + Modifier.onGloballyPositioned { + onBubbleBounds?.invoke(it.boundsInWindow().roundToIntRect()) + } } - }, + .then( + typingHandoff?.let { + Modifier.growFromTypingDots(it.from, it.progress, it.contentAlpha) + } ?: Modifier + ), item = item, // The bubble's own targets go with the row's: a cash // bubble behind the backdrop would otherwise open token @@ -380,17 +390,34 @@ internal fun MessageRow( // same gesture is what makes a cash bubble selectable. onLongClick = select, onDoubleClick = presentStrip, - position = bubblePositionOf( - index, - item, - messages, - separatorConfig, - receiptAbove = receiptAbove, - receiptBelow = showReceipt, - ), + // Out of the dots' solo corners, as the dots never join a run; + // it settles into its own run on the corner spring once grown. + position = if (typingHandoff != null) { + BubblePosition.Solo + } else { + bubblePositionOf( + index, + item, + messages, + separatorConfig, + receiptAbove = receiptAbove, + receiptBelow = showReceipt, + ) + }, attention = attention, ) + // What's left of the dots as the bubble grows out from under them. + if (typingHandoff != null) { + TypingDots( + modifier = Modifier + .align(Alignment.BottomStart) + .graphicsLayer { alpha = typingHandoff.dotsAlpha() }, + background = Color.Transparent, + border = Color.Transparent, + ) + } + // The quick strip lives above the message, only while it is the // selected one — it replaces the backdrop's own reach for a // reaction with something faster than opening the picker, and diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt new file mode 100644 index 0000000000..8029a71968 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt @@ -0,0 +1,348 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.util.lerp +import androidx.paging.compose.LazyPagingItems +import com.flipcash.app.messenger.internal.TypingAvatar +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.ActiveTypist +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.TypingIndicator +import kotlinx.datetime.Instant +import kotlin.math.roundToInt + +/** + * Whether the newest of [arrivals] grows out of the typing dots rather than being inserted above + * them. [arrivals] are the senders of the messages that just landed, oldest first, with null for the + * viewer's own; [typingBefore] is who the dots showed before they landed; [typing] is who is typing + * now, before the arrivals clear their senders. + * + * The shared rule in the orchestrator's `docs/cross-platform-parity.md` ("typing-dots handoff with + * several typists"): the newest arrival takes the dots' frame only if its sender was typing and + * nobody is left typing once the arrivals are counted. Otherwise the dots would have to reappear + * under the message in the same update, which moves the transcript twice. + */ +internal fun takesTypingDots( + typingBefore: Set, + arrivals: List, + typing: Set, +): Boolean { + val newest = arrivals.lastOrNull() ?: return false + return newest in typingBefore && (typing - arrivals.filterNotNull().toSet()).isEmpty() +} + +/** + * The gap between the newest message and the typing dots under it. The dots never join a run: a + * normal gap under a typist's own bubble and a wide one under anyone else's, so they read as a new + * turn, as iOS spaces them (`ChatViewController.interItemSpacing`). + */ +internal fun gapAboveTypingRow(newest: ChatListItem?, typists: Collection): RowGap { + val bubble = newest as? ChatListItem.ContentBubble ?: return RowGap.Normal + return if (!bubble.isFromSelf && bubble.authorId in typists) RowGap.Normal else RowGap.Wide +} + +/** + * The newest row opening from [fromPx] — the height the typing dots had under the row it replaced — + * to its own height, so the rows above ride up with it instead of jumping. [handoff] is set when the + * row's bubble grows out of the dots themselves. + */ +@Immutable +internal data class TypingGrow( + val key: Any, + val fromPx: Int, + val handoff: TypingDotsFrame?, +) + +/** The typing bubble's size when a message took it over, in px. */ +@Immutable +internal data class TypingDotsFrame(val size: Size) + +/** What [TypingRowModel.update] decided for this frame. */ +@Immutable +internal data class TypingRowFrame( + /** Who the dots show, oldest first. Empty once nobody is typing. */ + val typists: List, + /** The newest row opening from under the dots, until it has. */ + val grow: TypingGrow?, + /** The dots were handed to a message this frame and leave with no exit of their own. */ + val handedOff: Boolean, + /** A message opened from under the dots this frame; the list must rest on it for the layout. */ + val anchorNewest: Boolean, +) + +/** + * The typing row's bookkeeping across the frames the paged transcript changes in. + * + * Plain fields, folded in from `MessageList`'s composition: [update] runs whenever the typists or the + * paged snapshot change, both of which already recompose the list, and hands its decision down as an + * immutable [TypingRowFrame] the items read. The measured sizes are written from layout and only + * read at the moment a message arrives. + */ +internal class TypingRowModel { + private data class Session(val userId: ID, val since: Instant) + + private var newestKey: Any? = null + /** Typing sessions whose message has landed in the transcript. The dots drop them at once. */ + private val landed = mutableSetOf() + private var shown: Set = emptySet() + private var grow: TypingGrow? = null + private val grown = mutableSetOf() + + /** The dots row's height at full presence, gap above included, as last measured. */ + var slotPx: Int = 0 + /** The dots bubble's size, as last measured. */ + var dotsSize: Size = Size.Zero + + /** + * Folds [typing] and the paged snapshot into the frame. [dotsHeightPx] is how tall the dots + * stand under the newest row right now, and [resting] says whether the transcript sits on its + * newest row. [gapPx] is the gap the transcript puts between two rows; the newest row's own gap + * below it is [newestGapPx]. + */ + fun update( + typing: Set, + messages: LazyPagingItems, + dotsHeightPx: () -> Int, + resting: () -> Boolean, + gapPx: (item: ChatListItem, below: ChatListItem) -> Int, + newestGapPx: Int, + ): TypingRowFrame { + var handedOff = false + var anchor = false + val newest = if (messages.itemCount > 0) messages.peek(0) else null + val newestBubble = newest as? ChatListItem.ContentBubble + if (newestBubble != null && newestBubble.itemKey != newestKey) { + val previous = newestKey + newestKey = newestBubble.itemKey + val previousIndex = previous?.let { key -> + (1 until minOf(messages.itemCount, ArrivalScanLimit)).firstOrNull { i -> + (messages.peek(i) as? ChatListItem.ContentBubble)?.itemKey == key + } + } + if (previousIndex != null) { + val arrivals = (previousIndex - 1 downTo 0).mapNotNull { + messages.peek(it) as? ChatListItem.ContentBubble + } + val senders = arrivals.map { if (it.isFromSelf) null else it.authorId } + val live = typing.filterNot { it.session() in landed } + val takes = takesTypingDots( + typingBefore = shown, + arrivals = senders, + typing = live.map { it.userId }.toSet(), + ) + val landedIds = senders.filterNotNull().toSet() + live.filter { it.userId in landedIds }.forEach { landed += it.session() } + + // Only while resting on the dots: scrolled up, the arrival lands off screen and + // nothing on screen should move for it. + val under = dotsHeightPx() + if (under > 0 && resting()) { + // The row above now keeps the gap it has against the arrival rather than the + // newest row's; whatever that adds comes out of where the arrival starts, so + // the row above stays exactly where it stood over the dots. + val gapAbove = messages.peek(1)?.let { gapPx(it, newestBubble) } ?: newestGapPx + grow = TypingGrow( + key = newestBubble.itemKey, + fromPx = (under + newestGapPx - gapAbove).coerceAtLeast(0), + handoff = if (takes) TypingDotsFrame(dotsSize) else null, + ) + anchor = true + } + handedOff = takes + } + } else if (newestBubble == null) { + newestKey = null + } + + landed.retainAll(typing.map { it.session() }.toSet()) + val next = typing + .filterNot { it.session() in landed } + .sortedWith(compareBy({ it.since }, { it.userId.toString() })) + .map { it.userId } + shown = next.toSet() + return TypingRowFrame( + typists = next, + grow = grow?.takeIf { it.key !in grown && it.key == newestKey }, + handedOff = handedOff, + anchorNewest = anchor, + ) + } + + /** Whether [key] is the arrival this model just opened, so the list's own push leaves it be. */ + fun grows(key: Any?): Boolean = grow?.key == key && key !in grown + + /** Called when a grow has played, so a row scrolled away and back doesn't replay it. */ + fun finished(key: Any) { + grown += key + } + + private fun ActiveTypist.session() = Session(userId, since) + + private companion object { + // How far back from the newest message an arrival is looked for. A burst bigger than this + // is a sync, not someone replying, and opens no handoff. + const val ArrivalScanLimit = 24 + } +} + +/** + * Lays the newest row out at a height opening from [TypingGrow.fromPx] to its own on [progress], + * bottom-aligned so its lower edge stays put and the rows above ride up with its top. Clipped while + * it opens, so the part not yet uncovered doesn't draw over the row above. + */ +internal fun Modifier.openFromTypingRow(fromPx: Int, progress: () -> Float): Modifier = + this + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val height = lerp(fromPx.toFloat(), placeable.height.toFloat(), progress()) + .roundToInt() + .coerceAtLeast(0) + layout(placeable.width, height) { placeable.place(0, height - placeable.height) } + } + .graphicsLayer { clip = progress() < 1f } + +/** + * An incoming bubble growing out of the typing bubble it replaced, as iOS #908 does: its outline + * grows from the dots bubble's [from] size to its own about its bottom-leading corner, in the chrome + * the dots were drawn in, while its content fades in behind it. + */ +@Composable +internal fun Modifier.growFromTypingDots( + from: TypingDotsFrame, + progress: () -> Float, + contentAlpha: () -> Float, +): Modifier { + val chrome = CodeTheme.colors.chat.incomingBubble.background + val radius = with(LocalDensity.current) { CodeTheme.shapes.medium.topStart.toPx(Size.Unspecified, this) } + fun Size.outline(): Rect { + val p = progress() + val width = lerp(from.size.width, width, p).coerceAtLeast(0f) + val height = lerp(from.size.height, height, p).coerceAtLeast(0f) + return Rect(Offset(0f, this.height - height), Size(width, height)) + } + return this + .drawBehind { + // Hands over to the bubble's own fill as the content fades in, so the two don't stack. + drawRoundRect( + color = chrome, + topLeft = size.outline().topLeft, + size = size.outline().size, + cornerRadius = CornerRadius(radius), + alpha = 1f - contentAlpha(), + ) + } + .graphicsLayer { + alpha = contentAlpha() + clip = true + shape = GenericShape { size, _ -> addRoundRect(RoundRect(size.outline(), CornerRadius(radius))) } + } +} + +/** + * The typing dots under the newest row, for everyone in [avatars] (a group's faces; a DM shows the + * dots alone), in the incoming bubble's chrome so a message can grow out of them. + * + * [presence] is the row's share of its own height: 1 while it is in, 0 when it is out. Reading it in + * layout lets the newest row open and close with it, which moves the rows above on the same spring + * the insertion push uses. [enters] plays the dots' own scale-in, for the frame they first appear; + * dots carried under a newer message keep their place instead. + */ +@Composable +internal fun TypingRow( + avatars: List, + gapAbove: Dp, + model: TypingRowModel, + presence: () -> Float, + enters: Boolean, + modifier: Modifier = Modifier, +) { + var appeared by remember { mutableStateOf(!enters) } + LaunchedEffect(Unit) { appeared = true } + val entry by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = ChatAnimations.typingIndicator, + label = "typingRowEntry", + ) + + Box( + modifier = modifier + .fillMaxWidth() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + model.slotPx = placeable.height + val height = (placeable.height * presence().coerceIn(0f, 1f)).roundToInt() + layout(placeable.width, height) { placeable.place(0, height - placeable.height) } + } + .graphicsLayer { + alpha = (presence() * entry).coerceIn(0f, 1f) + val scale = lerp(TypingEntryScale, 1f, entry) + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 1f) + } + // The row above keeps the newest row's tight gap; the dots add the rest of theirs. + // Their own lower gap is the newest row's. + .padding(top = gapAbove, bottom = CodeTheme.dimens.grid.x1), + ) { + TypingIndicator( + typists = avatars, + key = { it.key }, + background = CodeTheme.colors.chat.incomingBubble.background, + border = Color.Transparent, + modifier = Modifier.onSizeChanged { model.dotsSize = Size(it.width.toFloat(), it.height.toFloat()) }, + ) { typist -> + // Drawn the way the sender gutter draws the same member, so the face beside the dots is + // the face beside their messages. + val profile = typist.profile + ContactAvatar( + image = profile?.profilePicture, + displayName = profile?.displayName.orEmpty(), + access = BlobAccessContext.profile(typist.userId), + ) + } + } +} + +private const val TypingEntryScale = 0.95f + +/** + * A message growing out of the typing dots, handed to its row: [progress] runs its outline from the + * dots bubble's [from] size to its own, [contentAlpha] fades its content in behind that, and + * [dotsAlpha] fades the dots out where they stood. + */ +@androidx.compose.runtime.Stable +internal class TypingHandoff( + val from: TypingDotsFrame, + val progress: () -> Float, + val contentAlpha: () -> Float, + val dotsAlpha: () -> Float, +) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingHandoffTest.kt new file mode 100644 index 0000000000..68da30b064 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingHandoffTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.RowGap +import com.flipcash.app.messenger.internal.screens.components.gapAboveTypingRow +import com.flipcash.app.messenger.internal.screens.components.takesTypingDots +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Which arrival grows out of the typing dots, and how far the dots sit from the row above them. + * + * The handoff cases are the shared table in the orchestrator's `docs/cross-platform-parity.md` + * ("typing-dots handoff with several typists"), one test per row, so iOS can check against the same + * list. `typing` is who is typing when the arrivals land, before they clear their senders; a null + * arrival is the viewer's own message. + */ +class TypingHandoffTest { + + @Test + fun `a DM reply takes the dots`() { + assertTrue(takesTypingDots(typingBefore = setOf(A), arrivals = listOf(A), typing = setOf(A))) + } + + @Test + fun `a DM reply during the linger takes the dots`() { + // The linger keeps A in the set after they stopped, so it reads the same as still typing. + assertTrue(takesTypingDots(typingBefore = setOf(A), arrivals = listOf(A), typing = setOf(A))) + } + + @Test + fun `a reply after the linger expired has no dots to take`() { + assertFalse(takesTypingDots(typingBefore = emptySet(), arrivals = listOf(A), typing = emptySet())) + } + + @Test + fun `one of two typists sending leaves the dots to the other`() { + assertFalse(takesTypingDots(typingBefore = setOf(A, B), arrivals = listOf(A), typing = setOf(A, B))) + } + + @Test + fun `a member who wasn't typing doesn't take the dots`() { + assertFalse(takesTypingDots(typingBefore = setOf(A), arrivals = listOf(B), typing = setOf(A))) + } + + @Test + fun `the viewer's own message doesn't take the dots`() { + assertFalse(takesTypingDots(typingBefore = setOf(A), arrivals = listOf(null), typing = setOf(A))) + } + + @Test + fun `when both typists send together the newest takes the dots`() { + assertTrue( + takesTypingDots(typingBefore = setOf(A, B), arrivals = listOf(A, B), typing = setOf(A, B)), + ) + } + + @Test + fun `a newest arrival that wasn't typing leaves the dots to exit`() { + assertFalse(takesTypingDots(typingBefore = setOf(A), arrivals = listOf(A, B), typing = setOf(A))) + } + + @Test + fun `no arrivals take nothing`() { + assertFalse(takesTypingDots(typingBefore = setOf(A), arrivals = emptyList(), typing = setOf(A))) + } + + @Test + fun `the dots sit a normal gap under the typist's own bubble`() { + assertEquals(RowGap.Normal, gapAboveTypingRow(bubble(alice), listOf(alice.userId))) + } + + @Test + fun `the dots sit a wide gap under anyone else's bubble`() { + assertEquals(RowGap.Wide, gapAboveTypingRow(bubble(bob), listOf(alice.userId))) + assertEquals(RowGap.Wide, gapAboveTypingRow(bubble(null, isFromSelf = true), listOf(alice.userId))) + } + + @Test + fun `the dots sit a normal gap under a separator`() { + assertEquals(RowGap.Normal, gapAboveTypingRow(ChatListItem.UnreadDivider(count = 1), listOf(alice.userId))) + } + + private companion object { + const val A = "A" + const val B = "B" + + val alice = SenderIdentity(userId = listOf(1), displayName = "Alice", picture = null) + val bob = SenderIdentity(userId = listOf(2), displayName = "Bob", picture = null) + + fun bubble(sender: SenderIdentity?, isFromSelf: Boolean = false) = ChatListItem.ContentBubble( + messageId = 1L, + contentIndex = 0, + content = MessageContent.Text("hi"), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochMilliseconds(1_700_000_000_000), + sender = sender, + ) + } +} 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 5a5f2da402..daa7ecedb7 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 @@ -46,6 +46,20 @@ object ChatAnimations { // Typing indicator entry/exit — scale from 0.95 + opacity. val typingIndicator: SpringSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) + // The typing row's own height opening as it joins the transcript and closing as it leaves, the + // rows above riding with it. The push's spring, so the dots and a message arriving under them + // move the transcript the same way. + val typingRowHeight: SpringSpec = insertionPush + + // An incoming message growing out of the typing bubble it replaces: the bubble's outline grows + // from the dots' to its own, the content fades in behind it, and the dots fade where they stood. + // iOS fromTyping: .spring(duration: 0.20, bounce: 0.21), fromTypingTextDelay 0.058, + // fromTypingTextFade 0.155, fromTypingDotsFade 0.08. + val fromTyping: SpringSpec = spring(dampingRatio = 0.79f, stiffness = 987f) + const val fromTypingTextDelayMs = 58 + const val fromTypingTextFadeMs = 155 + const val fromTypingDotsFadeMs = 80 + // Action bar <-> composer swap — scale from 0.95 + opacity. val composerSwap: SpringSpec = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index ce6188dc9c..42ee2647e5 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -34,6 +34,11 @@ data class ChatSummary( data class ActiveTypist( val userId: ID, val since: Instant, + /** + * Their message has arrived and is on its way into the transcript. They stay listed briefly, so + * the transcript's dots hold until the message is there to grow out of them. + */ + val arrived: Boolean = false, ) enum class FeedSyncState { 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 709dbe5e32..3417c34e41 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 @@ -38,6 +38,7 @@ internal class TypingIndicatorTracker( private val timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, private val expiry: Duration = INCOMING_EXPIRY, private val stoppedLinger: Duration = STOPPED_LINGER, + private val arrivalHold: Duration = ARRIVAL_HOLD, private val now: () -> Instant = { Clock.System.now() }, ) { @@ -53,9 +54,17 @@ internal class TypingIndicatorTracker( // 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 + + // How long a typist stays listed after their message arrives. The transcript draws the + // dots as its newest row and grows the message out of them, but the message reaches the + // paged list a beat after it reaches here; dropping the typist on arrival would take the + // dots away first and leave nothing to grow out of. The transcript lets go of the typist + // itself as soon as the message lands, so this only runs out when it never does. + val ARRIVAL_HOLD = 1500.milliseconds } - private class Typist(val since: Instant, val deadline: ComparableTimeMark) + /** [arrived] marks a typist held for their message to land; see [ARRIVAL_HOLD]. */ + private class Typist(val since: Instant, val deadline: ComparableTimeMark, val arrived: Boolean = false) private val lock = Any() private val typists = mutableMapOf>() @@ -74,13 +83,16 @@ internal class TypingIndicatorTracker( TypingState.STARTED_TYPING, TypingState.STILL_TYPING -> { val chat = typists.getOrPut(chatId) { mutableMapOf() } // A STILL heartbeat extends the deadline but keeps the typist's place in line. - val since = chat[notification.userId]?.since ?: now() + // Typing again after a message is a new session, with a new place. + val since = chat[notification.userId]?.takeUnless { it.arrived }?.since ?: now() chat[notification.userId] = Typist(since, timeSource.markNow() + expiry) } 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 + // Already held for their message; the STOPPED that trails a send doesn't cut it short. + if (typist.arrived) continue chat[notification.userId] = Typist(typist.since, timeSource.markNow() + stoppedLinger) } TypingState.UNKNOWN -> Unit @@ -90,11 +102,21 @@ internal class TypingIndicatorTracker( scheduleExpiry() } - /** Takes [senderIds] out of [chatId]'s typists as their messages arrive, ending any linger. */ + /** + * Ends the typing of [senderIds] in [chatId] as their messages arrive: any linger is cut to + * [ARRIVAL_HOLD], and a later STOPPED doesn't reopen it. They stay listed for that long so the + * transcript can hand their dots to the message; see [ARRIVAL_HOLD]. + */ 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) } + var changed = false + for (senderId in senderIds) { + val typist = chat[senderId] ?: continue + if (typist.arrived) continue + chat[senderId] = Typist(typist.since, timeSource.markNow() + arrivalHold, arrived = true) + changed = true + } + if (!changed) return@synchronized publish() scheduleExpiry() } @@ -141,7 +163,7 @@ internal class TypingIndicatorTracker( private fun publish() { val snapshot = typists.mapValues { (_, chat) -> - chat.map { (userId, typist) -> ActiveTypist(userId = userId, since = typist.since) }.toSet() + chat.map { (userId, typist) -> ActiveTypist(userId = userId, since = typist.since, arrived = typist.arrived) }.toSet() } stateHolder.update { it.copy(typingIndicators = snapshot) } } 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 efacf0ffba..4eec9395dd 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 @@ -132,17 +132,67 @@ class TypingIndicatorTrackerTest { } @Test - fun `the typist's message ends the linger at once`() = runTest { + fun `the typist's message holds them as arrived, then drops them`() = 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)) + runCurrent() + assertEquals(listOf(alice to true, bob to false), h.typists(chat).map { it.userId to it.arrived }) + + advance(1499.milliseconds) + assertEquals(listOf(alice, bob), h.typists(chat).map { it.userId }) + advance(1.milliseconds) assertEquals(listOf(bob), h.typists(chat).map { it.userId }) } + @Test + fun `a message cuts a longer typing deadline to the hold`() = runTest { + val h = harness() + h.send(chat, alice, TypingState.STARTED_TYPING) + runCurrent() + + h.tracker.messageArrived(chat, listOf(alice)) + advance(1500.milliseconds) + assertTrue(chat !in h.stateHolder.current.typingIndicators) + } + + @Test + fun `the STOPPED that trails a send does not reopen the linger`() = runTest { + val h = harness() + h.send(chat, alice, TypingState.STARTED_TYPING) + h.tracker.messageArrived(chat, listOf(alice)) + advance(1400.milliseconds) + + h.send(chat, alice, TypingState.STOPPED_TYPING) + advance(100.milliseconds) + assertTrue(chat !in h.stateHolder.current.typingIndicators) + } + + @Test + fun `typing again after a message starts a new session`() = runTest { + val h = harness() + h.send(chat, alice, TypingState.STARTED_TYPING) + h.tracker.messageArrived(chat, listOf(alice)) + advance(200.milliseconds) + + h.send(chat, alice, TypingState.STARTED_TYPING) + runCurrent() + val typist = h.typists(chat).single() + assertEquals(false, typist.arrived) + assertEquals(Instant.fromEpochMilliseconds(200), typist.since) + } + + @Test + fun `a message from someone not typing adds nobody`() = runTest { + val h = harness() + h.tracker.messageArrived(chat, listOf(alice)) + runCurrent() + assertTrue(chat !in h.stateHolder.current.typingIndicators) + } + @Test fun `the signed-in user's own typing is ignored`() = runTest { val h = harness() diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index e727005c72..b0c6a8da50 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -45,12 +45,14 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp @@ -69,26 +71,27 @@ import kotlin.math.roundToInt * top at the right. [avatar] draws one typist inside a circle the stack sizes, clips and animates; * it is measured to fill that circle. [key] identifies a typist across updates, so an avatar slides * to its new place as others join and leave rather than animating out and back in. + * + * [modifier] goes on the dots bubble, not the row, so a caller can measure or decorate the bubble + * alone. [background] and [border] colour it. */ @Composable fun TypingIndicator( typists: List, key: (T) -> Any, modifier: Modifier = Modifier, + background: Color = CodeTheme.colors.chat.typingIndicator.background, + border: Color = CodeTheme.colors.chat.typingIndicator.border, avatar: @Composable (T) -> Unit, ) { - Row( - modifier = Modifier - .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + Row(verticalAlignment = Alignment.CenterVertically) { AvatarStack( typists = typists, key = key, avatar = avatar, ) - TypingDots(modifier = modifier) + TypingDots(modifier = modifier, background = background, border = border) } } @@ -229,9 +232,15 @@ private fun AvatarStack( } } +/** + * The three waving dots in their bubble. A transparent [background] and [border] draw the dots + * alone, the way a message growing out of the bubble leaves them behind as it takes over. + */ @Composable -private fun TypingDots( +fun TypingDots( modifier: Modifier = Modifier, + background: Color = CodeTheme.colors.chat.typingIndicator.background, + border: Color = CodeTheme.colors.chat.typingIndicator.border, ) { val shape = CodeTheme.shapes.medium Row( @@ -239,15 +248,8 @@ private fun TypingDots( .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .clip(shape) .then(modifier) - .border( - color = CodeTheme.colors.chat.typingIndicator.border, - width = CodeTheme.dimens.border, - shape = CodeTheme.shapes.medium, - ) - .background( - color = CodeTheme.colors.chat.typingIndicator.background, - shape = CodeTheme.shapes.medium, - ) + .addIfBorder(border, CodeTheme.dimens.border, shape) + .background(color = background, shape = shape) .padding( horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x3, @@ -300,6 +302,13 @@ private fun TypingDots( } } +private fun Modifier.addIfBorder(border: Color, width: Dp, shape: Shape): Modifier = + if (border == Color.Transparent || border == Color.Unspecified) { + this + } else { + this.border(color = border, width = width, shape = shape) + } + private const val MaxAvatars = 3 private const val AvatarEnterScale = 0.4f // Matches the sender avatar beside a group's messages. From 7cf0d6622176fedded660fca24c7a70fc26c81d6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 15:33:40 -0400 Subject: [PATCH 2/2] fix(chat): grow the handoff bubble on its first frame without moving rows above The arrival row read the handoff decision before the model had made it, so the message appeared at full height and the dots vanished with nothing to grow from. The decision is now made idempotently by whichever of update() or the arrival row reaches it first. The first handoff frame also shifted the rows above by the change in the gap above the row; the model now carries that delta into the starting height. --- .../screens/components/MessageList.kt | 52 +++++++---- .../internal/screens/components/TypingRow.kt | 91 ++++++++++++------- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 4bf0a8edef..f217950aa7 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 @@ -365,24 +365,18 @@ internal fun MessageList( val dotsPresence = remember { Animatable(0f) } val typingScope = rememberCoroutineScope() val density = LocalDensity.current - val rowGaps = CodeTheme.dimens.grid.let { grid -> - with(density) { Triple(grid.x1.roundToPx(), grid.x2.roundToPx(), grid.x3.roundToPx()) } + val typists = state.typists + val arriveUnderDots = { + typingRow.arrive( + typing = typists, + messages = messages, + dotsHeightPx = { + Snapshot.withoutReadObservation { (typingRow.slotPx * dotsPresence.value).roundToInt() } + }, + resting = { key, index -> Snapshot.withoutReadObservation { listState.restsOn(key, index) } }, + ) } - fun RowGap.px() = when (this) { - RowGap.Tight -> rowGaps.first - RowGap.Normal -> rowGaps.second - RowGap.Wide -> rowGaps.third - } - val typingFrame = typingRow.update( - typing = state.typists, - messages = messages, - dotsHeightPx = { - Snapshot.withoutReadObservation { (typingRow.slotPx * dotsPresence.value).roundToInt() } - }, - resting = { Snapshot.withoutReadObservation { listState.restsOnNewest() } }, - gapPx = { item, below -> rowGapBelow(item, below, separatorConfig).px() }, - newestGapPx = RowGap.Tight.px(), - ) + val typingFrame = typingRow.update(typing = typists, arrive = arriveUnderDots) if (typingFrame.anchorNewest) { // Before the layout that places the arrival, which would otherwise hold the previous // newest row where it sat and leave the arrival under the bottom edge. @@ -466,7 +460,15 @@ internal fun MessageList( // Cross-item bookkeeping, so it stays with the list rather than the row: a message // animates in once, the first time it is laid out after the initial page. // The newest row opening from under the typing dots: see TypingRowModel. - val grow = typingFrame.grow?.takeIf { index == 0 && it.key == item.itemKey } + // Decided here too: the list can lay this row out before the composition above + // has seen it arrive. See TypingRowModel.arrive. + val grow = if (index == 0) { + arriveUnderDots() + if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) } + typingRow.growFor(item.itemKey) + } else { + null + } val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && // A message growing out of the dots doesn't rise in as well. grow?.handoff == null @@ -584,7 +586,7 @@ internal fun MessageList( TypingRow( avatars = dotsAvatars, // The newest row already keeps the tight gap under it; the dots add the rest. - gapAbove = when (gapAboveTypingRow(item, typingFrame.typists)) { + gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) { RowGap.Tight -> 0.dp RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 @@ -917,6 +919,17 @@ private suspend fun LazyListState.alignBelowTopBar(index: Int) { private fun LazyListState.restsOnNewest(): Boolean = firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 +/** + * Whether the transcript rests on the row keyed [key], which a new page has moved to [index]. The + * list re-anchors on that key when it measures the new page, so this holds both before that measure + * (still at 0, as the last layout placed it) and during it (already at [index]). + */ +private fun LazyListState.restsOn(key: Any, index: Int): Boolean = + firstVisibleItemScrollOffset == 0 && ( + firstVisibleItemIndex == index || + (firstVisibleItemIndex == 0 && layoutInfo.visibleItemsInfo.firstOrNull()?.key == key) + ) + /** The presented index of [messageId], or `null` while it is still unloaded. */ private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? = (0 until messages.itemCount).firstOrNull { i -> @@ -991,3 +1004,4 @@ 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/TypingRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt index 8029a71968..e88b8b7dd4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt @@ -91,6 +91,11 @@ internal data class TypingDotsFrame(val size: Size) internal data class TypingRowFrame( /** Who the dots show, oldest first. Empty once nobody is typing. */ val typists: List, + /** + * Who the dots showed last, the same as [typists] until nobody is typing. The dots close under + * these, so their gap doesn't change while they go. + */ + val lastTypists: List, /** The newest row opening from under the dots, until it has. */ val grow: TypingGrow?, /** The dots were handed to a message this frame and leave with no exit of their own. */ @@ -114,7 +119,10 @@ internal class TypingRowModel { /** Typing sessions whose message has landed in the transcript. The dots drop them at once. */ private val landed = mutableSetOf() private var shown: Set = emptySet() + private var lastShown: List = emptyList() private var grow: TypingGrow? = null + private var handOffPending = false + private var anchorPending = false private val grown = mutableSetOf() /** The dots row's height at full presence, gap above included, as last measured. */ @@ -123,21 +131,48 @@ internal class TypingRowModel { var dotsSize: Size = Size.Zero /** - * Folds [typing] and the paged snapshot into the frame. [dotsHeightPx] is how tall the dots - * stand under the newest row right now, and [resting] says whether the transcript sits on its - * newest row. [gapPx] is the gap the transcript puts between two rows; the newest row's own gap - * below it is [newestGapPx]. + * Folds [typing] and the paged snapshot into the frame, running [arrive] (a call to [arrive] + * with the list's measurements) first in case this composition is the first to see a message. */ - fun update( + fun update(typing: Set, arrive: () -> Unit): TypingRowFrame { + arrive() + val handedOff = handOffPending + handOffPending = false + val anchor = takeAnchor() + + landed.retainAll(typing.map { it.session() }.toSet()) + val next = typing + .filterNot { it.session() in landed } + .sortedWith(compareBy({ it.since }, { it.userId.toString() })) + .map { it.userId } + shown = next.toSet() + if (next.isNotEmpty()) lastShown = next + return TypingRowFrame( + typists = next, + lastTypists = lastShown, + grow = growFor(newestKey), + handedOff = handedOff, + anchorNewest = anchor, + ) + } + + /** + * Decides, once per newest message, whether it opens from under the dots and whether it takes + * them over. Idempotent, and called both from [update] and from the arrival's own row: the lazy + * list can lay a new page out before the composition that runs [update] sees it, and by then it + * has moved its scroll position on. The row composes inside that + * layout, while the list still describes the old one. + * + * [dotsHeightPx] is how tall the dots stand under the newest row right now, and [resting] says + * whether the transcript sits on the row that was newest before the arrival, which is now at + * the index given. + */ + fun arrive( typing: Set, messages: LazyPagingItems, dotsHeightPx: () -> Int, - resting: () -> Boolean, - gapPx: (item: ChatListItem, below: ChatListItem) -> Int, - newestGapPx: Int, - ): TypingRowFrame { - var handedOff = false - var anchor = false + resting: (previousKey: Any, previousIndex: Int) -> Boolean, + ) { val newest = if (messages.itemCount > 0) messages.peek(0) else null val newestBubble = newest as? ChatListItem.ContentBubble if (newestBubble != null && newestBubble.itemKey != newestKey) { @@ -165,38 +200,30 @@ internal class TypingRowModel { // Only while resting on the dots: scrolled up, the arrival lands off screen and // nothing on screen should move for it. val under = dotsHeightPx() - if (under > 0 && resting()) { - // The row above now keeps the gap it has against the arrival rather than the - // newest row's; whatever that adds comes out of where the arrival starts, so - // the row above stays exactly where it stood over the dots. - val gapAbove = messages.peek(1)?.let { gapPx(it, newestBubble) } ?: newestGapPx + if (under > 0 && resting(previous, previousIndex)) { + // Exactly the dots' height, so nothing above moves on the frame it lands. The + // gap between the row above and the arrival eases to its new size on the row's + // own reflow spring, riding along with the grow. grow = TypingGrow( key = newestBubble.itemKey, - fromPx = (under + newestGapPx - gapAbove).coerceAtLeast(0), + fromPx = under, handoff = if (takes) TypingDotsFrame(dotsSize) else null, ) - anchor = true + anchorPending = true } - handedOff = takes + handOffPending = takes } } else if (newestBubble == null) { newestKey = null } - - landed.retainAll(typing.map { it.session() }.toSet()) - val next = typing - .filterNot { it.session() in landed } - .sortedWith(compareBy({ it.since }, { it.userId.toString() })) - .map { it.userId } - shown = next.toSet() - return TypingRowFrame( - typists = next, - grow = grow?.takeIf { it.key !in grown && it.key == newestKey }, - handedOff = handedOff, - anchorNewest = anchor, - ) } + /** The grow [key]'s row plays, if [arrive] opened one for it that hasn't played yet. */ + fun growFor(key: Any?): TypingGrow? = grow?.takeIf { it.key == key && it.key == newestKey && key !in grown } + + /** Whether an arrival still needs the list to rest on it; true once per arrival. */ + fun takeAnchor(): Boolean = anchorPending.also { anchorPending = false } + /** Whether [key] is the arrival this model just opened, so the list's own push leaves it be. */ fun grows(key: Any?): Boolean = grow?.key == key && key !in grown