Browse Source

Make invite links clickable in Android text with inline animated emoji

master
evgeny 1 week ago
parent
commit
68103d0aca
  1. 34
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt
  2. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt
  3. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

34
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AnimatedEmoji.kt

@ -6,6 +6,7 @@ import android.icu.text.BreakIterator
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -19,6 +20,10 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@ -103,9 +108,10 @@ fun AnimatedEmoji(emoji: String, fontSize: TextUnit, modifier: Modifier = Modifi
/** Полные графемы сохраняют модификаторы кожи и ZWJ-последовательности обычными Unicode-смайликами. */ /** Полные графемы сохраняют модификаторы кожи и ZWJ-последовательности обычными Unicode-смайликами. */
@Composable @Composable
fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier = Modifier) { fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier = Modifier,
onInviteLink: ((String) -> Unit)? = null) {
val annotated = remember(text) { val annotated = remember(text) {
buildAnnotatedString { val emojiText = buildAnnotatedString {
val iterator = BreakIterator.getCharacterInstance(Locale.US) val iterator = BreakIterator.getCharacterInstance(Locale.US)
iterator.setText(text) iterator.setText(text)
var start = iterator.first() var start = iterator.first()
@ -118,6 +124,14 @@ fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier
end = iterator.next() 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)
}
}
} }
val inline = remember(fontSize, color) { val inline = remember(fontSize, color) {
animatedEmojiAssets.values.associateWith { animatedEmojiAssets.values.associateWith {
@ -126,5 +140,19 @@ fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier
} }
} }
} }
Text(annotated, inlineContent = inline, fontSize = fontSize, color = color, modifier = modifier) var layout by remember { mutableStateOf<TextLayoutResult?>(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) {
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) }
}
}
Text(annotated, inlineContent = inline, fontSize = fontSize, color = color, modifier = linkModifier,
onTextLayout = { layout = it })
} }

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt

@ -27,7 +27,8 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
playbackState: ChatViewModel.PlaybackState, modifier: Modifier = Modifier, playbackState: ChatViewModel.PlaybackState, modifier: Modifier = Modifier,
onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit, onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit,
onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit, onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit,
onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}) { onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {},
onInviteLink: (String) -> Unit = {}) {
val background = ImageBitmap.imageResource(R.drawable.chat_background) val background = ImageBitmap.imageResource(R.drawable.chat_background)
LaunchedEffect(background) { LaunchedEffect(background) {
LogManager.addLog("DEBUG", "UI", "Chat background ready: desktop bg.png tile=${background.width}x${background.height}") LogManager.addLog("DEBUG", "UI", "Chat background ready: desktop bg.png tile=${background.width}x${background.height}")
@ -44,7 +45,7 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) { LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) {
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message -> items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message ->
MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo) MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onInviteLink)
} }
} }
} }

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -102,7 +102,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
onDownloadFile: (Message) -> Unit = {}, onDownloadFile: (Message) -> Unit = {},
onOpenFile: (Message) -> Unit = {}, onOpenFile: (Message) -> Unit = {},
onOpenImage: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {},
onPlayVideo: (Message) -> Unit = {}) { onPlayVideo: (Message) -> Unit = {},
onInviteLink: (String) -> Unit = {}) {
val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble
val textColor = ChatColors.Text val textColor = ChatColors.Text
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@ -158,7 +159,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage) isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> { else -> {
EmojiText(message.text, fontSize = 17.sp, color = textColor) EmojiText(message.text, fontSize = 17.sp, color = textColor, onInviteLink = onInviteLink)
} }
} }

Loading…
Cancel
Save