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..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 @@ -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,64 @@ 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 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) } }, + ) + } + 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. + 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 +459,19 @@ 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. + // 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 if (animateInsertion) animatedKeys.add(item.itemKey) val bubble = item as? ChatListItem.ContentBubble @@ -423,6 +501,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 +577,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.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 + }, + 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 +715,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 +915,21 @@ 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 + +/** + * 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 -> @@ -842,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/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..e88b8b7dd4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/TypingRow.kt @@ -0,0 +1,375 @@ +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, + /** + * 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. */ + 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 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. */ + 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, 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(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: (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) { + 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(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, + handoff = if (takes) TypingDotsFrame(dotsSize) else null, + ) + anchorPending = true + } + handOffPending = takes + } + } else if (newestBubble == null) { + newestKey = null + } + } + + /** 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 + + /** 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.