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.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
)
}

View File

@ -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)