feat(chat): anchor delivery checks to the bubble's bottom-end corner

Instead of trailing the timestamp mid-line, the delivery checks now
park at the bubble's bottom-end corner like classic messengers, with
the body text reserving a small end inset so the last line never
collides with them. The checks keep their constant-width grey-to-green
behaviour, colour tween, and scale pop (shared DeliveryStatusIcon).
Matrix mode is unchanged.
This commit is contained in:
callebtc 2026-08-01 17:07:41 +02:00
parent 7247d0ad5b
commit 6eafa5932d
3 changed files with 48 additions and 91 deletions

View File

@ -193,42 +193,6 @@ private fun appendMutedTimestamp(
builder.pop()
}
/**
* Per-check colours for the inline delivery marker trailing the timestamp inside a bubble.
*
* Both checks always render grey until an acknowledgement turns them on so a status change
* recolours in place and can never reflow the message text.
*/
data class MessageStatusGlyph(
val firstColor: Color,
val secondColor: Color,
)
private fun appendStatusGlyph(
builder: AnnotatedString.Builder,
glyph: MessageStatusGlyph,
) {
builder.append(" ")
builder.pushStyle(
SpanStyle(
color = glyph.firstColor,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = FontWeight.Normal,
)
)
builder.append("")
builder.pop()
builder.pushStyle(
SpanStyle(
color = glyph.secondColor,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = FontWeight.Normal,
)
)
builder.append("")
builder.pop()
}
/**
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
* trailing timestamp.
@ -244,8 +208,7 @@ fun formatTextMessageBody(
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeTimestamp: Boolean = true,
statusGlyph: MessageStatusGlyph? = null
includeTimestamp: Boolean = true
): AnnotatedString {
val builder = AnnotatedString.Builder()
@ -262,9 +225,6 @@ fun formatTextMessageBody(
if (includeTimestamp) {
appendBodyTimestamp(builder, message, palette, timeFormatter)
}
if (statusGlyph != null) {
appendStatusGlyph(builder, statusGlyph)
}
return builder.toAnnotatedString()
}

View File

@ -760,30 +760,6 @@ internal fun TextMessageLayout(
onMessageLongPress?.invoke(message)
}
// Bubble mode pulls the delivery marker into the bubble, trailing the timestamp. Both
// checks render from the start — grey until an acknowledgement turns them green — so a
// status change recolours in place and never reflows the text. The colour transition is
// animated, which reads as the checks lighting up rather than popping in.
val checkTargets = deliveryCheckColors(
status = if (bubbles && isSelf && message.isPrivate) message.deliveryStatus else null,
colorScheme = colorScheme,
)
val firstCheck by animateColorAsState(
targetValue = checkTargets.first,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "firstCheckColor",
)
val secondCheck by animateColorAsState(
targetValue = checkTargets.second,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "secondCheckColor",
)
val statusGlyph = if (bubbles && isSelf && message.isPrivate && message.deliveryStatus != null) {
MessageStatusGlyph(firstColor = firstCheck, secondColor = secondCheck)
} else {
null
}
// 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.
val bodyText = remember(
@ -793,8 +769,7 @@ internal fun TextMessageLayout(
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter,
statusGlyph
timeFormatter
) {
formatTextMessageBody(
message = displayMessage,
@ -804,7 +779,6 @@ internal fun TextMessageLayout(
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
statusGlyph = statusGlyph,
)
}
@ -966,32 +940,52 @@ private fun BubbleTextMessageLayout(
)
}
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
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
}
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
else -> false
else -> false
}
},
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
}
),
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)
}
}
},
onLongPress = onLongPress,
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
)
}
}
}
}
}

View File

@ -61,6 +61,9 @@ 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
const val SenderSuffixAlpha: Float = 0.60f
const val HighlightAlpha: Float = 0.20f
const val MutedTextAlpha: Float = 0.50f