Browse Source

android: add reply menu and preserve drafts until submission completes

master
evgeny 4 days ago
parent
commit
f72fb177ba
  1. 53
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt
  2. 48
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt

53
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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* 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.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -34,6 +37,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val activeTarget by rememberUpdatedState(target) val activeTarget by rememberUpdatedState(target)
var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } 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<SelectedAttachment?>(null) } var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) }
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) } var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) }
var preparing by remember(target) { mutableStateOf(false) } var preparing by remember(target) { mutableStateOf(false) }
@ -75,25 +87,49 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
} }
} }
Column { 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 (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)) 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, else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp)) modifier = Modifier.padding(8.dp))
key(target) { InputBar( key(target) { InputBar(
text = text, onTextChange = { text = it }, enabled = !preparing, text = text, onTextChange = { text = it; textRevision++ }, enabled = !preparing && !pending, focusRevision = reply?.revision ?: 0,
onSend = { onSend = {
if (preparing || pending) return@InputBar
val attachment = selected val attachment = selected
val video = sendAsVideo val video = sendAsVideo
selected = null val sentText = text.text
if (attachment != null) scope.launch { val revision = textRevision
val capturedReply = reply
scope.launch {
preparing = true; error = "" preparing = true; error = ""
var cache: String? = null var cache: String? = null
var attachmentSaved = attachment == null
var textSaved = sentText.isBlank()
try { try {
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) } if (attachment != null) {
val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed)) withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) }
if (video) viewModel.sendVideoMessage(path, attachment.target) val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed))
else viewModel.sendAttachment(path, attachment.target, attachment.name) val submission = if (video) viewModel.sendVideoMessage(path, attachment.target, capturedReply, clearReply = false)
cache = null // Native/backend принял собственный cache-файл. 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: CancellationException) { throw e }
catch (e: Exception) { catch (e: Exception) {
LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}") LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}")
@ -105,7 +141,6 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
preparing = false preparing = false
} }
} }
if (text.text.isNotBlank()) { onSendText(text.text); text = TextFieldValue() }
}, },
isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level, isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level,
onPttPressed = { viewModel.startVoiceRecording(target) }, onPttPressed = { viewModel.startVoiceRecording(target) },

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

@ -1,6 +1,8 @@
package com.utun.chat.ui.components package com.utun.chat.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import android.content.Intent import android.content.Intent
@ -9,6 +11,14 @@ import android.net.Uri
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -32,6 +42,7 @@ import com.utun.chat.viewmodel.ChatViewModel
/** Общий список; направление включено в ключ, поскольку PM имеет два потока seq. */ /** Общий список; направление включено в ключ, поскольку PM имеет два потока seq. */
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ConversationMessages(target: ConversationTarget, messages: List<Message>, state: LazyListState, fun ConversationMessages(target: ConversationTarget, messages: List<Message>, state: LazyListState,
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,
@ -40,8 +51,27 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
onInviteLink: (String) -> Unit = {}, reactionCatalog: List<com.utun.chat.data.ReactionState> = emptyList(), onInviteLink: (String) -> Unit = {}, reactionCatalog: List<com.utun.chat.data.ReactionState> = emptyList(),
onVote: (Message, List<Int>, String) -> Unit = { _, _, _ -> }, onVote: (Message, List<Int>, String) -> Unit = { _, _, _ -> },
onReact: (Message, Int?) -> 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<String, Message> = emptyMap(), highlightedId: String = "") {
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val haptic = LocalHapticFeedback.current
var selectedMessage by remember(target) { mutableStateOf<Message?>(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) } var openFailed by remember { mutableStateOf(false) }
val onLink: (String) -> Unit = { link -> val onLink: (String) -> Unit = { link ->
if (link.startsWith("utun://")) onInviteLink(link) if (link.startsWith("utun://")) onInviteLink(link)
@ -85,16 +115,26 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author, if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 54.4.dp) 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 { Column {
val footer: @Composable () -> Unit = { val footer: @Composable () -> Unit = {
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
MessageReactions(message, reactionCatalog, onReact) 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") 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, 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, if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,

Loading…
Cancel
Save