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] 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(