From f72fb177bacf31d0dbd5020088013346ae3aabef Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 8 Oct 2026 10:29:30 +0200 Subject: [PATCH] android: add reply menu and preserve drafts until submission completes --- .../ui/components/ConversationComposer.kt | 53 +++++++++++++++---- .../ui/components/ConversationMessages.kt | 48 +++++++++++++++-- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index fa4a3caf..21cd83bb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -10,6 +10,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.ui.Alignment import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier @@ -34,6 +37,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o val scope = rememberCoroutineScope() val activeTarget by rememberUpdatedState(target) var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var textRevision by rememberSaveable(target.nativeId) { mutableStateOf(0L) } + val replies by viewModel.replies.collectAsState() + val reply = if (target is ConversationTarget.Channel) replies[target.id] else null + val sendFailed by viewModel.messageSendFailed.collectAsState() + val submitting by viewModel.submittingChannels.collectAsState() + val pending = target is ConversationTarget.Channel && target.id in submitting + if (sendFailed) AlertDialog(onDismissRequest = viewModel::dismissMessageSendError, + text = { Text(stringResource(R.string.message_send_failed)) }, + confirmButton = { TextButton(onClick = viewModel::dismissMessageSendError) { Text(stringResource(R.string.close)) } }) var selected by remember(target) { mutableStateOf(null) } var pickerTarget by remember { mutableStateOf(null) } var preparing by remember(target) { mutableStateOf(false) } @@ -75,25 +87,49 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o } } Column { + if (reply != null) Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Row(Modifier.fillMaxWidth().padding(start = 12.dp), verticalAlignment = Alignment.CenterVertically) { + ReplyQuote(reply.message, Modifier.weight(1f)) + IconButton(onClick = { viewModel.cancelReply(reply.channelId) }) { + Icon(Icons.Default.Close, stringResource(R.string.reply_cancel)) + } + } + } if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp)) else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp)) key(target) { InputBar( - text = text, onTextChange = { text = it }, enabled = !preparing, + text = text, onTextChange = { text = it; textRevision++ }, enabled = !preparing && !pending, focusRevision = reply?.revision ?: 0, onSend = { + if (preparing || pending) return@InputBar val attachment = selected val video = sendAsVideo - selected = null - if (attachment != null) scope.launch { + val sentText = text.text + val revision = textRevision + val capturedReply = reply + scope.launch { preparing = true; error = "" var cache: String? = null + var attachmentSaved = attachment == null + var textSaved = sentText.isBlank() try { - withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) } - val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed)) - if (video) viewModel.sendVideoMessage(path, attachment.target) - else viewModel.sendAttachment(path, attachment.target, attachment.name) - cache = null // Native/backend принял собственный cache-файл. + if (attachment != null) { + withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) } + val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed)) + val submission = if (video) viewModel.sendVideoMessage(path, attachment.target, capturedReply, clearReply = false) + else viewModel.sendAttachment(path, attachment.target, attachment.name, capturedReply) + cache = null // Задача ViewModel владеет файлом до завершения отправки. + attachmentSaved = submission.await() + if (attachmentSaved && selected === attachment) selected = null + } + if (sentText.isNotBlank()) { + textSaved = if (target is ConversationTarget.Channel) + viewModel.sendChannelText(target, sentText, capturedReply).await() + else { onSendText(sentText); true } + if (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ } + } + if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply) } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}") @@ -105,7 +141,6 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o preparing = false } } - if (text.text.isNotBlank()) { onSendText(text.text); text = TextFieldValue() } }, isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level, onPttPressed = { viewModel.startVoiceRecording(target) }, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index 54ebb8de..637620c6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -1,6 +1,8 @@ package com.utun.chat.ui.components import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.* import androidx.compose.runtime.* import android.content.Intent @@ -9,6 +11,14 @@ import android.net.Uri import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment @@ -32,6 +42,7 @@ import com.utun.chat.viewmodel.ChatViewModel /** Общий список; направление включено в ключ, поскольку PM имеет два потока seq. */ @Composable +@OptIn(ExperimentalMaterial3Api::class) fun ConversationMessages(target: ConversationTarget, messages: List, state: LazyListState, playbackState: ChatViewModel.PlaybackState, modifier: Modifier = Modifier, onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit, @@ -40,8 +51,27 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st onInviteLink: (String) -> Unit = {}, reactionCatalog: List = emptyList(), onVote: (Message, List, String) -> Unit = { _, _, _ -> }, onReact: (Message, Int?) -> Unit = { _, _ -> }, - authorName: (Long) -> String = { "0x%016x".format(it) }) { + authorName: (Long) -> String = { "0x%016x".format(it) }, + onReply: (Message) -> Unit = {}, onOriginal: (String) -> Unit = {}, + originals: Map = emptyMap(), highlightedId: String = "") { val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val haptic = LocalHapticFeedback.current + var selectedMessage by remember(target) { mutableStateOf(null) } + selectedMessage?.let { selected -> + ModalBottomSheet(onDismissRequest = { selectedMessage = null }) { + ListItem(headlineContent = { Text(stringResource(R.string.reply)) }, modifier = Modifier.clickable { + selectedMessage = null + onReply(selected) + }) + if (selected.contentType == "text/plain") ListItem(headlineContent = { Text(stringResource(R.string.message_copy)) }, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(selected.text)) + selectedMessage = null + }) + Spacer(Modifier.height(16.dp)) + } + } var openFailed by remember { mutableStateOf(false) } val onLink: (String) -> Unit = { link -> if (link.startsWith("utun://")) onInviteLink(link) @@ -85,16 +115,26 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 54.4.dp) - Box(Modifier.weight(1f)) { + val actions = if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) + Modifier.messageLongPress(stringResource(R.string.message_actions)) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + selectedMessage = message + } else Modifier + Box(Modifier.weight(1f).then(actions).then(if (message.messageId == highlightedId && highlightedId.isNotEmpty()) + Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)) else Modifier)) { Column { val footer: @Composable () -> Unit = { if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) MessageReactions(message, reactionCatalog, onReact) } + val header: @Composable () -> Unit = { + if (target is ConversationTarget.Channel && message.replyTo.isNotEmpty()) + ReplyQuote(originals[message.replyTo], Modifier.fillMaxWidth()) { onOriginal(message.replyTo) } + } if (target is ConversationTarget.Channel && message.contentType == "poll") - PollMessageBubble(message, onVote, authorName, footer) + PollMessageBubble(message, onVote, authorName, footer, header) else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, - onOpenFile, onOpenImage, onPlayVideo, onLink, footer) + onOpenFile, onOpenImage, onPlayVideo, onLink, footer, header) } } if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,