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:
parent
cf334d3b7d
commit
9d08000c90
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user