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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:14:05 -05:00
parent 8a68ae3107
commit 3544b7a84a
2 changed files with 97 additions and 27 deletions

View File

@ -39,7 +39,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.ui.messages.components.ChatComposer 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.ChatMessageRow
import app.closer.ui.messages.components.isSameChatDay
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -98,13 +100,18 @@ fun ConversationScreen(
) { ) {
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message -> itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
val isMe = message.userId == viewModel.currentUserId 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 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( ChatMessageRow(
message = message, message = message,
isCurrentUser = isMe, isCurrentUser = isMe,
partnerAvatarUrl = state.partnerPhotoUrl, partnerAvatarUrl = state.partnerPhotoUrl,
showAvatar = showAvatar, showAvatar = isLastInRun,
showTimestamp = isLastInRun,
loadDecryptedMedia = viewModel::loadDecryptedMedia loadDecryptedMedia = viewModel::loadDecryptedMedia
) )
} }

View File

@ -14,6 +14,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@ -87,6 +88,7 @@ fun ChatMessageRow(
isCurrentUser: Boolean, isCurrentUser: Boolean,
partnerAvatarUrl: String?, partnerAvatarUrl: String?,
showAvatar: Boolean, showAvatar: Boolean,
showTimestamp: Boolean,
loadDecryptedMedia: suspend (String) -> ByteArray? loadDecryptedMedia: suspend (String) -> ByteArray?
) { ) {
val bubbleShape = if (isCurrentUser) { val bubbleShape = if (isCurrentUser) {
@ -95,33 +97,64 @@ fun ChatMessageRow(
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
} }
Row( Column(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
) { verticalAlignment = Alignment.Bottom
if (!isCurrentUser) { ) {
ChatAvatar(partnerAvatarUrl, visible = showAvatar) if (!isCurrentUser) {
Spacer(modifier = Modifier.width(6.dp)) 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 { if (showTimestamp) {
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia) Text(
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia) text = formatClockTime(message.createdAt),
else -> Surface( style = MaterialTheme.typography.labelSmall,
shape = bubbleShape, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
color = if (isCurrentUser) MaterialTheme.colorScheme.primary modifier = Modifier
else MaterialTheme.colorScheme.surfaceVariant, .align(if (isCurrentUser) Alignment.End else Alignment.Start)
modifier = Modifier.widthIn(max = 264.dp) .padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
) { )
Text( }
text = message.text, }
style = MaterialTheme.typography.bodyMedium, }
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant, /** A centered date pill shown between messages from different days. */
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp) @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 val s = totalSec % 60
return "%d:%02d".format(m, s) 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)