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)
This commit is contained in:
JARVIS 2026-01-27 18:03:37 +01:00
parent cf334d3b7d
commit 9d08000c90
2 changed files with 102 additions and 24 deletions

View File

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

View File

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