Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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? ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,36 @@ internal fun bubblePositionOf(
}
}

internal fun bubblePositionOf(
index: Int,
item: ChatListItem.ContentBubble,
messages: List<ChatListItem>,
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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"
}
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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<Float>(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<Float>(dampingRatio = 0.86f, stiffness = 80f)
val insertionScaleSpec = spring<Float>(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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Float>(dampingRatio = 0.88f, stiffness = 600f)
// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12)
val deliveredSpec = spring<Float>(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<Float>(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<Float>(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,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading