feat(chat): right-align bubble timestamps beside the delivery checks

Bubbles now park a bottom meta row at their end edge, like classic
messengers: the timestamp sits right-aligned, followed at a fixed gap
by the delivery checks for own private messages, instead of trailing
the body text mid-line. Matrix mode keeps its inline trailing
timestamps.
This commit is contained in:
callebtc 2026-08-01 17:34:36 +02:00
parent 6eafa5932d
commit 498c35188f
2 changed files with 47 additions and 41 deletions

View File

@ -30,10 +30,12 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@ -761,7 +763,8 @@ internal fun TextMessageLayout(
}
// The timestamp trails the body rather than occupying its own column, so a short message
// no longer reserves a full-width row for eight grey characters.
// no longer reserves a full-width row for eight grey characters. Bubbles instead park it
// in the bubble's bottom meta row, right-aligned next to the delivery checks.
val bodyText = remember(
displayMessage,
currentUserNickname,
@ -769,7 +772,8 @@ internal fun TextMessageLayout(
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter
timeFormatter,
bubbles
) {
formatTextMessageBody(
message = displayMessage,
@ -779,6 +783,7 @@ internal fun TextMessageLayout(
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
includeTimestamp = !bubbles,
)
}
@ -789,6 +794,7 @@ internal fun TextMessageLayout(
bodyText = bodyText,
isSelf = isSelf,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = handleLongPress,
modifier = modifier,
@ -870,6 +876,7 @@ private fun BubbleTextMessageLayout(
bodyText: AnnotatedString,
isSelf: Boolean,
showSender: Boolean,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onLongPress: () -> Unit,
modifier: Modifier = Modifier,
@ -940,49 +947,48 @@ private fun BubbleTextMessageLayout(
)
}
Box {
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
else -> false
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
},
onLongPress = onLongPress,
// Keep the last line clear of the checks parked at the bubble's corner.
modifier = Modifier.padding(
end = if (isSelf && message.isPrivate && message.deliveryStatus != null) {
ChatVisualTokens.BubbleStatusInset
} else {
0.dp
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
),
else -> false
}
},
onLongPress = onLongPress,
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
)
// Bottom meta row, right-aligned like classic messengers: timestamp, then
// the delivery checks at a fixed gap when this is an own private message.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
)
// Delivery checks anchor the bubble's bottom-end corner, like classic
// messengers, instead of trailing the timestamp mid-line.
if (isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
DeliveryStatusIcon(status = status)
}
Spacer(Modifier.width(ChatVisualTokens.BubbleStatusSpacing))
DeliveryStatusIcon(status = status)
}
}
}

View File

@ -61,8 +61,8 @@ internal object ChatVisualTokens {
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
const val BubbleBorderAlpha: Float = 0.38f
/** End inset reserving room for the delivery checks parked at a bubble's bottom-end corner. */
val BubbleStatusInset: Dp = 18.dp
/** Fixed gap between the timestamp and the delivery checks in a bubble's meta row. */
val BubbleStatusSpacing: Dp = 4.dp
const val SenderSuffixAlpha: Float = 0.60f
const val HighlightAlpha: Float = 0.20f