From 30102f5f427ea09d500c639c305505b74e278f49 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 8 Oct 2026 10:29:30 +0200 Subject: [PATCH] android: render reply quotes and support message long press --- .../utun/chat/ui/components/MessageBubble.kt | 14 ++-- .../utun/chat/ui/components/MessageReply.kt | 70 +++++++++++++++++++ .../com/utun/chat/ui/components/PollUi.kt | 3 +- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt 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 a4e35528..202eb54f 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 @@ -140,7 +140,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = onOpenImage: (Message) -> Unit = {}, onPlayVideo: (Message) -> Unit = {}, onLink: (String) -> Unit = {}, - footer: @Composable () -> Unit = {}) { + footer: @Composable () -> Unit = {}, header: @Composable () -> 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 @@ -154,7 +154,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = val isActive = isVoice && playbackState.filePath == message.filePath val emojiCount = if (isVoice || isVideo || isImage || isMedia) 0 else detectEmojiOnly(message.text) - if (emojiCount > 0) { + if (emojiCount > 0 && message.replyTo.isEmpty()) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = alignment @@ -187,6 +187,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = .padding(12.dp) ) { Column { + header() if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary) Spacer(Modifier.height(2.dp)) @@ -582,6 +583,7 @@ fun InputBar( isVideoMode: Boolean = false, onToggleMode: () -> Unit = {}, onVideoRecordOpen: () -> Unit = {}, + focusRevision: Long = 0, modifier: Modifier = Modifier ) { val ctx = LocalContext.current @@ -589,6 +591,9 @@ fun InputBar( val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current val inputFocus = remember { FocusRequester() } + LaunchedEffect(focusRevision) { + if (focusRevision > 0 && enabled && !isRecording) { inputFocus.requestFocus(); keyboard?.show() } + } var emojiPanel by remember { mutableStateOf(false) } fun closeEmojiPanel(reason: String) { if (!emojiPanel) return @@ -742,7 +747,7 @@ fun InputBar( modifier = Modifier .size(44.dp) .background(sendColor, CircleShape) - .clickable { onSend() }, + .clickable(enabled = enabled) { onSend() }, contentAlignment = Alignment.Center ) { Text("\u27A4", fontSize = 22.sp, color = MaterialTheme.colorScheme.onPrimary) @@ -754,7 +759,8 @@ fun InputBar( modifier = Modifier .size(48.dp) .background(pttBg, RoundedCornerShape(4.dp)) - .pointerInput(isVideoMode, hasMicPerm) { + .pointerInput(isVideoMode, hasMicPerm, enabled) { + if (!enabled) return@pointerInput awaitPointerEventScope { while (true) { val down = awaitFirstDown(requireUnconsumed = false) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt new file mode 100644 index 00000000..830d9b52 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageReply.kt @@ -0,0 +1,70 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.utun.chat.R +import com.utun.chat.data.Message + +/** Initial-pass позволяет открыть меню поверх ссылок, плеера и кнопок голосования. */ +@Composable +internal fun Modifier.messageLongPress(label: String, onLongPress: () -> Unit): Modifier { + val action by rememberUpdatedState(onLongPress) + return semantics { onLongClick(label) { action(); true } }.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val releasedOrMoved = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: return@withTimeoutOrNull true + if (!change.pressed || (change.position - down.position).getDistance() > viewConfiguration.touchSlop) + return@withTimeoutOrNull true + } while (true) + } + if (releasedOrMoved == null) { + action() + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } while (event.changes.any { it.pressed }) + } + } + } +} + +@Composable +fun ReplyQuote(original: Message?, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { + val preview = when { + original == null -> stringResource(R.string.reply_original_missing) + original.contentType == "audio/opus" -> stringResource(R.string.reply_voice) + original.contentType.startsWith("image/") -> stringResource(R.string.photo) + original.contentType.startsWith("video/") -> stringResource(R.string.video_message) + original.contentType == "poll" -> original.feedback?.poll?.question ?: stringResource(R.string.reply_poll) + else -> original.text.replace(Regex("\\s+"), " ").take(160) + } + Row(modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .height(IntrinsicSize.Min).padding(vertical = 4.dp)) { + Box(Modifier.width(3.dp).fillMaxHeight().background(MaterialTheme.colorScheme.primary)) + Column(Modifier.padding(horizontal = 8.dp).weight(1f)) { + Text(original?.let { if (it.isOutgoing) stringResource(R.string.you) else it.author } ?: stringResource(R.string.reply), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, + maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(preview, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt index c2ba03c5..640db833 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt @@ -56,7 +56,7 @@ fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List, Boo @Composable fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> Unit, authorName: (Long) -> String, - footer: @Composable () -> Unit = {}) { + footer: @Composable () -> Unit = {}, header: @Composable () -> Unit = {}) { var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) } val poll = message.feedback?.poll Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), @@ -66,6 +66,7 @@ fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> else MaterialTheme.colorScheme.surfaceVariant)) { MessageWithFooter(message, footer, Modifier.padding(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + header() if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium) if (poll == null) Text(stringResource(R.string.poll_pending)) else {