mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
7569fba7b4
commit
af91abab01
@ -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),
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user