From facd94b2260e1c8a55710430a6561a6206d79896 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 17 Jun 2026 15:59:09 -0400 Subject: [PATCH] feat: improve message insert animations incoming and outgoing Signed-off-by: Brandon McAnsh --- .../app/messenger/internal/ChatViewModel.kt | 2 +- .../screens/components/MessageBubble.kt | 30 ++++++ .../screens/components/MessageList.kt | 85 +++++++++++------ .../screens/components/ReceiptLabel.kt | 93 ++++++++++--------- .../flipcash/app/featureflags/FeatureFlag.kt | 2 +- .../sources/ChatMessageDataSource.kt | 20 +++- .../sources/mapper/chat/ChatEntityMapper.kt | 1 + .../services/models/chat/ChatMessage.kt | 1 + 8 files changed, 159 insertions(+), 75 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 892f87d05..e1b1a910a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -72,7 +72,6 @@ import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant data class TypingConstraints( val enabled: Boolean = false, @@ -200,6 +199,7 @@ internal class ChatViewModel @Inject constructor( isFromSelf = message.isFromSelf, timestamp = message.timestamp, receiptStatus = receiptStatus, + pendingClientIdHex = message.pendingClientIdHex, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt index efc024564..01f6099d4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt @@ -248,6 +248,36 @@ internal fun bubblePositionOf( } } +internal fun bubblePositionOf( + index: Int, + item: ChatListItem.ContentBubble, + messages: List, + config: SeparatorConfig, +): BubblePosition { + // index+1 = visually above (older), index-1 = visually below (newer) + val above = if (index + 1 < messages.count()) { + messages[index + 1] as? ChatListItem.ContentBubble + } else null + val below = if (index > 0) { + messages[index - 1] as? ChatListItem.ContentBubble + } else null + + val groupedAbove = above != null && + above.isFromSelf == item.isFromSelf && + config.isGrouped(item.timestamp, above.timestamp) + + val groupedBelow = below != null && + below.isFromSelf == item.isFromSelf && + config.isGrouped(item.timestamp, below.timestamp) + + return when { + groupedAbove && groupedBelow -> BubblePosition.Middle + groupedAbove -> BubblePosition.Last // bottom of visual group + groupedBelow -> BubblePosition.First // top of visual group + else -> BubblePosition.Solo + } +} + // region Previews @Preview 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 2c8959132..43fbe4434 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,5 @@ package com.flipcash.app.messenger.internal.screens.components -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectTapGestures @@ -17,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp +import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel @@ -73,9 +74,11 @@ internal enum class ReceiptStatus { SENDING, SENT, READ, FAILED } internal sealed interface ChatListItem { val itemKey: Any + val itemContentType: Any - data class DateSeparator(val timestamp: kotlin.time.Instant) : ChatListItem { + data class DateSeparator(val timestamp: Instant) : ChatListItem { override val itemKey: Any = "sep-${timestamp.epochSeconds}" + override val itemContentType: Any = "date-separator" } data class ContentBubble( @@ -85,8 +88,13 @@ internal sealed interface ChatListItem { val isFromSelf: Boolean, val timestamp: Instant, val receiptStatus: ReceiptStatus? = null, + val pendingClientIdHex: String? = null, ) : ChatListItem { - override val itemKey: Any = "$messageId-$contentIndex" + override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" + override val itemContentType: Any = when (content) { + is MessageContent.Text -> "text-bubble" + is MessageContent.Cash -> "cash-bubble" + } } } @@ -128,6 +136,17 @@ internal fun MessageList( } } + // Gate insertion animations: only animate items that arrive after the initial page load + val initialLoadComplete by remember { + derivedStateOf { + messages.loadState.refresh is LoadState.NotLoading && messages.itemCount > 0 + } + } + var hasLoaded by remember { mutableStateOf(false) } + LaunchedEffect(initialLoadComplete) { + if (initialLoadComplete) hasLoaded = true + } + LazyColumn( modifier = modifier .sheetResignmentBehavior(listState) @@ -151,55 +170,63 @@ internal fun MessageList( val item = messages[index] ?: return@items val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) - // Message insertion animation — scale from 0.95 + opacity with edge anchor - var appeared by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { appeared = true } - val insertionSpec = - spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) + val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + + // Message insertion animation — scale from 0.95 + opacity with edge anchor. + // Tuned to match observed iOS timing (~500ms gradual fade-in). + // Only animate genuinely new messages (index 0 after initial load). + val shouldAnimate = index == 0 && hasLoaded + var appeared by remember { mutableStateOf(!shouldAnimate) } + LaunchedEffect(Unit) { if (!appeared) appeared = true } + val insertionAlphaSpec = spring(dampingRatio = 0.86f, stiffness = 80f) + val insertionScaleSpec = spring(dampingRatio = 0.73f, stiffness = 300f) val insertionAlpha by animateFloatAsState( targetValue = if (appeared) 1f else 0f, - animationSpec = insertionSpec, + animationSpec = insertionAlphaSpec, label = "insertAlpha", ) val insertionScale by animateFloatAsState( targetValue = if (appeared) 1f else 0.95f, - animationSpec = insertionSpec, + animationSpec = insertionScaleSpec, label = "insertScale", ) - val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + + val insertionModifier = Modifier.graphicsLayer { + alpha = insertionAlpha + scaleX = insertionScale + scaleY = insertionScale + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) // anchor trailing + } else { + TransformOrigin(0f, 0.5f) // anchor leading + } + } Box( modifier = Modifier - .padding(bottom = bottomSpacing) - .animateItem(placementSpec = null) - .graphicsLayer { - alpha = insertionAlpha - scaleX = insertionScale - scaleY = insertionScale - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) // anchor trailing - } else { - TransformOrigin(0f, 0.5f) // anchor leading - } - }, + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .padding(bottom = bottomSpacing), ) { when (item) { - is ChatListItem.DateSeparator -> DateSeparatorRow(item.timestamp) + is ChatListItem.DateSeparator -> Box(insertionModifier) { + DateSeparatorRow(item.timestamp) + } is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - ContentBubble( - item = item, - position = bubblePositionOf(index, item, messages, separatorConfig), - ) + Box(insertionModifier) { + ContentBubble( + item = item, + position = bubblePositionOf(index, item, messages, separatorConfig), + ) + } val showReceipt = shouldShowReceiptLabel(index, item, messages, otherReadPointer) if (showReceipt && effectiveStatus != null) { ReceiptLabel( - itemKey = item.itemKey, status = effectiveStatus, readPointer = otherReadPointer ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index fc8ee0fb7..43f8dacf4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -6,12 +6,15 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -33,76 +36,82 @@ import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.getcode.theme.CodeTheme import com.getcode.util.formatLocalized -import kotlinx.coroutines.delay import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant +import kotlinx.coroutines.delay + +private const val DELIVERED_DELAY_MS = 700L @Composable internal fun ReceiptLabel( status: ReceiptStatus, readPointer: MessagePointer?, modifier: Modifier = Modifier, - itemKey: Any? = null, ) { - // Delayed pop for "Delivered" — wait 700ms before showing, instant removal. - // Start visible when not SENT so the animation is oneshot (doesn't replay on scroll). - var visible by remember(itemKey) { mutableStateOf(status != ReceiptStatus.SENT) } + // iOS: "Delivered" hides instantly on send, then appears after 700ms with + // scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12). + // "Read" swaps in immediately (no delay). + var deliveredVisible by remember { mutableStateOf(status != ReceiptStatus.SENT) } LaunchedEffect(status) { - if (status == ReceiptStatus.SENT && !visible) { - delay(700.milliseconds) - visible = true + if (status == ReceiptStatus.SENT) { + delay(DELIVERED_DELAY_MS) + deliveredVisible = true + } else { + deliveredVisible = true } } - val deliveredSpec = spring(dampingRatio = 0.88f, stiffness = 600f) + // Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12) + val deliveredSpec = spring(dampingRatio = 0.88f, stiffness = 250f) - AnimatedVisibility( - visible = visible, + Box( modifier = modifier.padding( top = CodeTheme.dimens.grid.x1, end = CodeTheme.dimens.grid.x2, ), - enter = scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec), - exit = fadeOut(snap()), ) { - // Delivered -> Read directional swap with scale - val readSwapSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) - AnimatedContent( - targetState = status, - transitionSpec = { - (scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith - (scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec)) - }, - label = "receiptStatus", - ) { animatedStatus -> - val text = when (animatedStatus) { - ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) - ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) - else -> return@AnimatedContent - } - - val readAtFormatted = - readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" + AnimatedVisibility( + visible = deliveredVisible, + enter = expandVertically() + + scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec), + exit = shrinkVertically(snap()) + fadeOut(snap()), + ) { + // Delivered -> Read directional swap with scale + val readSwapSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) + AnimatedContent( + targetState = status, + transitionSpec = { + (scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith + (scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec)) + }, + label = "receiptStatus", + ) { animatedStatus -> + val text = when (animatedStatus) { + ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) + ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) + else -> return@AnimatedContent + } - // split text into two lines to eventually support a Theme driven - // difference in font weights - Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { - Text( - text = text, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - ) + val readAtFormatted = + readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: "" - if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { Text( - text = readAtFormatted, + text = text, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, ) + + if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) { + Text( + text = readAtFormatted, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } } } } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index b04d730f0..1785ac4c8 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -277,7 +277,7 @@ val FeatureFlag<*>.title: String FeatureFlag.DepositUsdc -> "Deposit USDC" FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ContactPickerMode -> "Contact Picker Mode" - FeatureFlag.PhoneNumberSend -> "Phone Number Send" + FeatureFlag.PhoneNumberSend -> "Send Cash" FeatureFlag.OnboardingPhoneVerification -> "Onboarding Phone Verification" FeatureFlag.Messenger -> "Messenger" FeatureFlag.NavBar -> "Navigation Bar" diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 8ad67372e..3ea767450 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -13,6 +13,7 @@ import com.flipcash.services.persistence.PagingDataSource import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.RandomId +import com.getcode.utils.hexEncodedString import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull @@ -102,9 +103,24 @@ class ChatMessageDataSource @Inject constructor( val hex = mapper.chatIdHex(chatId) val entities = messages.map { mapper.toEntity(hex, it) } val selfId = userManager.accountId - val hasSelfMessage = selfId != null && messages.any { it.senderId == selfId } + val selfHex = selfId?.hexEncodedString() + val hasSelfMessage = selfHex != null && entities.any { it.senderIdHex == selfHex } if (hasSelfMessage) { - db?.chatMessageDao()?.upsertAndClearPending(hex, entities) + val dao = db?.chatMessageDao() ?: return + // Rescue pendingClientIdHex from pending rows before they are deleted, + // so the UI item key stays stable across the SENDING→SENT transition. + val rescuedIds = dao.getPendingClientIds(hex).toMutableList() + val merged = if (rescuedIds.isNotEmpty()) { + entities.map { entity -> + if (rescuedIds.isNotEmpty() + && entity.senderIdHex == selfHex + && entity.pendingClientIdHex == null + ) { + entity.copy(pendingClientIdHex = rescuedIds.removeFirst()) + } else entity + } + } else entities + dao.upsertAndClearPending(hex, merged) } else { db?.chatMessageDao()?.upsert(entities) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index e42d65762..e99fa2baa 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -90,6 +90,7 @@ class ChatEntityMapper @Inject constructor() { MessageStatus.SENT -> DeliveryStatus.SENT MessageStatus.FAILED -> DeliveryStatus.FAILED }, + pendingClientIdHex = entity.pendingClientIdHex, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt index 5df08f32c..1b4baf4fd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -11,4 +11,5 @@ data class ChatMessage( val unreadSeq: Long, val isFromSelf: Boolean = false, val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, + val pendingClientIdHex: String? = null, )