From d9c226fecf29fbb48a5570834552ef5dd1ebf81b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 15 Jun 2026 17:28:17 -0400 Subject: [PATCH] feat(chat): update formatting of read receipts using timestamp available Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 4 + .../app/messenger/internal/ChatViewModel.kt | 10 +- .../internal/screens/MessengerScreen.kt | 2 +- .../screens/components/MessageList.kt | 39 ++----- .../screens/components/ReceiptLabel.kt | 108 ++++++++++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 3 +- 6 files changed, 127 insertions(+), 39 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt 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() }