From 94d5fa30849279a4f12693543fe901a0c30d62fe Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:41:36 +0200 Subject: [PATCH 01/11] feat(chat): add bubbles chat UI mode with peer-color bubbles Add a toggleable chat transcript style alongside the existing matrix transcript. Bubbles mode renders text messages as classic messenger bubbles: own messages on the right, peers on the left, each bubble washed with the author's stable identity-derived peer colour so the speaker stays identifiable without changing any theme, surface, or background colours. - ChatUiMode preference (Matrix / Bubbles, default Bubbles) persisted via ChatUiModeManager, mirroring ThemePreferenceManager - BubbleTextMessageLayout: rounded bubble with a subtle tail on the speaker's side, width capped at 80% so long messages wrap; sender labels, grouping, timestamps, mentions, links, long-press, and the existing spring entry/placement animations are unchanged - Delivery status for own private messages moves beneath the bubble in bubbles mode so it never overlaps the tail - Chat style picker in About -> Settings reusing the ThemeChip pattern --- .../com/bitchat/android/BitchatApplication.kt | 3 + .../java/com/bitchat/android/ui/AboutSheet.kt | 34 ++++ .../bitchat/android/ui/MessageComponents.kt | 182 +++++++++++++++++- .../bitchat/android/ui/theme/ChatUiMode.kt | 49 +++++ .../android/ui/theme/ChatVisualTokens.kt | 24 +++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index fe3d924f..7b5926ef 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -48,6 +48,9 @@ class BitchatApplication : Application() { // Initialize theme preference ThemePreferenceManager.init(this) + // Initialize chat UI mode (matrix transcript vs bubbles) + com.bitchat.android.ui.theme.ChatUiModeManager.init(this) + // Initialize debug preference manager (persists debug toggles) try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f58e2cc4..d4368ec8 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -418,6 +418,40 @@ fun AboutSheet( } } + item(key = "chat_style") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_chat_style)) + val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.chat_ui_matrix), + selected = chatUiMode.isMatrix, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.chat_ui_bubbles), + selected = chatUiMode.isBubbles, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + item(key = "language") { val selectedLanguageName = supportedLanguages .firstOrNull { it.languageTag == selectedLanguageTag } 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 5ab113db..35166672 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding @@ -36,6 +37,7 @@ 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.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -49,6 +51,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -67,6 +70,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -83,10 +87,12 @@ import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.ChatUiModeManager import com.bitchat.android.ui.theme.ChatVisualTokens import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeer import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -223,6 +229,10 @@ fun MessagesList( mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) } + // Collected once here so individual rows never subscribe to the preference flow; a mode + // switch simply recomposes the list against the new layout. + val bubbles by ChatUiModeManager.modeFlow.collectAsState() + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the // previous channel's offset and then had to correct itself, which is what the jump was. // @@ -352,6 +362,7 @@ fun MessagesList( meshService = meshService, mentionPeerIdentities = resolvedMentionPeerIdentities, showSender = !isGrouped, + bubbles = bubbles.isBubbles, topSpacing = MessageGrouping.topSpacingFor( isGrouped = isGrouped, isFirstInList = originalIndex == 0 @@ -385,6 +396,7 @@ fun MessageItem( messages: List = emptyList(), mentionPeerIdentities: Map = emptyMap(), showSender: Boolean = true, + bubbles: Boolean = false, topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -407,8 +419,9 @@ fun MessageItem( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top ) { - // Provide a small end padding for own private messages so overlay doesn't cover text - val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp + // Provide a small end padding for own private messages so overlay doesn't cover text. + // Bubble mode draws the status beneath the bubble instead, so no inset is needed. + val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp // Create a custom layout that combines selectable text with clickable nickname areas MessageTextWithClickableNicknames( message = message, @@ -419,6 +432,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -429,8 +443,9 @@ fun MessageItem( ) } - // Delivery status for private messages (overlay, non-displacing) - if (message.isPrivate && message.sender == currentUserNickname) { + // Delivery status for private messages (overlay, non-displacing). Bubble mode aligns + // own messages to the end edge where this overlay lives, so it renders below instead. + if (!bubbles && message.isPrivate && message.sender == currentUserNickname) { message.deliveryStatus?.let { status -> Box( modifier = Modifier @@ -442,7 +457,21 @@ fun MessageItem( } } } - + + // Bubble mode: a small end-aligned marker beneath the bubble, clear of the tail. + if (bubbles && message.isPrivate && message.sender == currentUserNickname) { + message.deliveryStatus?.let { status -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, end = 4.dp), + contentAlignment = Alignment.CenterEnd + ) { + DeliveryStatusIcon(status = status) + } + } + } + // Link previews removed; links are now highlighted inline and clickable within the message text } } @@ -458,6 +487,7 @@ fun MessageItem( colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, showSender: Boolean, + bubbles: Boolean = false, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, @@ -624,6 +654,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier @@ -668,6 +699,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -687,6 +719,7 @@ internal fun TextMessageLayout( onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, showSender: Boolean = true, + bubbles: Boolean = false, bodyContent: String = message.content, ) { val palette = LocalBitchatPalette.current @@ -731,6 +764,20 @@ internal fun TextMessageLayout( onMessageLongPress?.invoke(message) } + if (bubbles) { + BubbleTextMessageLayout( + message = message, + senderText = senderText, + bodyText = bodyText, + isSelf = isSelf, + showSender = showSender, + onNicknameClick = onNicknameClick, + onLongPress = handleLongPress, + modifier = modifier, + ) + return + } + Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), @@ -788,6 +835,129 @@ internal fun TextMessageLayout( } } +/** + * Classic messenger rendering of a text message: a rounded bubble that hugs its content, own + * messages on the right and everyone else on the left, with the corner on the speaker's side + * tightened into a subtle tail. + * + * The bubble is washed with the author's stable peer colour — the same identity-derived colour + * the `@name` label and mention chips already use — so the speaker stays identifiable at a + * glance without touching any surface, background, or theme colour. Body text keeps the + * standard `onSurface` tone; only the bubble shell carries the identity. + */ +@Composable +private fun BubbleTextMessageLayout( + message: BitchatMessage, + senderText: AnnotatedString, + bodyText: AnnotatedString, + isSelf: Boolean, + showSender: Boolean, + onNicknameClick: ((String) -> Unit)?, + onLongPress: () -> Unit, + modifier: Modifier = Modifier, +) { + val palette = LocalBitchatPalette.current + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + + val authorColor = remember(message, isSelf, palette) { + if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette) + } + + val corner = ChatVisualTokens.BubbleCornerRadius + val tail = ChatVisualTokens.BubbleTailRadius + val bubbleShape = if (isSelf) { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner) + } else { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail) + } + + 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()) { + val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction + Box( + modifier = Modifier + .align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxBubbleWidth) + .border( + width = 1.dp, + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha), + shape = bubbleShape + ) + .background( + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha), + shape = bubbleShape + ) + .padding( + horizontal = ChatVisualTokens.BubblePaddingHorizontal, + 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 + } + + "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), + ) + } + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun CashuMessageContent( @@ -797,6 +967,7 @@ private fun CashuMessageContent( meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, + bubbles: Boolean = false, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier @@ -816,6 +987,7 @@ private fun CashuMessageContent( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, bodyContent = remainingText, diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt new file mode 100644 index 00000000..36c9fb7f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui.theme + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Chat transcript presentation. + * + * [Matrix] is the established terminal-style transcript: a flat, left-aligned monochrome + * stream where colour is reserved for `@names` and links. + * + * [Bubbles] is the classic messenger layout: messages hug their content inside rounded + * bubbles, own messages on the right and everyone else on the left. Each bubble is tinted + * with its author's stable peer colour, so the speaker stays identifiable without reading + * the name. Colours, surfaces, and typography are untouched — only the message layout + * changes. + */ +enum class ChatUiMode { + Matrix, + Bubbles; + + val isMatrix: Boolean get() = this == Matrix + val isBubbles: Boolean get() = this == Bubbles +} + +/** + * Simple SharedPreferences-backed manager for the chat UI mode with a StateFlow. + * Mirrors [ThemePreferenceManager]. + */ +object ChatUiModeManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_CHAT_UI_MODE = "chat_ui_mode" + + private val _modeFlow = MutableStateFlow(ChatUiMode.Bubbles) + val modeFlow: StateFlow = _modeFlow + + fun init(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs.getString(KEY_CHAT_UI_MODE, ChatUiMode.Bubbles.name) + _modeFlow.value = runCatching { ChatUiMode.valueOf(saved!!) }.getOrDefault(ChatUiMode.Bubbles) + } + + fun set(context: Context, mode: ChatUiMode) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_CHAT_UI_MODE, mode.name).apply() + _modeFlow.value = mode + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index cdd4d418..10ab9e9e 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -37,6 +37,30 @@ internal object ChatVisualTokens { val SenderTopPadding: Dp = 8.dp val SenderToBodySpacing: Dp = 4.dp + // MARK: - Bubble geometry (ChatUiMode.Bubbles) + + /** Rounded corner on the three "free" corners of a message bubble. */ + val BubbleCornerRadius: Dp = 16.dp + + /** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */ + val BubbleTailRadius: Dp = 4.dp + + /** Padding inside a bubble, around the text. */ + val BubblePaddingHorizontal: Dp = 12.dp + val BubblePaddingVertical: Dp = 8.dp + + /** A bubble never grows past this fraction of the list width, so long lines still wrap. */ + const val BubbleMaxWidthFraction: Float = 0.80f + + /** + * Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted + * bubble stays legible on both the near-black and near-white chat surfaces. + */ + const val BubbleBackgroundAlpha: Float = 0.18f + + /** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */ + const val BubbleBorderAlpha: Float = 0.38f + const val SenderSuffixAlpha: Float = 0.60f const val HighlightAlpha: Float = 0.20f const val MutedTextAlpha: Float = 0.50f diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f705c5ad..690db90b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,6 +171,7 @@ About Theme + Chat style Settings @@ -196,6 +197,8 @@ System Light Dark + Matrix + Bubbles Proof of Work PoW Off PoW On From 7569fba7b4f2a6f8458ad1b46e3550885bbe1fc1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:57:40 +0200 Subject: [PATCH 02/11] feat(chat): align self media rows with bubbles chat UI mode In bubbles mode, self-authored image, voice-note, and file rows now align to the end side with the same tail-corner cue as text bubbles, instead of sitting on the received side where they read as someone else's content. Grouped self voice notes also keep the run on the correct side. VoiceNotePlayer gains an optional modifier so the player can hug the end side at a capped width. Received media and matrix mode are unchanged. --- .../bitchat/android/ui/MessageComponents.kt | 21 +++++++++++++-- .../android/ui/media/AudioMessageItem.kt | 16 +++++++++--- .../android/ui/media/ImageMessageItem.kt | 26 +++++++++++++++---- .../android/ui/media/VoiceNotePlayer.kt | 3 ++- 4 files changed, 55 insertions(+), 11 deletions(-) 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 35166672..b9e51f75 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -506,6 +506,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -524,6 +525,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -544,7 +546,15 @@ fun MessageItem( } else -> null to null } - Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = modifier.fillMaxWidth(), + // Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles. + horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) { + Alignment.End + } else { + Alignment.Start + }, + ) { // Header: nickname + timestamp line above the file, identical styling to text messages val headerText = formatMessageHeaderAnnotatedString( message = message, @@ -590,7 +600,14 @@ fun MessageItem( null } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) { + Arrangement.End + } else { + Arrangement.Start + } + ) { Box { if (packet != null) { if (overrideProgress != null) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index c646d570..9252123c 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -22,6 +22,7 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.isFromSelf import java.text.SimpleDateFormat @Composable @@ -35,10 +36,13 @@ fun AudioMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, - showSender: Boolean = true + showSender: Boolean = true, + bubbles: Boolean = false ) { val palette = LocalBitchatPalette.current val path = message.content.trim() + // Bubble mode aligns self-authored voice notes to the end side, mirroring text bubbles. + val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID) // Derive sending progress if applicable val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) { is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> { @@ -48,7 +52,10 @@ fun AudioMessageItem( } else -> null to null } - Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start, + ) { // Header: nickname + timestamp line above the audio note, identical styling to text messages val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, @@ -81,7 +88,10 @@ fun AudioMessageItem( VoiceNotePlayer( path = path, progressOverride = overrideProgress, - progressColor = overrideColor + progressColor = overrideColor, + // Self voice notes hug the end side like other self content instead of + // spanning the full row. + modifier = if (isSelfInBubbles) Modifier.widthIn(max = 300.dp) else Modifier ) val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered) if (showCancel) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 5d9908fc..ce7ba470 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -28,6 +28,7 @@ import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.isFromSelf import java.text.SimpleDateFormat @Composable @@ -43,11 +44,23 @@ fun ImageMessageItem( onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier, - showSender: Boolean = true + showSender: Boolean = true, + bubbles: Boolean = false ) { val palette = LocalBitchatPalette.current val path = message.content.trim() - Column(modifier = modifier.fillMaxWidth()) { + // Bubble mode aligns self-authored media to the end side, mirroring text bubbles; the + // corner on the speaker's side is tightened into the same subtle tail. + val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID) + val imageShape = when { + !bubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp) + isSelfInBubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 3.dp, 10.dp) + else -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 10.dp, 3.dp) + } + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start, + ) { val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, @@ -91,7 +104,10 @@ fun ImageMessageItem( is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f else -> null } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isSelfInBubbles) Arrangement.End else Arrangement.Start + ) { Box { if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) { // Cyberpunk block-reveal while sending @@ -103,7 +119,7 @@ fun ImageMessageItem( modifier = Modifier .widthIn(max = 300.dp) .aspectRatio(aspect) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) + .clip(imageShape) .clickable { val currentIndex = imagePaths.indexOf(path) onImageClick?.invoke(path, imagePaths, currentIndex) @@ -117,7 +133,7 @@ fun ImageMessageItem( modifier = Modifier .widthIn(max = 300.dp) .aspectRatio(aspect) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) + .clip(imageShape) .clickable { val currentIndex = imagePaths.indexOf(path) onImageClick?.invoke(path, imagePaths, currentIndex) diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 10109194..1f3f567e 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp @Composable fun VoiceNotePlayer( path: String, + modifier: Modifier = Modifier, progressOverride: Float? = null, progressColor: Color? = null ) { @@ -86,7 +87,7 @@ fun VoiceNotePlayer( DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } } Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { 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 03/11] 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" } From 7247d0ad5b9122e4f7bf2c2e2dd9d984c784acdb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:37 +0200 Subject: [PATCH 04/11] feat(chat): constant-width two-check delivery marker that lights up green Delivery/read markers previously swapped glyphs as acknowledgements arrived, reflowing the text around them. Both checks now render from the start in a disabled grey and simply recolour as the state advances (delivered lights the first check, read lights both), so nothing ever pushes message text around. Read receipts use the app's primary green instead of the blue accent, a quick colour tween lights the checks up, and the standalone marker adds a snappy scale pop when the state advances. Applies to both matrix and bubbles modes. --- .../com/bitchat/android/ui/ChatUIUtils.kt | 26 +++- .../bitchat/android/ui/MessageComponents.kt | 144 +++++++++++------- app/src/main/res/values-fil/strings.xml | 6 - app/src/main/res/values-fr/strings.xml | 6 - app/src/main/res/values-he/strings.xml | 6 - app/src/main/res/values-it/strings.xml | 6 - app/src/main/res/values-mg/strings.xml | 6 - app/src/main/res/values-ms/strings.xml | 6 - app/src/main/res/values-ne/strings.xml | 6 - app/src/main/res/values-nl/strings.xml | 6 - app/src/main/res/values-pl/strings.xml | 6 - app/src/main/res/values-ru/strings.xml | 7 - app/src/main/res/values-sv/strings.xml | 7 - app/src/main/res/values-ta/strings.xml | 6 - app/src/main/res/values-tr/strings.xml | 7 - app/src/main/res/values-uk/strings.xml | 6 - app/src/main/res/values-zh-rCN/strings.xml | 6 - app/src/main/res/values-zh-rTW/strings.xml | 6 - app/src/main/res/values-zh/strings.xml | 6 - app/src/main/res/values/strings.xml | 8 - 20 files changed, 106 insertions(+), 177 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 a67f6a04..485fb9b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -194,28 +194,38 @@ private fun appendMutedTimestamp( } /** - * A delivery-status glyph rendered inline, trailing the timestamp inside a bubble. + * Per-check colours for the inline delivery marker trailing the timestamp inside a bubble. * - * Mirrors the mapping used by the standalone delivery marker so the two never disagree. + * 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 text: String, - val color: Color, - val bold: Boolean, + val firstColor: Color, + val secondColor: Color, ) private fun appendStatusGlyph( builder: AnnotatedString.Builder, glyph: MessageStatusGlyph, ) { + builder.append(" ") builder.pushStyle( SpanStyle( - color = glyph.color, + color = glyph.firstColor, fontSize = ChatVisualTokens.SystemTimeFontSize, - fontWeight = if (glyph.bold) FontWeight.Bold else FontWeight.Normal, + fontWeight = FontWeight.Normal, ) ) - builder.append(" ${glyph.text}") + builder.append("✓") + builder.pop() + builder.pushStyle( + SpanStyle( + color = glyph.secondColor, + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + builder.append("✓") builder.pop() } 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 ce127b8c..6b3b52af 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -8,7 +8,7 @@ import android.content.Intent import android.net.Uri import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FiniteAnimationSpec @@ -16,9 +16,6 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -763,25 +760,26 @@ internal fun TextMessageLayout( 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) - } - } + // 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 } @@ -1145,43 +1143,77 @@ private fun redeemCashu(context: Context, token: String, preferWallet: Boolean) runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) } } +/** + * Per-check target colours for the delivery marker. + * + * Both checks always render — grey (disabled) until an acknowledgement turns them on — so a + * status change recolours in place and never reflows text around it. Read receipts use the + * app's primary green rather than a separate accent. [status] == null yields the all-grey + * baseline used while a message is still being sent. + */ +private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair { + val grey = colorScheme.onSurface.copy(alpha = 0.35f) + val green = colorScheme.primary + return when (status) { + is DeliveryStatus.Read -> green to green + is DeliveryStatus.Delivered -> green to grey + is DeliveryStatus.PartiallyDelivered -> green to grey + is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error + else -> grey to grey + } +} + +/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */ +private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) { + is DeliveryStatus.Read -> 3 + is DeliveryStatus.Delivered -> 2 + is DeliveryStatus.PartiallyDelivered -> 2 + is DeliveryStatus.Failed -> 1 + else -> 0 +} + @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme + val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme) + val first by animateColorAsState( + targetValue = firstTarget, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "firstCheckColor", + ) + val second by animateColorAsState( + targetValue = secondTarget, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "secondCheckColor", + ) - // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. - // Keyed on the status *type* rather than the instance, because Delivered/Read carry a - // timestamp that would otherwise retrigger the transition on every identical update. - AnimatedContent( - targetState = status::class, - transitionSpec = { - fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith - fadeOut(tween(BitchatMotion.QUICK_MS)) - }, - label = "deliveryStatus" - ) { statusClass -> - val (text, color, weight) = when (statusClass) { - DeliveryStatus.Sending::class -> - Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) - // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity). - DeliveryStatus.Sent::class -> - Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) - DeliveryStatus.Delivered::class -> - Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) - DeliveryStatus.Read::class -> - Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) - DeliveryStatus.Failed::class -> - Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) - // A single subdued check, without the numeric label. - else -> - Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + // Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not + // the instance, because Delivered/Read carry timestamps that would retrigger it otherwise. + val scale = remember { Animatable(1f) } + LaunchedEffect(deliveryCheckRank(status)) { + if (deliveryCheckRank(status) >= 2) { + scale.snapTo(1.3f) + scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f)) } - - Text( - text = stringResource(text), - fontSize = 10.sp, - color = color, - fontWeight = weight - ) } + + val text = remember(first, second) { + androidx.compose.ui.text.buildAnnotatedString { + pushStyle(androidx.compose.ui.text.SpanStyle(color = first)) + append("✓") + pop() + pushStyle(androidx.compose.ui.text.SpanStyle(color = second)) + append("✓") + pop() + } + } + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + modifier = Modifier.graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } + ) } diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 66cd93bd..fa4aea0c 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -293,12 +293,6 @@ File - - - - ✓✓ - - 📷 nagpadala ng larawan diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aa857f44..a884d35e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -294,12 +294,6 @@ Fichier - - - - ✓✓ - - 📷 a envoyé une image diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 86e4bbcb..f742042e 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -349,12 +349,6 @@ image/* תמונה קובץ - - - - ✓✓ - - 📷 שלח/ה תמונה 🎤 שלח/ה הודעה קולית 📎 שלח/ה קובץ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a8ecb688..af59fad3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -338,12 +338,6 @@ ⚠️ - - - - ✓✓ - - 📄 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 52617e72..b7182e09 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -302,12 +302,6 @@ Rakitra - - - - ✓✓ - - 📷 nandefasa sary diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c6391e6f..79bf5b1c 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -374,12 +374,6 @@ Fail - - - - ✓✓ - - 📷 menghantar imej diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 7a3da0bd..59d52853 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -293,12 +293,6 @@ फाइल - - - - ✓✓ - - 📷 तस्वीर पठाइयो diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 64326ae5..1e46f196 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -339,12 +339,6 @@ ? - - - - ✓✓ - - 📄 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0d2f2689..3ec84dd4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -347,12 +347,6 @@ image/* Obraz Plik - - - - ✓✓ - - 📷 wysłał(a) obraz 🎤 wysłał(a) wiadomość głosową 📎 wysłał(a) plik diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 613b008d..02b1175d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -276,13 +276,6 @@ Изображение Файл - - - - ✓✓ - - - 📷 отправил изображение 🎤 отправил голосовое 📎 отправил файл diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c0457997..b958faad 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -276,13 +276,6 @@ Bild Fil - - - - ✓✓ - - - 📷 skickade en bild 🎤 skickade ett röstmeddelande 📎 skickade en fil diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 5e7f8bdd..243f2112 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -352,12 +352,6 @@ image/* படம் கோப்பு - - - - ✓✓ - - 📷 ஒரு படத்தை அனுப்பியது 🎤 ஒரு குரல் செய்தியை அனுப்பியது 📎 ஒரு கோப்பை அனுப்பியது diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 533e3252..251d6a46 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -276,13 +276,6 @@ Görsel Dosya - - - - ✓✓ - - - 📷 görsel gönderdi 🎤 sesli mesaj gönderdi 📎 dosya gönderdi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cdc73c91..f248ff71 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -341,12 +341,6 @@ image/* Зображення Файл - - - - ✓✓ - - 📷 надіслав(-ла) зображення 🎤 надіслав(-ла) голосове повідомлення 📎 надіслав(-ла) файл diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b9e35372..fca6b07b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -347,12 +347,6 @@ image/* 图片 文件 - - - - ✓✓ - - 📷 发送了一张图片 🎤 发送了一条语音消息 📎 发送了一个文件 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 36b6bbdd..f427dbaf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -347,12 +347,6 @@ image/* 圖片 檔案 - - - - ✓✓ - - 📷 傳送了一張圖片 🎤 傳送了一則語音訊息 📎 傳送了一個檔案 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 751580a6..35f7477a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -293,12 +293,6 @@ 文件 - - - - ✓✓ - - 📷 发送了图片 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 690db90b..1c20d534 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -559,14 +559,6 @@ Image File - - - - - ✓✓ - - - 📷 sent an image 🎤 sent a voice message From 6eafa5932dcc20e13da9f655ee1fa6d464b5c766 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:07:41 +0200 Subject: [PATCH 05/11] 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. --- .../com/bitchat/android/ui/ChatUIUtils.kt | 42 +-------- .../bitchat/android/ui/MessageComponents.kt | 94 +++++++++---------- .../android/ui/theme/ChatVisualTokens.kt | 3 + 3 files changed, 48 insertions(+), 91 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 485fb9b0..14c7879d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 = 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() } 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 6b3b52af..bb5d4987 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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), - ) + } + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index 10ab9e9e..1db0d45a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -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 From 498c35188f3af3cfc624682f9f3165de3dc81708 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:34:36 +0200 Subject: [PATCH 06/11] 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. --- .../bitchat/android/ui/MessageComponents.kt | 84 ++++++++++--------- .../android/ui/theme/ChatVisualTokens.kt | 4 +- 2 files changed, 47 insertions(+), 41 deletions(-) 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 bb5d4987..fe545a62 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index 1db0d45a..976d1abd 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -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 From 6d9dfd970caca9d68d33f4d6c3361e3f87c92f61 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:04:39 +0200 Subject: [PATCH 07/11] fix(chat): trail bubble timestamp and checks inline with the body Replaces the separate bottom meta row, which forced every bubble to at least the meta row's width and added a line even to one-line messages. The timestamp + checks cluster now trails the body inline: it rides the last text line when there is room and wraps only when there isn't, so bubbles hug their content again. Own private messages keep the constant-width grey-to-green checks with the colour tween; matrix mode is unchanged. --- .../bitchat/android/ui/MessageComponents.kt | 78 ++++++++++++------- .../android/ui/theme/ChatVisualTokens.kt | 3 - 2 files changed, 52 insertions(+), 29 deletions(-) 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 fe545a62..e786093a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -763,8 +763,9 @@ 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. Bubbles instead park it - // in the bubble's bottom meta row, right-aligned next to the delivery checks. + // no longer reserves a full-width row for eight grey characters. Self bubbles trail a + // timestamp + checks cluster inline instead: it rides the last text line when there is + // room and only wraps when there isn't, so bubbles keep hugging their content. val bodyText = remember( displayMessage, currentUserNickname, @@ -773,7 +774,8 @@ internal fun TextMessageLayout( colorScheme.secondary, mentionPeerIdentities, timeFormatter, - bubbles + bubbles, + isSelf ) { formatTextMessageBody( message = displayMessage, @@ -783,18 +785,62 @@ internal fun TextMessageLayout( linkColor = colorScheme.secondary, mentionPeerIdentities = mentionPeerIdentities, timeFormatter = timeFormatter, - includeTimestamp = !bubbles, + includeTimestamp = !bubbles || !isSelf, ) } + // Self-bubble meta cluster: timestamp plus the constant-width delivery checks, appended as + // text spans so the whole thing flows with the body. Check colours tween grey -> green as + // acknowledgements arrive; nothing in the transcript reflows. + 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 bubbleBodyText = remember(bodyText, message, timeFormatter, bubbles, isSelf, firstCheck, secondCheck) { + if (!bubbles || !isSelf) return@remember bodyText + androidx.compose.ui.text.buildAnnotatedString { + append(bodyText) + append(" ") + append(formatTextMessageMetadata(message, timeFormatter)) + if (message.isPrivate && message.deliveryStatus != null) { + append(" ") + pushStyle( + androidx.compose.ui.text.SpanStyle( + color = firstCheck, + fontSize = ChatVisualTokens.SystemTimeFontSize, + ) + ) + append("✓") + pop() + pushStyle( + androidx.compose.ui.text.SpanStyle( + color = secondCheck, + fontSize = ChatVisualTokens.SystemTimeFontSize, + ) + ) + append("✓") + pop() + } + } + } + if (bubbles) { BubbleTextMessageLayout( message = message, senderText = senderText, - bodyText = bodyText, + bodyText = bubbleBodyText, isSelf = isSelf, showSender = showSender, - timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onLongPress = handleLongPress, modifier = modifier, @@ -876,7 +922,6 @@ private fun BubbleTextMessageLayout( bodyText: AnnotatedString, isSelf: Boolean, showSender: Boolean, - timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onLongPress: () -> Unit, modifier: Modifier = Modifier, @@ -973,25 +1018,6 @@ private fun BubbleTextMessageLayout( 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, - ) - if (isSelf && message.isPrivate) { - message.deliveryStatus?.let { status -> - Spacer(Modifier.width(ChatVisualTokens.BubbleStatusSpacing)) - DeliveryStatusIcon(status = status) - } - } - } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index 976d1abd..10ab9e9e 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -61,9 +61,6 @@ internal object ChatVisualTokens { /** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */ const val BubbleBorderAlpha: Float = 0.38f - /** 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 const val MutedTextAlpha: Float = 0.50f From 79d2e0328a24c48c7f020867d8f4090f7927b68b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:55:07 +0200 Subject: [PATCH 08/11] fix(chat): keep bubble meta flush-right without extra lines or width The timestamp + checks cluster now overlays the bubble's bottom-end corner while the body reserves an invisible no-break-space run (capped by a zero-width word joiner so it is never trimmed) exactly where the cluster lands. The reservation rides the last text line when there is room and wraps only when there isn't, so the cluster sits flush-right on the last line for every message length: no overlap, no forced new line, no minimum bubble width. Matrix mode is unchanged. --- .../bitchat/android/ui/MessageComponents.kt | 126 +++++++++--------- 1 file changed, 61 insertions(+), 65 deletions(-) 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 e786093a..61c54835 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -763,9 +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. Self bubbles trail a - // timestamp + checks cluster inline instead: it rides the last text line when there is - // room and only wraps when there isn't, so bubbles keep hugging their content. + // no longer reserves a full-width row for eight grey characters. Self bubbles leave the + // timestamp to their meta cluster (see BubbleTextMessageLayout). val bodyText = remember( displayMessage, currentUserNickname, @@ -789,48 +788,19 @@ internal fun TextMessageLayout( ) } - // Self-bubble meta cluster: timestamp plus the constant-width delivery checks, appended as - // text spans so the whole thing flows with the body. Check colours tween grey -> green as - // acknowledgements arrive; nothing in the transcript reflows. - 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 bubbleBodyText = remember(bodyText, message, timeFormatter, bubbles, isSelf, firstCheck, secondCheck) { + // Self bubbles reserve a run of no-break spaces after the body, sized to the meta cluster + // the bubble overlays there (timestamp plus delivery checks). A zero-width word joiner + // caps the run so the line's trailing whitespace is not trimmed away. The run rides the + // last text line when there is room and wraps only when there isn't, so the overlay can + // park flush-right on the last line without ever overlapping text or forcing the bubble + // wider than its content. + val bubbleBodyText = remember(bodyText, message, bubbles, isSelf) { if (!bubbles || !isSelf) return@remember bodyText + val reserveChars = if (message.isPrivate && message.deliveryStatus != null) 7 else 5 androidx.compose.ui.text.buildAnnotatedString { append(bodyText) - append(" ") - append(formatTextMessageMetadata(message, timeFormatter)) - if (message.isPrivate && message.deliveryStatus != null) { - append(" ") - pushStyle( - androidx.compose.ui.text.SpanStyle( - color = firstCheck, - fontSize = ChatVisualTokens.SystemTimeFontSize, - ) - ) - append("✓") - pop() - pushStyle( - androidx.compose.ui.text.SpanStyle( - color = secondCheck, - fontSize = ChatVisualTokens.SystemTimeFontSize, - ) - ) - append("✓") - pop() - } + append(" ".repeat(reserveChars)) + append("⁠") } } @@ -841,6 +811,7 @@ internal fun TextMessageLayout( bodyText = bubbleBodyText, isSelf = isSelf, showSender = showSender, + timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onLongPress = handleLongPress, modifier = modifier, @@ -922,6 +893,7 @@ private fun BubbleTextMessageLayout( bodyText: AnnotatedString, isSelf: Boolean, showSender: Boolean, + timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onLongPress: () -> Unit, modifier: Modifier = Modifier, @@ -992,32 +964,56 @@ 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, + fontFamily = BitchatFontFamily, + softWrap = true, + overflow = TextOverflow.Visible, + style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface), + ) + + // Meta cluster overlay, flush with the bubble's end edge on the body's + // last line: timestamp, then the delivery checks at a fixed gap for own + // private messages. The body reserved invisible space for it, so the + // cluster never collides with text and never adds a line unnecessarily. + if (isSelf) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + Text( + text = formatTextMessageMetadata(message, timeFormatter), + fontFamily = BitchatFontFamily, + ) + if (message.isPrivate) { + message.deliveryStatus?.let { status -> + Spacer(Modifier.width(4.dp)) + DeliveryStatusIcon(status = status) + } + } } - }, - onLongPress = onLongPress, - fontFamily = BitchatFontFamily, - softWrap = true, - overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface), - ) + } + } } } } From d85401fb496be40709078c9a4faa52c962515811 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:10:25 +0200 Subject: [PATCH 09/11] fix(chat): never let the bubble meta cluster influence text wrapping The no-break-space reservation narrowed the text's own wrap width, so first lines wrapped early and carried dead slack. The cluster (timestamp plus delivery checks) is now placed from the laid-out text: it rides flush-right in the last line's slack when there is room and drops below the text only when there is not. The body wraps at the full bubble width either way, one-liners keep hugging their content, and matrix mode is unchanged. AnnotatedClickableText gains an optional onTextLayout callback to support the measurement. --- .../component/text/AnnotatedClickableText.kt | 6 +- .../bitchat/android/ui/MessageComponents.kt | 82 +++++++++++++------ 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt index 3e1d7fe1..ac3b52f1 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt @@ -52,6 +52,7 @@ fun AnnotatedClickableText( softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, style: TextStyle = LocalTextStyle.current, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { var layoutResult by remember { mutableStateOf(null) } val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick) @@ -91,6 +92,9 @@ fun AnnotatedClickableText( softWrap = softWrap, overflow = overflow, style = style, - onTextLayout = { layoutResult = it }, + onTextLayout = { result -> + layoutResult = result + onTextLayout?.invoke(result) + }, ) } 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 61c54835..0c4af8ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -63,17 +63,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.ui.theme.BitchatFontFamily @@ -788,27 +792,11 @@ internal fun TextMessageLayout( ) } - // Self bubbles reserve a run of no-break spaces after the body, sized to the meta cluster - // the bubble overlays there (timestamp plus delivery checks). A zero-width word joiner - // caps the run so the line's trailing whitespace is not trimmed away. The run rides the - // last text line when there is room and wraps only when there isn't, so the overlay can - // park flush-right on the last line without ever overlapping text or forcing the bubble - // wider than its content. - val bubbleBodyText = remember(bodyText, message, bubbles, isSelf) { - if (!bubbles || !isSelf) return@remember bodyText - val reserveChars = if (message.isPrivate && message.deliveryStatus != null) 7 else 5 - androidx.compose.ui.text.buildAnnotatedString { - append(bodyText) - append(" ".repeat(reserveChars)) - append("⁠") - } - } - if (bubbles) { BubbleTextMessageLayout( message = message, senderText = senderText, - bodyText = bubbleBodyText, + bodyText = bodyText, isSelf = isSelf, showSender = showSender, timeFormatter = timeFormatter, @@ -922,6 +910,36 @@ private fun BubbleTextMessageLayout( // opposite edge, while short ones hug their content. BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction + val density = LocalDensity.current + val textCapPx = with(density) { + (maxBubbleWidth - ChatVisualTokens.BubblePaddingHorizontal * 2 - 2.dp).toPx() + } + var bodyLayout by remember { mutableStateOf(null) } + var clusterSize by remember { mutableStateOf(IntSize.Zero) } + + // The meta cluster (timestamp, then delivery checks for own private messages) rides + // flush-right on the body's last line when that line has room for it, and drops + // below the text only when it does not. Placement is computed from the laid-out + // text, so wrapping is never influenced by the cluster: no early wraps, no slack + // carved out of the first lines, no minimum bubble width. + val metaGapPx = with(density) { 8.dp.toPx() } + val metaPlan = remember(bodyLayout, clusterSize, textCapPx) { + val layout = bodyLayout ?: return@remember null + if (layout.lineCount == 0 || clusterSize.width <= 0) return@remember null + val lastLineRight = layout.getLineRight(layout.lineCount - 1) + if (lastLineRight + metaGapPx + clusterSize.width <= textCapPx) { + BubbleMetaPlan( + widthPx = maxOf(layout.size.width.toFloat(), lastLineRight + metaGapPx + clusterSize.width), + reserveOwnLine = false, + ) + } else { + BubbleMetaPlan( + widthPx = maxOf(layout.size.width.toFloat(), clusterSize.width.toFloat()), + reserveOwnLine = true, + ) + } + } + Box( modifier = Modifier .align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart) @@ -964,7 +982,13 @@ private fun BubbleTextMessageLayout( ) } - Box { + Box( + modifier = if (isSelf && metaPlan != null) { + Modifier.width(with(density) { metaPlan!!.widthPx.toDp() }) + } else { + Modifier + } + ) { AnnotatedClickableText( text = bodyText, annotationTags = listOf("geohash_click", "url_click"), @@ -986,20 +1010,27 @@ private fun BubbleTextMessageLayout( } }, onLongPress = onLongPress, + modifier = Modifier.padding( + bottom = if (metaPlan?.reserveOwnLine == true) { + with(density) { clusterSize.height.toDp() } + } else { + 0.dp + } + ), fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface), + onTextLayout = { bodyLayout = it }, ) - // Meta cluster overlay, flush with the bubble's end edge on the body's - // last line: timestamp, then the delivery checks at a fixed gap for own - // private messages. The body reserved invisible space for it, so the - // cluster never collides with text and never adds a line unnecessarily. if (isSelf) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.BottomEnd), + modifier = Modifier + .align(Alignment.BottomEnd) + .onSizeChanged { clusterSize = it } + .graphicsLayer { alpha = if (metaPlan != null) 1f else 0f }, ) { Text( text = formatTextMessageMetadata(message, timeFormatter), @@ -1020,6 +1051,11 @@ private fun BubbleTextMessageLayout( } } +private data class BubbleMetaPlan( + val widthPx: Float, + val reserveOwnLine: Boolean, +) + @OptIn(ExperimentalFoundationApi::class) @Composable private fun CashuMessageContent( From bd5ae8702a653e7c9c313278566905dc480170ce Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:45:42 +0200 Subject: [PATCH 10/11] feat(chat): never show our own nickname in bubbles mode The end side and the author-colour wash already attribute own bubbles, so the @name heading on the first bubble of an own run was redundant. Received bubbles keep their sender names; matrix mode is unchanged. --- .../main/java/com/bitchat/android/ui/MessageComponents.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0c4af8ce..402bb395 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -960,8 +960,9 @@ private fun BubbleTextMessageLayout( ) { 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) { + // messengers, instead of floating above it. Own bubbles never show a name — + // the end side is attribution enough. Continuation bubbles skip it too. + if (showSender && !isSelf) { AnnotatedClickableText( text = senderText, annotationTags = listOf("nickname_click"), From aad0c4d7f85741377e60a8d66563782e9c3d44a9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:17:30 +0200 Subject: [PATCH 11/11] feat(settings): combine theme and chat style into one card System/Light/Dark and the chat style picker now share the single Theme card in About -> Settings: theme chips on the first row, chat style chips on the second, ordered Bubbles then Matrix. Bubbles remains the default for fresh installs. --- .../java/com/bitchat/android/ui/AboutSheet.kt | 95 ++++++++----------- app/src/main/res/values/strings.xml | 1 - 2 files changed, 42 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index d4368ec8..7b5fed3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -382,45 +382,6 @@ fun AboutSheet( Column { AboutSectionLabel(text = stringResource(R.string.about_section_theme)) val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = colorScheme.surface, - shape = AboutCardShape - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeChip( - label = stringResource(R.string.about_system), - selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.about_light), - selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.about_dark), - selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - modifier = Modifier.weight(1f) - ) - } - } - } - } - - item(key = "chat_style") { - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_chat_style)) val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() Surface( modifier = Modifier @@ -429,24 +390,52 @@ fun AboutSheet( color = colorScheme.surface, shape = AboutCardShape ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - ThemeChip( - label = stringResource(R.string.chat_ui_matrix), - selected = chatUiMode.isMatrix, - onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.chat_ui_bubbles), - selected = chatUiMode.isBubbles, - onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.chat_ui_bubbles), + selected = chatUiMode.isBubbles, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.chat_ui_matrix), + selected = chatUiMode.isMatrix, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, + modifier = Modifier.weight(1f) + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c20d534..a73b5bbb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,7 +171,6 @@ About Theme - Chat style Settings