feat(chat): pull sender and delivery status into bubbles, thin-space hash suffix

Bubbles mode now reads like a classic messenger thread:

- the sender's name heads the first bubble of each run instead of
  floating above it; continuation bubbles skip it
- the delivery/read marker for own private messages trails the
  timestamp inside the bubble (same glyph mapping as the standalone
  marker); media rows keep the beneath-card marker since they have no
  inline text
- display names and their #abcd disambiguation suffix are now
  separated by a thin space (U+2009) in both matrix and bubbles modes
This commit is contained in:
callebtc 2026-08-01 15:05:41 +02:00
parent 7569fba7b4
commit af91abab01
3 changed files with 127 additions and 67 deletions

View File

@ -53,11 +53,14 @@ fun getRSSIColor(rssi: Int): Color {
}
}
/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */
internal const val SUFFIX_THIN_SPACE = ""
/**
* Build the sender label shown above the first message of a group.
*
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
* annotation for everyone except yourself.
* Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries
* a `nickname_click` annotation for everyone except yourself.
*/
fun formatTextMessageSender(
message: BitchatMessage,
@ -97,6 +100,7 @@ fun formatTextMessageSender(
builder.pop()
if (suffix.isNotEmpty()) {
builder.append(SUFFIX_THIN_SPACE)
builder.pushStyle(
SpanStyle(
color = senderColor.copy(alpha = SUFFIX_ALPHA),
@ -189,6 +193,32 @@ private fun appendMutedTimestamp(
builder.pop()
}
/**
* A delivery-status glyph rendered inline, trailing the timestamp inside a bubble.
*
* Mirrors the mapping used by the standalone delivery marker so the two never disagree.
*/
data class MessageStatusGlyph(
val text: String,
val color: Color,
val bold: Boolean,
)
private fun appendStatusGlyph(
builder: AnnotatedString.Builder,
glyph: MessageStatusGlyph,
) {
builder.pushStyle(
SpanStyle(
color = glyph.color,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = if (glyph.bold) FontWeight.Bold else FontWeight.Normal,
)
)
builder.append(" ${glyph.text}")
builder.pop()
}
/**
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
* trailing timestamp.
@ -204,7 +234,8 @@ fun formatTextMessageBody(
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeTimestamp: Boolean = true
includeTimestamp: Boolean = true,
statusGlyph: MessageStatusGlyph? = null
): AnnotatedString {
val builder = AnnotatedString.Builder()
@ -221,6 +252,9 @@ fun formatTextMessageBody(
if (includeTimestamp) {
appendBodyTimestamp(builder, message, palette, timeFormatter)
}
if (statusGlyph != null) {
appendStatusGlyph(builder, statusGlyph)
}
return builder.toAnnotatedString()
}
@ -304,6 +338,7 @@ fun formatMessageHeaderAnnotatedString(
builder.pop()
if (suffix.isNotEmpty()) {
builder.append(SUFFIX_THIN_SPACE)
builder.pushStyle(
SpanStyle(
color = baseColor.copy(alpha = SUFFIX_ALPHA),

View File

@ -458,8 +458,11 @@ fun MessageItem(
}
}
// Bubble mode: a small end-aligned marker beneath the bubble, clear of the tail.
if (bubbles && message.isPrivate && message.sender == currentUserNickname) {
// Bubble mode: text messages carry the marker inline, trailing the timestamp. Media
// rows have no inline text, so their marker stays beneath the end-aligned card.
if (bubbles && message.type != BitchatMessageType.Message &&
message.isPrivate && message.sender == currentUserNickname
) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
@ -752,6 +755,37 @@ internal fun TextMessageLayout(
palette = palette,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val handleLongPress: () -> Unit = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
// Bubble mode pulls the delivery marker into the bubble, trailing the timestamp, so the
// whole message reads as one unit. Same glyph mapping as the standalone marker.
val statusGlyph = if (bubbles && isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
when (status) {
is DeliveryStatus.Sending ->
MessageStatusGlyph(stringResource(R.string.status_sending), colorScheme.primary.copy(alpha = 0.6f), bold = false)
is DeliveryStatus.Sent ->
MessageStatusGlyph(stringResource(R.string.status_pending), colorScheme.primary.copy(alpha = 0.6f), bold = false)
is DeliveryStatus.Delivered ->
MessageStatusGlyph(stringResource(R.string.status_sent), colorScheme.primary.copy(alpha = 0.8f), bold = false)
is DeliveryStatus.Read ->
MessageStatusGlyph(stringResource(R.string.status_delivered), colorScheme.secondary, bold = true)
is DeliveryStatus.Failed ->
MessageStatusGlyph(stringResource(R.string.status_failed), colorScheme.error, bold = false)
is DeliveryStatus.PartiallyDelivered ->
MessageStatusGlyph(stringResource(R.string.status_sent), colorScheme.primary.copy(alpha = 0.6f), bold = false)
}
}
} 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(
@ -761,7 +795,8 @@ internal fun TextMessageLayout(
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter
timeFormatter,
statusGlyph
) {
formatTextMessageBody(
message = displayMessage,
@ -771,15 +806,9 @@ internal fun TextMessageLayout(
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
statusGlyph = statusGlyph,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val handleLongPress: () -> Unit = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
if (bubbles) {
BubbleTextMessageLayout(
@ -891,37 +920,8 @@ private fun BubbleTextMessageLayout(
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
) {
if (showSender) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = onLongPress,
modifier = Modifier
.padding(
top = MessageGrouping.SENDER_TOP_PADDING,
// Nudge the label off the bubble's rounded edge so it lines up with the text.
start = if (isSelf) 0.dp else ChatVisualTokens.BubblePaddingHorizontal,
end = if (isSelf) ChatVisualTokens.BubblePaddingHorizontal else 0.dp,
),
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
// Cap the bubble at a fraction of the row so long messages wrap instead of touching the
// opposite edge, while short ones hug their content.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
@ -944,32 +944,57 @@ private fun BubbleTextMessageLayout(
vertical = ChatVisualTokens.BubblePaddingVertical,
)
) {
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
// The sender's name heads the first bubble of their run, like classic group
// messengers, instead of floating above it. Continuation bubbles skip it.
if (showSender) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = onLongPress,
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(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),
)
"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),
)
}
}
}
}

View File

@ -385,7 +385,7 @@ class ChatUIUtilsTest {
palette = palette,
)
assertEquals("@carol#04af", sender.text)
assertEquals("@carol#04af", sender.text)
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }