Preserve composer commit receipts and finish message batches after rotation
This commit is contained in:
1 parent
47e997df15
commit
dae19c06d7
2 files changed
+46
-19
No files matched your search
+30
-19
@@ -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<SelectedAttachment?, Any>(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<SelectedAttachment?>(null) }
|
||||
var pendingToken by rememberSaveable(target) { mutableStateOf<String?>(null) }
|
||||
var pendingTextRevision by rememberSaveable(target) { mutableStateOf(0L) }
|
||||
var pendingAttachmentId by rememberSaveable(target) { mutableStateOf<String?>(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<String?>(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}")
|
||||
|
||||
@@ -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<ChannelEdit?>(null)
|
||||
val editing: StateFlow<ChannelEdit?> = _editing
|
||||
|
||||
Reference in new issue
Block a user