From 3544b7a84adeb5f9941c055c3c411ab82ea4bd33 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 18:14:05 -0500 Subject: [PATCH] feat(chat): message timestamps + day separators Show a muted clock time under the last bubble of each sender-run (side-aligned), and a centered Today/Yesterday/date pill between messages from different days. Falls back to now for a just-sent message whose server timestamp is unresolved. Co-Authored-By: Claude Opus 4.8 --- .../closer/ui/messages/ConversationScreen.kt | 11 +- .../ui/messages/components/ChatComponents.kt | 113 ++++++++++++++---- 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index 4a0bfd5d..42175242 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -39,7 +39,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import app.closer.ui.messages.components.ChatComposer +import app.closer.ui.messages.components.ChatDaySeparator import app.closer.ui.messages.components.ChatMessageRow +import app.closer.ui.messages.components.isSameChatDay import app.closer.ui.theme.closerBackgroundBrush import coil.compose.AsyncImage @@ -98,13 +100,18 @@ fun ConversationScreen( ) { itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message -> val isMe = message.userId == viewModel.currentUserId - val showAvatar = index == state.messages.lastIndex || + val isLastInRun = index == state.messages.lastIndex || state.messages[index + 1].userId != message.userId + val prev = state.messages.getOrNull(index - 1) + if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) { + ChatDaySeparator(message.createdAt) + } ChatMessageRow( message = message, isCurrentUser = isMe, partnerAvatarUrl = state.partnerPhotoUrl, - showAvatar = showAvatar, + showAvatar = isLastInRun, + showTimestamp = isLastInRun, loadDecryptedMedia = viewModel::loadDecryptedMedia ) } diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 0dd1336b..95220f43 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -87,6 +88,7 @@ fun ChatMessageRow( isCurrentUser: Boolean, partnerAvatarUrl: String?, showAvatar: Boolean, + showTimestamp: Boolean, loadDecryptedMedia: suspend (String) -> ByteArray? ) { val bubbleShape = if (isCurrentUser) { @@ -95,33 +97,64 @@ fun ChatMessageRow( RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, - verticalAlignment = Alignment.Bottom - ) { - if (!isCurrentUser) { - ChatAvatar(partnerAvatarUrl, visible = showAvatar) - Spacer(modifier = Modifier.width(6.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + if (!isCurrentUser) { + ChatAvatar(partnerAvatarUrl, visible = showAvatar) + Spacer(modifier = Modifier.width(6.dp)) + } + + when { + message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) + message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) + else -> Surface( + shape = bubbleShape, + color = if (isCurrentUser) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.widthIn(max = 264.dp) + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) + ) + } + } } - when { - message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) - message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) - else -> Surface( - shape = bubbleShape, - color = if (isCurrentUser) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.widthIn(max = 264.dp) - ) { - Text( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) - ) - } + if (showTimestamp) { + Text( + text = formatClockTime(message.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + modifier = Modifier + .align(if (isCurrentUser) Alignment.End else Alignment.Start) + .padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp) + ) + } + } +} + +/** A centered date pill shown between messages from different days. */ +@Composable +fun ChatDaySeparator(epochMillis: Long) { + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + ) { + Text( + text = chatDayLabel(epochMillis), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 3.dp) + ) } } } @@ -522,3 +555,33 @@ private fun formatDuration(ms: Long): String { val s = totalSec % 60 return "%d:%02d".format(m, s) } + +/** "3:45 PM" — falls back to now for a just-sent message whose server timestamp hasn't resolved. */ +private fun formatClockTime(ms: Long): String { + val t = if (ms > 0) ms else System.currentTimeMillis() + return java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).format(java.util.Date(t)) +} + +/** "Today" / "Yesterday" / "March 4" for day separators. */ +fun chatDayLabel(ms: Long): String { + val t = if (ms > 0) ms else System.currentTimeMillis() + val cal = java.util.Calendar.getInstance().apply { timeInMillis = t } + val today = java.util.Calendar.getInstance() + val yesterday = (today.clone() as java.util.Calendar).apply { add(java.util.Calendar.DAY_OF_YEAR, -1) } + return when { + isSameCalendarDay(cal, today) -> "Today" + isSameCalendarDay(cal, yesterday) -> "Yesterday" + else -> java.text.SimpleDateFormat("MMMM d", java.util.Locale.getDefault()).format(java.util.Date(t)) + } +} + +/** True when two epoch millis fall on the same calendar day (0 is treated as now). */ +fun isSameChatDay(a: Long, b: Long): Boolean { + val ca = java.util.Calendar.getInstance().apply { timeInMillis = if (a > 0) a else System.currentTimeMillis() } + val cb = java.util.Calendar.getInstance().apply { timeInMillis = if (b > 0) b else System.currentTimeMillis() } + return isSameCalendarDay(ca, cb) +} + +private fun isSameCalendarDay(a: java.util.Calendar, b: java.util.Calendar): Boolean = + a.get(java.util.Calendar.YEAR) == b.get(java.util.Calendar.YEAR) && + a.get(java.util.Calendar.DAY_OF_YEAR) == b.get(java.util.Calendar.DAY_OF_YEAR)