android: add reply menu and preserve drafts until submission completes
This commit is contained in:
1 parent
30102f5f42
commit
f72fb177ba
2 files changed
+88
-13
No files matched your search
+44
-9
@@ -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<SelectedAttachment?>(null) }
|
||||
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(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) },
|
||||
|
||||
+44
-4
@@ -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<Message>, 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<Message>, st
|
||||
onInviteLink: (String) -> Unit = {}, reactionCatalog: List<com.utun.chat.data.ReactionState> = emptyList(),
|
||||
onVote: (Message, List<Int>, 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<String, Message> = emptyMap(), highlightedId: String = "") {
|
||||
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) }
|
||||
val onLink: (String) -> Unit = { 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) {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user