From c191b777b7cf60e3cb4d3ae9c2c4090eacac7a97 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 4 Oct 2026 19:06:44 +0200 Subject: [PATCH] Annotate Android web links without altering addresses containing emoji --- .../utun/chat/ui/components/AnimatedEmoji.kt | 55 +++++++++++-------- .../utun/chat/ui/components/MessageBubble.kt | 6 +- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt index 7a934475..b05b1f94 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.TextLayoutResult @@ -33,6 +34,7 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieAnimatable import com.utun.chat.data.LogManager +import com.utun.chat.data.MessageLinks import java.util.Locale import kotlin.math.ceil @@ -106,31 +108,37 @@ fun AnimatedEmoji(emoji: String, fontSize: TextUnit, modifier: Modifier = Modifi } } +/** Преобразовать только обычный текст: адреса остаются точными, без emoji placeholders. */ +private fun AnnotatedString.Builder.appendEmojiText(text: String) { + val iterator = BreakIterator.getCharacterInstance(Locale.US) + iterator.setText(text) + var start = iterator.first() + var end = iterator.next() + while (end != BreakIterator.DONE) { + val cluster = text.substring(start, end) + val asset = animationAsset(cluster) + if (asset == null) append(cluster) else appendInlineContent(asset, cluster) + start = end + end = iterator.next() + } +} + /** Полные графемы сохраняют модификаторы кожи и ZWJ-последовательности обычными Unicode-смайликами. */ @Composable fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier = Modifier, - onInviteLink: ((String) -> Unit)? = null) { + onLink: ((String) -> Unit)? = null) { val annotated = remember(text) { - val emojiText = buildAnnotatedString { - val iterator = BreakIterator.getCharacterInstance(Locale.US) - iterator.setText(text) - var start = iterator.first() - var end = iterator.next() - while (end != BreakIterator.DONE) { - val cluster = text.substring(start, end) - val asset = animationAsset(cluster) - if (asset == null) append(cluster) else appendInlineContent(asset, cluster) - start = end - end = iterator.next() - } - } buildAnnotatedString { - append(emojiText) - Regex("utun://[A-Za-z0-9+/]+={0,2}").findAll(emojiText.text).forEach { match -> - addStringAnnotation("invite", match.value, match.range.first, match.range.last + 1) - addStyle(SpanStyle(color = Color(0xFF3390EC), textDecoration = TextDecoration.Underline), - match.range.first, match.range.last + 1) + var position = 0 + for (link in MessageLinks.parse(text)) { + appendEmojiText(text.substring(position, link.start)) + val start = length + append(link.url) + addStringAnnotation("link", link.url, start, length) + addStyle(SpanStyle(color = Color(0xFF3390EC), textDecoration = TextDecoration.Underline), start, length) + position = link.end } + appendEmojiText(text.substring(position)) } } val inline = remember(fontSize, color) { @@ -141,16 +149,17 @@ fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier } } var layout by remember { mutableStateOf(null) } - val callback by rememberUpdatedState(onInviteLink) - val links = annotated.getStringAnnotations("invite", 0, annotated.length) - val linkModifier = if (onInviteLink == null || links.isEmpty()) modifier else modifier.pointerInput(annotated) { + val callback by rememberUpdatedState(onLink) + val links = annotated.getStringAnnotations("link", 0, annotated.length) + val linkModifier = if (onLink == null || links.isEmpty()) modifier else modifier.pointerInput(annotated) { detectTapGestures { point -> val result = layout ?: return@detectTapGestures val line = result.getLineForVerticalPosition(point.y) if (point.y < 0 || point.y >= result.size.height || point.x < result.getLineLeft(line) || point.x >= result.getLineRight(line)) return@detectTapGestures val offset = result.getOffsetForPosition(point) - annotated.getStringAnnotations("invite", offset, offset).firstOrNull()?.let { callback?.invoke(it.item) } + annotated.getStringAnnotations("link", offset, offset).firstOrNull { offset >= it.start && offset < it.end } + ?.let { callback?.invoke(it.item) } } } Text(annotated, inlineContent = inline, fontSize = fontSize, color = color, modifier = linkModifier, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 7c26e8d6..bb1acbaa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -109,7 +109,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = onOpenFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}, onPlayVideo: (Message) -> Unit = {}, - onInviteLink: (String) -> Unit = {}) { + onLink: (String) -> Unit = {}) { val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble val textColor = ChatColors.Text val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start @@ -170,7 +170,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = Text(message.text, fontSize = 17.sp, color = Color(0xFF3390EC), textDecoration = TextDecoration.Underline, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth().combinedClickable( - onClick = { onInviteLink(message.text) }, + onClick = { onLink(message.text) }, onLongClickLabel = stringResource(R.string.copy_link), onLongClick = { clipboard.setText(AnnotatedString(message.text)) @@ -182,7 +182,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) else -> { - EmojiText(message.text, fontSize = 17.sp, color = textColor, onInviteLink = onInviteLink) + EmojiText(message.text, fontSize = 17.sp, color = textColor, onLink = onLink) } }