diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index dc8d9c8c4..dfd004b9c 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -768,4 +768,8 @@
You sent
You received
+ Delivered
+ Read %1$s
+ Yesterday
+
\ No newline at end of file
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 c320688e9..019a936a8 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
@@ -26,6 +26,7 @@ import com.flipcash.services.models.buildDmPaymentMetadata
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
@@ -162,16 +163,15 @@ internal class ChatViewModel @Inject constructor(
private val separatorConfig = SeparatorConfig.TimeGap()
@OptIn(ExperimentalCoroutinesApi::class)
- private val messageStream = stateFlow.mapNotNull { it.chatId }.distinctUntilChanged()
+ private val messageStream = stateFlow.mapNotNull { it.chatId }
+ .distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeMessagesPaged(it) }
@OptIn(ExperimentalCoroutinesApi::class)
- val otherReadPointer = stateFlow
- .map { it.chatId }
- .filterNotNull()
+ val otherReadPointer = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeOtherReadPointer(it) }
- .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val messages: Flow> = messageStream
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index 767822374..61199c13e 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -72,7 +72,7 @@ import dev.chrisbanes.haze.rememberHazeState
internal fun MessengerScreen(viewModel: ChatViewModel) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val messages = viewModel.messages.collectAsLazyPagingItems()
- val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle()
+ val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null)
val navigator = LocalCodeNavigator.current
val hazeState = rememberHazeState()
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 f5af4f89b..c7c18859f 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,17 +1,12 @@
package com.flipcash.app.messenger.internal.screens.components
-import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-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.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -27,10 +22,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.flipcash.services.models.chat.MessageContent
-import androidx.compose.ui.unit.dp
+import com.flipcash.services.models.chat.MessagePointer
import com.getcode.theme.CodeTheme
import com.getcode.ui.utils.sheetResignmentBehavior
import com.getcode.util.toLocalDate
@@ -94,7 +90,7 @@ internal fun MessageList(
contentPadding: PaddingValues,
messages: LazyPagingItems,
separatorConfig: SeparatorConfig,
- otherReadPointer: Long = 0L,
+ otherReadPointer: MessagePointer? = null,
onAdvanceReadPointer: ((Long) -> Unit)? = null,
) {
val listAlpha by animateFloatAsState(
@@ -149,7 +145,7 @@ internal fun MessageList(
val showReceipt =
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
if (showReceipt && effectiveStatus != null) {
- ReceiptLabel(effectiveStatus)
+ ReceiptLabel(effectiveStatus, otherReadPointer)
}
}
}
@@ -241,10 +237,11 @@ private fun HandleMessageReads(
private fun effectiveReceiptStatus(
bubble: ChatListItem.ContentBubble,
- otherReadPointer: Long,
+ otherReadPointer: MessagePointer?,
): ReceiptStatus? {
val base = bubble.receiptStatus ?: return null
- if (base == ReceiptStatus.SENT && bubble.messageId in 1..otherReadPointer) {
+ val pointerValue = otherReadPointer?.value ?: 0L
+ if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) {
return ReceiptStatus.READ
}
return base
@@ -262,7 +259,7 @@ private fun shouldShowReceiptLabel(
index: Int,
item: ChatListItem.ContentBubble,
messages: LazyPagingItems,
- otherReadPointer: Long,
+ otherReadPointer: MessagePointer?,
): Boolean {
if (!item.isFromSelf) return false
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
@@ -302,23 +299,3 @@ private fun shouldShowReceiptLabel(
return true
}
-@Composable
-private fun ReceiptLabel(status: ReceiptStatus, modifier: Modifier = Modifier) {
- AnimatedContent(
- targetState = status,
- modifier = modifier.padding(top = CodeTheme.dimens.grid.x1),
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- label = "receiptStatus",
- ) { animatedStatus ->
- val text = when (animatedStatus) {
- ReceiptStatus.SENT -> "Delivered"
- ReceiptStatus.READ -> "Read"
- else -> return@AnimatedContent
- }
- Text(
- text = text,
- style = CodeTheme.typography.caption,
- color = CodeTheme.colors.textSecondary,
- )
- }
-}
\ No newline at end of file
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
new file mode 100644
index 000000000..5e3a1298c
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt
@@ -0,0 +1,108 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import android.text.format.DateFormat
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import com.flipcash.app.theme.FlipcashThemeWrapper
+import com.flipcash.features.messenger.R
+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.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Instant
+
+@Composable
+internal fun ReceiptLabel(
+ status: ReceiptStatus,
+ readPointer: MessagePointer?,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedContent(
+ targetState = status,
+ modifier = modifier.padding(top = CodeTheme.dimens.grid.x1),
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ label = "receiptStatus",
+ ) { animatedStatus ->
+ val text = when (animatedStatus) {
+ ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
+ ReceiptStatus.READ -> {
+ val readAtFormatted = readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
+ stringResource(R.string.label_chatReceipt_read, readAtFormatted)
+ }
+ else -> return@AnimatedContent
+ }
+ Text(
+ text = text,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+}
+
+@Composable
+internal fun formatReadTimestamp(instant: Instant): String {
+ val context = LocalContext.current
+ val is24Hour = DateFormat.is24HourFormat(context)
+ val now = Clock.System.now()
+ val tz = TimeZone.currentSystemDefault()
+ val todayDate = now.toLocalDateTime(tz).date
+ val readDate = instant.toLocalDateTime(tz).date
+
+ val dayDiff = todayDate.toEpochDays() - readDate.toEpochDays()
+
+ return when {
+ // Today -> time only (e.g. "11:15 AM")
+ dayDiff == 0L -> instant.formatLocalized(
+ "h:mm a", is24Hour = is24Hour, if24Hour = "H:mm"
+ )
+ // Yesterday
+ dayDiff == 1L -> stringResource(R.string.label_chatReceipt_yesterday)
+ // 2-6 days ago -> weekday name (e.g. "Monday")
+ dayDiff in 2L..6L -> instant.formatLocalized("EEEE")
+ // Same year -> month + day (e.g. "Jun 8")
+ readDate.year == todayDate.year -> instant.formatLocalized("MMM d")
+ // Earlier year -> month + day + year (e.g. "Jun 8, 2025")
+ else -> instant.formatLocalized("MMM d, yyyy")
+ }
+}
+
+// region Previews
+
+private fun previewPointer(timestamp: Instant) = MessagePointer(
+ type = PointerType.READ,
+ userId = listOf(1.toByte()),
+ value = 1L,
+ timestamp = timestamp,
+)
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_AllStates() {
+ val now = Clock.System.now()
+ Column {
+ ReceiptLabel(status = ReceiptStatus.SENT, readPointer = null)
+ ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now))
+ ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(1.days)))
+ ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(3.days)))
+ ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(30.days)))
+ ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(400.days)))
+ }
+}
+
+// endregion
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index ceeaa1e44..a8808fa5b 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -181,14 +181,13 @@ class ChatCoordinator @Inject constructor(
return _state.map { it.typingIndicators[chatId] ?: emptySet() }
}
- fun observeOtherReadPointer(chatId: ChatId): Flow {
+ fun observeOtherReadPointer(chatId: ChatId): Flow {
val selfId = userManager.accountId
return memberDataSource.observeMembers(chatId)
.map { members ->
members.firstOrNull { it.userId != selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
- ?.value ?: 0L
}
.distinctUntilChanged()
}