From af91abab0169b9d302331d4cfc07fea5308f978f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:05:41 +0200 Subject: [PATCH] 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 --- .../com/bitchat/android/ui/ChatUIUtils.kt | 41 ++++- .../bitchat/android/ui/MessageComponents.kt | 151 ++++++++++-------- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 2 +- 3 files changed, 127 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d2ab06d3..a67f6a04 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 = 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), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index b9e51f75..ce127b8c 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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), + ) + } } } } diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 69693b7e..44377856 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -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" }