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 0dd41b93..c475d080 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 @@ -26,22 +26,23 @@ import com.utun.chat.viewmodel.ChatViewModel import java.io.File import kotlinx.coroutines.* -private data class SelectedAttachment(val target: ConversationTarget, val uri: Uri, val name: String, val size: Long, val video: Boolean) +private data class SelectedAttachment(val target: ConversationTarget, val uri: Uri, val name: String, val size: Long, val video: Boolean, + val id: String = java.util.UUID.randomUUID().toString()) private val selectedAttachmentSaver = listSaver(save = { if (it == null) emptyList() else listOf(it.target.nativeId, (it.target as? ConversationTarget.Pm)?.generation ?: 0L, - it.uri.toString(), it.name, it.size, it.video) + it.uri.toString(), it.name, it.size, it.video, it.id) }, restore = { if (it.isEmpty()) null else { val id = it[0] as String val target = if (id.startsWith("dm:")) ConversationTarget.Pm(id.removePrefix("dm:"), it[1] as Long) else ConversationTarget.Channel(id) - SelectedAttachment(target, Uri.parse(it[2] as String), it[3] as String, it[4] as Long, it[5] as Boolean) + SelectedAttachment(target, Uri.parse(it[2] as String), it[3] as String, it[4] as Long, it[5] as Boolean, it[6] as String) } }) /** Общий ввод и вложения: draft/выбор URI переживают пересоздание Activity, адресат и reply фиксируются до подготовки. * Экран владеет cache до передачи задачи ViewModel; отменённое копирование очищается в NonCancellable. - * После ухода с экрана coroutine UI отменяется, уже переданная задача остаётся у ViewModel. */ + * ViewModel продолжает пакет «вложение + текст» после ухода UI; token/revision очищают только подтверждённый прежний draft. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, @@ -66,12 +67,27 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, } val sendFailed by viewModel.messageSendFailed.collectAsState() val submitting by viewModel.submittingChannels.collectAsState() + val batchStates by viewModel.composerSubmissions.states.collectAsState() + val batch = batchStates[target] val deletingDm by viewModel.dmDeleting.collectAsState() - val pending = target.nativeId in submitting || (target is ConversationTarget.Pm && target.id in deletingDm) + val pending = batch?.finished == false || target.nativeId in submitting || (target is ConversationTarget.Pm && target.id in deletingDm) 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 rememberSaveable(target, stateSaver = selectedAttachmentSaver) { mutableStateOf(null) } + var pendingToken by rememberSaveable(target) { mutableStateOf(null) } + var pendingTextRevision by rememberSaveable(target) { mutableStateOf(0L) } + var pendingAttachmentId by rememberSaveable(target) { mutableStateOf(null) } + LaunchedEffect(batch, pendingToken) { + if (batch != null && batch.token == pendingToken) { + if (batch.attachmentSaved == true && selected?.id == pendingAttachmentId) selected = null + if (batch.textSaved == true && textRevision == pendingTextRevision) { text = TextFieldValue(); textRevision++ } + if (batch.finished) { + viewModel.composerSubmissions.consume(target, batch.token) + pendingToken = null; pendingAttachmentId = null + } + } + } var pickerId by rememberSaveable { mutableStateOf(null) } var pickerGeneration by rememberSaveable { mutableStateOf(0L) } var preparing by remember(target) { mutableStateOf(false) } @@ -166,28 +182,23 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, val sentText = text.text val revision = textRevision val capturedReply = reply + if (attachment == null && sentText.isBlank()) return@InputBar preparing = true; error = null scope.launch { var cache: String? = null - var attachmentSaved = attachment == null - var textSaved = sentText.isBlank() try { if (attachment != null) { withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) } - val path = cache ?: throw IllegalStateException("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 + checkNotNull(cache) { "Attachment copy failed" } } - if (sentText.isNotBlank()) { - textSaved = if (target is ConversationTarget.Channel) - viewModel.sendChannelText(target, sentText, capturedReply).await() - else viewModel.sendDmText(target as ConversationTarget.Pm, sentText).await() - if (textSaved && textRevision == revision) { text = TextFieldValue(); textRevision++ } + val token = java.util.UUID.randomUUID().toString() + pendingToken = token; pendingTextRevision = revision; pendingAttachmentId = attachment?.id + if (viewModel.sendConversation(target, token, sentText, cache, attachment?.name.orEmpty(), video, capturedReply)) { + cache = null // ViewModel приняла весь пакет и владеет cache. + } else { + pendingToken = null; pendingAttachmentId = null + error = R.string.attachment_send_failed } - if (attachmentSaved && textSaved) viewModel.finishReply(capturedReply) } catch (e: CancellationException) { throw e } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 11ab26e1..5b9d5870 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -30,6 +30,22 @@ import org.json.JSONArray private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") class ChatViewModel : ViewModel() { + val composerSubmissions = ConversationSubmissions(viewModelScope) + + /** После приёма ViewModel владеет cache и обеими частями отправки, даже если Activity уничтожена. */ + fun sendConversation(target: ConversationTarget, token: String, text: String, filePath: String?, name: String, + video: Boolean, reply: ChannelReply?): Boolean { + val attachmentTask: (suspend () -> Boolean)? = filePath?.let { path -> + { if (video) sendVideoMessage(path, target, reply, clearReply = false).await() + else sendAttachment(path, target, name, reply).await() } + } + val textTask: (suspend () -> Boolean)? = if (text.isBlank()) null else { + { if (target is ConversationTarget.Channel) sendChannelText(target, text, reply).await() + else sendDmText(target as ConversationTarget.Pm, text).await() } + } + return composerSubmissions.submit(target, token, attachmentTask, textTask, { finishReply(reply) }, { _messageSendFailed.value = true }) + } + data class ChannelEdit(val channelId: String, val message: Message, val revision: Long) private val _editing = MutableStateFlow(null) val editing: StateFlow = _editing