From 9d08000c906dad3d1f03074963d4d2022d5b49a1 Mon Sep 17 00:00:00 2001 From: JARVIS Date: Tue, 27 Jan 2026 18:03:37 +0100 Subject: [PATCH] feat(mobile): show message timestamps in chat bubbles - Add relative timestamp display below each message bubble - Show time only (HH:mm) for today's messages - Show day + time (EEE HH:mm) for messages within 7 days - Show date + time (d MMM HH:mm) for older messages - Subtle styling: small font, reduced opacity - Implemented for both Android (Compose) and iOS/macOS (SwiftUI) --- .../android/ui/chat/ChatMessageViews.kt | 79 +++++++++++++++---- .../ClawdbotChatUI/ChatMessageViews.swift | 47 ++++++++--- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt index 59479744e..7bff067f0 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt @@ -38,32 +38,49 @@ import com.clawdbot.android.tools.ToolDisplayRegistry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun ChatMessageBubble(message: ChatMessage) { val isUser = message.role.lowercase() == "user" + val timestampText = remember(message.timestampMs) { formatTimestamp(message.timestampMs) } - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, ) { - Surface( - shape = RoundedCornerShape(16.dp), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(0.92f), ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = message.content, textColor = textColor) + Box( + modifier = + Modifier + .background(bubbleBackground(isUser)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + val textColor = textColorOverBubble(isUser) + ChatMessageBody(content = message.content, textColor = textColor) + } } } + if (timestampText != null) { + Text( + text = timestampText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } } } @@ -250,3 +267,35 @@ fun ChatCodeBlock(code: String, language: String?) { ) } } + +private fun formatTimestamp(timestampMs: Long?): String? { + if (timestampMs == null || timestampMs <= 0) return null + val now = System.currentTimeMillis() + val diff = now - timestampMs + val date = Date(timestampMs) + + // If today, show only time (e.g., "14:32") + // If yesterday or within 7 days, show day + time (e.g., "Mon 14:32") + // Otherwise, show date + time (e.g., "15 Jan 14:32") + val dayMs = 24 * 60 * 60 * 1000L + val weekMs = 7 * dayMs + + return when { + diff < dayMs && isSameDay(timestampMs, now) -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + } + diff < weekMs -> { + SimpleDateFormat("EEE HH:mm", Locale.getDefault()).format(date) + } + else -> { + SimpleDateFormat("d MMM HH:mm", Locale.getDefault()).format(date) + } + } +} + +private fun isSameDay(ts1: Long, ts2: Long): Boolean { + val cal1 = java.util.Calendar.getInstance().apply { timeInMillis = ts1 } + val cal2 = java.util.Calendar.getInstance().apply { timeInMillis = ts2 } + return cal1.get(java.util.Calendar.YEAR) == cal2.get(java.util.Calendar.YEAR) && + cal1.get(java.util.Calendar.DAY_OF_YEAR) == cal2.get(java.util.Calendar.DAY_OF_YEAR) +} diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift index 679f50139..a60984d25 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift @@ -141,18 +141,47 @@ struct ChatMessageBubble: View { let userAccent: Color? var body: some View { - ChatMessageBody( - message: self.message, - isUser: self.isUser, - style: self.style, - markdownVariant: self.markdownVariant, - userAccent: self.userAccent) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) - .padding(.horizontal, 2) + VStack(alignment: self.isUser ? .trailing : .leading, spacing: 2) { + ChatMessageBody( + message: self.message, + isUser: self.isUser, + style: self.style, + markdownVariant: self.markdownVariant, + userAccent: self.userAccent) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) + if let timestampText = self.formattedTimestamp { + Text(timestampText) + .font(.caption2) + .foregroundStyle(.secondary.opacity(0.6)) + .padding(.horizontal, 4) + } + } + .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) + .padding(.horizontal, 2) } private var isUser: Bool { self.message.role.lowercased() == "user" } + + private var formattedTimestamp: String? { + guard let ts = self.message.timestamp, ts > 0 else { return nil } + let date = Date(timeIntervalSince1970: ts / 1000.0) + let now = Date() + let calendar = Calendar.current + let dayDiff = calendar.dateComponents([.day], from: date, to: now).day ?? 0 + + let formatter = DateFormatter() + formatter.locale = Locale.current + + if calendar.isDateInToday(date) { + formatter.dateFormat = "HH:mm" + } else if dayDiff < 7 { + formatter.dateFormat = "EEE HH:mm" + } else { + formatter.dateFormat = "d MMM HH:mm" + } + + return formatter.string(from: date) + } } @MainActor