Skip to content

Commit facd94b

Browse files
committed
feat: improve message insert animations incoming and outgoing
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 9f1c25d commit facd94b

8 files changed

Lines changed: 159 additions & 75 deletions

File tree

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ import kotlin.math.min
7272
import kotlin.time.Duration
7373
import kotlin.time.Duration.Companion.milliseconds
7474
import kotlin.time.Duration.Companion.seconds
75-
import kotlin.time.Instant
7675

7776
data class TypingConstraints(
7877
val enabled: Boolean = false,
@@ -200,6 +199,7 @@ internal class ChatViewModel @Inject constructor(
200199
isFromSelf = message.isFromSelf,
201200
timestamp = message.timestamp,
202201
receiptStatus = receiptStatus,
202+
pendingClientIdHex = message.pendingClientIdHex,
203203
)
204204
}
205205
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageBubble.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,36 @@ internal fun bubblePositionOf(
248248
}
249249
}
250250

251+
internal fun bubblePositionOf(
252+
index: Int,
253+
item: ChatListItem.ContentBubble,
254+
messages: List<ChatListItem>,
255+
config: SeparatorConfig,
256+
): BubblePosition {
257+
// index+1 = visually above (older), index-1 = visually below (newer)
258+
val above = if (index + 1 < messages.count()) {
259+
messages[index + 1] as? ChatListItem.ContentBubble
260+
} else null
261+
val below = if (index > 0) {
262+
messages[index - 1] as? ChatListItem.ContentBubble
263+
} else null
264+
265+
val groupedAbove = above != null &&
266+
above.isFromSelf == item.isFromSelf &&
267+
config.isGrouped(item.timestamp, above.timestamp)
268+
269+
val groupedBelow = below != null &&
270+
below.isFromSelf == item.isFromSelf &&
271+
config.isGrouped(item.timestamp, below.timestamp)
272+
273+
return when {
274+
groupedAbove && groupedBelow -> BubblePosition.Middle
275+
groupedAbove -> BubblePosition.Last // bottom of visual group
276+
groupedBelow -> BubblePosition.First // top of visual group
277+
else -> BubblePosition.Solo
278+
}
279+
}
280+
251281
// region Previews
252282

253283
@Preview

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.flipcash.app.messenger.internal.screens.components
22

3-
import androidx.compose.animation.core.Spring
43
import androidx.compose.animation.core.animateFloatAsState
54
import androidx.compose.animation.core.spring
65
import androidx.compose.foundation.gestures.detectTapGestures
@@ -17,6 +16,7 @@ import androidx.compose.runtime.Composable
1716
import androidx.compose.runtime.LaunchedEffect
1817
import androidx.compose.runtime.getValue
1918
import androidx.compose.runtime.mutableLongStateOf
19+
import androidx.compose.runtime.derivedStateOf
2020
import androidx.compose.runtime.mutableStateOf
2121
import androidx.compose.runtime.remember
2222
import androidx.compose.runtime.setValue
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.TransformOrigin
2727
import androidx.compose.ui.graphics.graphicsLayer
2828
import androidx.compose.ui.input.pointer.pointerInput
2929
import androidx.compose.ui.unit.Dp
30+
import androidx.paging.LoadState
3031
import androidx.paging.compose.LazyPagingItems
3132
import androidx.paging.compose.itemKey
3233
import com.flipcash.app.messenger.internal.ChatViewModel
@@ -73,9 +74,11 @@ internal enum class ReceiptStatus { SENDING, SENT, READ, FAILED }
7374

7475
internal sealed interface ChatListItem {
7576
val itemKey: Any
77+
val itemContentType: Any
7678

77-
data class DateSeparator(val timestamp: kotlin.time.Instant) : ChatListItem {
79+
data class DateSeparator(val timestamp: Instant) : ChatListItem {
7880
override val itemKey: Any = "sep-${timestamp.epochSeconds}"
81+
override val itemContentType: Any = "date-separator"
7982
}
8083

8184
data class ContentBubble(
@@ -85,8 +88,13 @@ internal sealed interface ChatListItem {
8588
val isFromSelf: Boolean,
8689
val timestamp: Instant,
8790
val receiptStatus: ReceiptStatus? = null,
91+
val pendingClientIdHex: String? = null,
8892
) : ChatListItem {
89-
override val itemKey: Any = "$messageId-$contentIndex"
93+
override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex"
94+
override val itemContentType: Any = when (content) {
95+
is MessageContent.Text -> "text-bubble"
96+
is MessageContent.Cash -> "cash-bubble"
97+
}
9098
}
9199
}
92100

@@ -128,6 +136,17 @@ internal fun MessageList(
128136
}
129137
}
130138

139+
// Gate insertion animations: only animate items that arrive after the initial page load
140+
val initialLoadComplete by remember {
141+
derivedStateOf {
142+
messages.loadState.refresh is LoadState.NotLoading && messages.itemCount > 0
143+
}
144+
}
145+
var hasLoaded by remember { mutableStateOf(false) }
146+
LaunchedEffect(initialLoadComplete) {
147+
if (initialLoadComplete) hasLoaded = true
148+
}
149+
131150
LazyColumn(
132151
modifier = modifier
133152
.sheetResignmentBehavior(listState)
@@ -151,55 +170,63 @@ internal fun MessageList(
151170
val item = messages[index] ?: return@items
152171
val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig)
153172

154-
// Message insertion animation — scale from 0.95 + opacity with edge anchor
155-
var appeared by remember { mutableStateOf(false) }
156-
LaunchedEffect(Unit) { appeared = true }
157-
val insertionSpec =
158-
spring<Float>(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh)
173+
val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false
174+
175+
// Message insertion animation — scale from 0.95 + opacity with edge anchor.
176+
// Tuned to match observed iOS timing (~500ms gradual fade-in).
177+
// Only animate genuinely new messages (index 0 after initial load).
178+
val shouldAnimate = index == 0 && hasLoaded
179+
var appeared by remember { mutableStateOf(!shouldAnimate) }
180+
LaunchedEffect(Unit) { if (!appeared) appeared = true }
181+
val insertionAlphaSpec = spring<Float>(dampingRatio = 0.86f, stiffness = 80f)
182+
val insertionScaleSpec = spring<Float>(dampingRatio = 0.73f, stiffness = 300f)
159183
val insertionAlpha by animateFloatAsState(
160184
targetValue = if (appeared) 1f else 0f,
161-
animationSpec = insertionSpec,
185+
animationSpec = insertionAlphaSpec,
162186
label = "insertAlpha",
163187
)
164188
val insertionScale by animateFloatAsState(
165189
targetValue = if (appeared) 1f else 0.95f,
166-
animationSpec = insertionSpec,
190+
animationSpec = insertionScaleSpec,
167191
label = "insertScale",
168192
)
169-
val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false
193+
194+
val insertionModifier = Modifier.graphicsLayer {
195+
alpha = insertionAlpha
196+
scaleX = insertionScale
197+
scaleY = insertionScale
198+
transformOrigin = if (isOutgoing) {
199+
TransformOrigin(1f, 0.5f) // anchor trailing
200+
} else {
201+
TransformOrigin(0f, 0.5f) // anchor leading
202+
}
203+
}
170204

171205
Box(
172206
modifier = Modifier
173-
.padding(bottom = bottomSpacing)
174-
.animateItem(placementSpec = null)
175-
.graphicsLayer {
176-
alpha = insertionAlpha
177-
scaleX = insertionScale
178-
scaleY = insertionScale
179-
transformOrigin = if (isOutgoing) {
180-
TransformOrigin(1f, 0.5f) // anchor trailing
181-
} else {
182-
TransformOrigin(0f, 0.5f) // anchor leading
183-
}
184-
},
207+
.animateItem(fadeInSpec = null, fadeOutSpec = null)
208+
.padding(bottom = bottomSpacing),
185209
) {
186210
when (item) {
187-
is ChatListItem.DateSeparator -> DateSeparatorRow(item.timestamp)
211+
is ChatListItem.DateSeparator -> Box(insertionModifier) {
212+
DateSeparatorRow(item.timestamp)
213+
}
188214
is ChatListItem.ContentBubble -> {
189215
val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
190216
Column(
191217
modifier = Modifier.fillMaxWidth(),
192218
horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start,
193219
) {
194-
ContentBubble(
195-
item = item,
196-
position = bubblePositionOf(index, item, messages, separatorConfig),
197-
)
220+
Box(insertionModifier) {
221+
ContentBubble(
222+
item = item,
223+
position = bubblePositionOf(index, item, messages, separatorConfig),
224+
)
225+
}
198226
val showReceipt =
199227
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
200228
if (showReceipt && effectiveStatus != null) {
201229
ReceiptLabel(
202-
itemKey = item.itemKey,
203230
status = effectiveStatus,
204231
readPointer = otherReadPointer
205232
)

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import androidx.compose.animation.AnimatedVisibility
66
import androidx.compose.animation.core.Spring
77
import androidx.compose.animation.core.snap
88
import androidx.compose.animation.core.spring
9+
import androidx.compose.animation.expandVertically
910
import androidx.compose.animation.fadeIn
1011
import androidx.compose.animation.fadeOut
1112
import androidx.compose.animation.scaleIn
1213
import androidx.compose.animation.scaleOut
14+
import androidx.compose.animation.shrinkVertically
1315
import androidx.compose.animation.togetherWith
1416
import androidx.compose.foundation.layout.Arrangement
17+
import androidx.compose.foundation.layout.Box
1518
import androidx.compose.foundation.layout.Column
1619
import androidx.compose.foundation.layout.Row
1720
import androidx.compose.foundation.layout.padding
@@ -33,76 +36,82 @@ import com.flipcash.services.models.chat.MessagePointer
3336
import com.flipcash.services.models.chat.PointerType
3437
import com.getcode.theme.CodeTheme
3538
import com.getcode.util.formatLocalized
36-
import kotlinx.coroutines.delay
3739
import kotlinx.datetime.TimeZone
3840
import kotlinx.datetime.toLocalDateTime
3941
import kotlin.time.Clock
4042
import kotlin.time.Duration.Companion.days
41-
import kotlin.time.Duration.Companion.milliseconds
4243
import kotlin.time.Instant
44+
import kotlinx.coroutines.delay
45+
46+
private const val DELIVERED_DELAY_MS = 700L
4347

4448
@Composable
4549
internal fun ReceiptLabel(
4650
status: ReceiptStatus,
4751
readPointer: MessagePointer?,
4852
modifier: Modifier = Modifier,
49-
itemKey: Any? = null,
5053
) {
51-
// Delayed pop for "Delivered" — wait 700ms before showing, instant removal.
52-
// Start visible when not SENT so the animation is oneshot (doesn't replay on scroll).
53-
var visible by remember(itemKey) { mutableStateOf(status != ReceiptStatus.SENT) }
54+
// iOS: "Delivered" hides instantly on send, then appears after 700ms with
55+
// scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12).
56+
// "Read" swaps in immediately (no delay).
57+
var deliveredVisible by remember { mutableStateOf(status != ReceiptStatus.SENT) }
5458
LaunchedEffect(status) {
55-
if (status == ReceiptStatus.SENT && !visible) {
56-
delay(700.milliseconds)
57-
visible = true
59+
if (status == ReceiptStatus.SENT) {
60+
delay(DELIVERED_DELAY_MS)
61+
deliveredVisible = true
62+
} else {
63+
deliveredVisible = true
5864
}
5965
}
6066

61-
val deliveredSpec = spring<Float>(dampingRatio = 0.88f, stiffness = 600f)
67+
// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12)
68+
val deliveredSpec = spring<Float>(dampingRatio = 0.88f, stiffness = 250f)
6269

63-
AnimatedVisibility(
64-
visible = visible,
70+
Box(
6571
modifier = modifier.padding(
6672
top = CodeTheme.dimens.grid.x1,
6773
end = CodeTheme.dimens.grid.x2,
6874
),
69-
enter = scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec),
70-
exit = fadeOut(snap()),
7175
) {
72-
// Delivered -> Read directional swap with scale
73-
val readSwapSpec = spring<Float>(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)
74-
AnimatedContent(
75-
targetState = status,
76-
transitionSpec = {
77-
(scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith
78-
(scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec))
79-
},
80-
label = "receiptStatus",
81-
) { animatedStatus ->
82-
val text = when (animatedStatus) {
83-
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
84-
ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read)
85-
else -> return@AnimatedContent
86-
}
87-
88-
val readAtFormatted =
89-
readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
76+
AnimatedVisibility(
77+
visible = deliveredVisible,
78+
enter = expandVertically() +
79+
scaleIn(deliveredSpec, initialScale = 0.95f) + fadeIn(deliveredSpec),
80+
exit = shrinkVertically(snap()) + fadeOut(snap()),
81+
) {
82+
// Delivered -> Read directional swap with scale
83+
val readSwapSpec = spring<Float>(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)
84+
AnimatedContent(
85+
targetState = status,
86+
transitionSpec = {
87+
(scaleIn(readSwapSpec, initialScale = 0.9f) + fadeIn(readSwapSpec)) togetherWith
88+
(scaleOut(readSwapSpec, targetScale = 0.9f) + fadeOut(readSwapSpec))
89+
},
90+
label = "receiptStatus",
91+
) { animatedStatus ->
92+
val text = when (animatedStatus) {
93+
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
94+
ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read)
95+
else -> return@AnimatedContent
96+
}
9097

91-
// split text into two lines to eventually support a Theme driven
92-
// difference in font weights
93-
Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
94-
Text(
95-
text = text,
96-
style = CodeTheme.typography.caption,
97-
color = CodeTheme.colors.textSecondary,
98-
)
98+
val readAtFormatted =
99+
readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
99100

100-
if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) {
101+
Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
101102
Text(
102-
text = readAtFormatted,
103+
text = text,
103104
style = CodeTheme.typography.caption,
104105
color = CodeTheme.colors.textSecondary,
105106
)
107+
108+
if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) {
109+
Text(
110+
text = readAtFormatted,
111+
style = CodeTheme.typography.caption,
112+
color = CodeTheme.colors.textSecondary,
113+
)
114+
}
106115
}
107116
}
108117
}

apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ val FeatureFlag<*>.title: String
277277
FeatureFlag.DepositUsdc -> "Deposit USDC"
278278
FeatureFlag.BackgroundReset -> "Background Reset"
279279
FeatureFlag.ContactPickerMode -> "Contact Picker Mode"
280-
FeatureFlag.PhoneNumberSend -> "Phone Number Send"
280+
FeatureFlag.PhoneNumberSend -> "Send Cash"
281281
FeatureFlag.OnboardingPhoneVerification -> "Onboarding Phone Verification"
282282
FeatureFlag.Messenger -> "Messenger"
283283
FeatureFlag.NavBar -> "Navigation Bar"

apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.flipcash.services.persistence.PagingDataSource
1313
import com.flipcash.services.user.UserManager
1414
import com.getcode.opencode.model.core.ID
1515
import com.getcode.opencode.model.core.RandomId
16+
import com.getcode.utils.hexEncodedString
1617
import kotlinx.coroutines.flow.Flow
1718
import kotlinx.coroutines.flow.emptyFlow
1819
import kotlinx.coroutines.flow.firstOrNull
@@ -102,9 +103,24 @@ class ChatMessageDataSource @Inject constructor(
102103
val hex = mapper.chatIdHex(chatId)
103104
val entities = messages.map { mapper.toEntity(hex, it) }
104105
val selfId = userManager.accountId
105-
val hasSelfMessage = selfId != null && messages.any { it.senderId == selfId }
106+
val selfHex = selfId?.hexEncodedString()
107+
val hasSelfMessage = selfHex != null && entities.any { it.senderIdHex == selfHex }
106108
if (hasSelfMessage) {
107-
db?.chatMessageDao()?.upsertAndClearPending(hex, entities)
109+
val dao = db?.chatMessageDao() ?: return
110+
// Rescue pendingClientIdHex from pending rows before they are deleted,
111+
// so the UI item key stays stable across the SENDING→SENT transition.
112+
val rescuedIds = dao.getPendingClientIds(hex).toMutableList()
113+
val merged = if (rescuedIds.isNotEmpty()) {
114+
entities.map { entity ->
115+
if (rescuedIds.isNotEmpty()
116+
&& entity.senderIdHex == selfHex
117+
&& entity.pendingClientIdHex == null
118+
) {
119+
entity.copy(pendingClientIdHex = rescuedIds.removeFirst())
120+
} else entity
121+
}
122+
} else entities
123+
dao.upsertAndClearPending(hex, merged)
108124
} else {
109125
db?.chatMessageDao()?.upsert(entities)
110126
}

0 commit comments

Comments
 (0)