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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageBubble(message: ChatMessage) {
|
fun ChatMessageBubble(message: ChatMessage) {
|
||||||
val isUser = message.role.lowercase() == "user"
|
val isUser = message.role.lowercase() == "user"
|
||||||
|
val timestampText = remember(message.timestampMs) { formatTimestamp(message.timestampMs) }
|
||||||
|
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Row(
|
||||||
shape = RoundedCornerShape(16.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
tonalElevation = 0.dp,
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
shadowElevation = 0.dp,
|
|
||||||
color = Color.Transparent,
|
|
||||||
modifier = Modifier.fillMaxWidth(0.92f),
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Surface(
|
||||||
modifier =
|
shape = RoundedCornerShape(16.dp),
|
||||||
Modifier
|
tonalElevation = 0.dp,
|
||||||
.background(bubbleBackground(isUser))
|
shadowElevation = 0.dp,
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
color = Color.Transparent,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.92f),
|
||||||
) {
|
) {
|
||||||
val textColor = textColorOverBubble(isUser)
|
Box(
|
||||||
ChatMessageBody(content = message.content, textColor = textColor)
|
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?
|
let userAccent: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChatMessageBody(
|
VStack(alignment: self.isUser ? .trailing : .leading, spacing: 2) {
|
||||||
message: self.message,
|
ChatMessageBody(
|
||||||
isUser: self.isUser,
|
message: self.message,
|
||||||
style: self.style,
|
isUser: self.isUser,
|
||||||
markdownVariant: self.markdownVariant,
|
style: self.style,
|
||||||
userAccent: self.userAccent)
|
markdownVariant: self.markdownVariant,
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
userAccent: self.userAccent)
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(.horizontal, 2)
|
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 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
|
@MainActor
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user