diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt index f038f14..05d97f5 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt @@ -103,7 +103,7 @@ class DmAttachmentUploadWorker( val encryptedBlob = encryptFileBlob(fileUri) if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { - sendInline(recipientId, plaintext, filename, encryptedBlob) + sendInline(jobId, recipientId, plaintext, filename, encryptedBlob) } else { sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob) } @@ -133,6 +133,7 @@ class DmAttachmentUploadWorker( } private suspend fun sendInline( + jobId: String, recipientId: Int, plaintext: String, filename: String, @@ -146,6 +147,7 @@ class DmAttachmentUploadWorker( ApiClient.sendDm( recipientId = recipientId, plaintext = plaintext, + clientMessageId = jobId, transportFiles = listOf(file) ) } @@ -185,6 +187,7 @@ class DmAttachmentUploadWorker( ApiClient.sendDm( recipientId = recipientId, plaintext = plaintext, + clientMessageId = jobId, uploadedFileIds = listOf(completed.fileId) ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 3c2547a..7700848 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -244,6 +244,7 @@ object ApiClient { suspend fun sendDm( recipientId: Int, plaintext: String, + clientMessageId: String? = null, replyToId: Int? = null, transportFiles: List = emptyList(), uploadedFileIds: List = emptyList() @@ -270,6 +271,7 @@ object ApiClient { transportCiphertextB64 = transportCipher.ciphertextB64, senderPublicKeyB64 = senderPublicKeyB64, recipientPublicKeyB64 = recipientPublicKey, + clientMessageId = clientMessageId, replyToId = replyToId, transportFiles = transportFiles, uploadedFileIds = uploadedFileIds diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index c941c4a..aef3997 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -195,6 +195,7 @@ data class DmEnvelope( @SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, val timestamp: String, + @SerialName("client_message_id") val clientMessageId: String? = null, @SerialName("reply_to_id") val replyToId: Int? = null, val files: List? = null ) @@ -237,6 +238,7 @@ data class SendDmRequest( @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String, + @SerialName("client_message_id") val clientMessageId: String? = null, @SerialName("reply_to_id") val replyToId: Int? = null, @SerialName("transport_files") val transportFiles: List = emptyList(), @SerialName("uploaded_file_ids") val uploadedFileIds: List = emptyList() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 6672c80..2313060 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -1,12 +1,17 @@ package ru.fromchat.ui.chat -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,7 +31,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.rounded.Download import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -175,22 +182,16 @@ fun AttachmentPreview( ) { if (!isExpanded) { when { - isPendingImage && isImageWithThumb -> UnifiedImageContent( + isPendingImage -> UnifiedImageContent( localUri = pendingFileUri, - messageId = messageId!!, - fileIndex = fileIndex!!, + messageId = messageId, + fileIndex = fileIndex, file = file, envelope = dmEnvelope, currentUserId = currentUserId, - thumbnailBase64 = fileThumbnail, - aspectRatio = fileAspectRatio, - onFullyLoaded = { isFullyLoaded = it } - ) - isPendingImage -> PendingImageContent( - uri = pendingFileUri, isUploading = isUploading, uploadProgress = uploadProgress, - isImage = true + onFullyLoaded = { isFullyLoaded = it } ) else -> DecryptedImageContent( messageId = messageId ?: -1, @@ -211,24 +212,35 @@ fun AttachmentPreview( } } +@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun UnifiedImageContent( localUri: String, - messageId: Int, - fileIndex: Int, - file: DmFile, - envelope: DmEnvelope, + messageId: Int?, + fileIndex: Int?, + file: DmFile?, + envelope: DmEnvelope?, currentUserId: Int?, - thumbnailBase64: String, - aspectRatio: Float?, + isUploading: Boolean, + uploadProgress: Int?, onFullyLoaded: (Boolean) -> Unit = {} ) { - var cachedPath by remember(messageId, fileIndex, file.path) { - mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path)) + var cachedPath by remember(messageId, fileIndex, file?.path) { + mutableStateOf( + if (messageId != null && fileIndex != null && file != null) { + DecryptedImageCache.getCached(messageId, fileIndex, file.path) + } else { + null + } + ) } - LaunchedEffect(messageId, fileIndex, file.path, envelope) { - cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) + LaunchedEffect(messageId, fileIndex, file?.path, envelope) { + cachedPath = if (messageId != null && fileIndex != null && file != null && envelope != null) { + DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) + } else { + null + } } LaunchedEffect(Unit) { onFullyLoaded(true) } @@ -244,35 +256,137 @@ private fun UnifiedImageContent( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) - when { - cachedPath != null -> { - val fullPainter = rememberAsyncImagePainter( - model = cachedPath!!, - contentScale = ContentScale.FillWidth + AnimatedContent( + targetState = isUploading, + modifier = Modifier.matchParentSize(), + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + scaleIn(initialScale = 0.98f, animationSpec = tween(220))) + .togetherWith( + fadeOut(animationSpec = tween(450, easing = FastOutSlowInEasing)) + + scaleOut(targetScale = 1.02f, animationSpec = tween(450, easing = FastOutSlowInEasing)) + ) + }, + label = "upload_overlay" + ) { uploading -> + if (uploading) { + UploadingImageOverlay( + model = localUri, + uploadProgress = uploadProgress, + modifier = Modifier.matchParentSize() ) - val fullState by fullPainter.state.collectAsState() - when (fullState) { - is coil3.compose.AsyncImagePainter.State.Success -> { - LaunchedEffect(Unit) { onFullyLoaded(true) } - val alpha = remember { Animatable(0f) } - LaunchedEffect(Unit) { - alpha.animateTo(1f, animationSpec = tween(300)) - } - Image( - painter = fullPainter, - contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .alpha(alpha.value), - contentScale = ContentScale.FillWidth - ) - } - else -> { - LaunchedEffect(Unit) { onFullyLoaded(false) } + } else { + Box(modifier = Modifier.matchParentSize()) + } + } + if (cachedPath != null && file != null) { + val fullPainter = rememberAsyncImagePainter( + model = cachedPath!!, + contentScale = ContentScale.FillWidth + ) + val fullState by fullPainter.state.collectAsState() + when (fullState) { + is coil3.compose.AsyncImagePainter.State.Success -> { + LaunchedEffect(Unit) { onFullyLoaded(true) } + val alpha = remember { Animatable(0f) } + LaunchedEffect(Unit) { + alpha.animateTo(1f, animationSpec = tween(300)) } + Image( + painter = fullPainter, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .alpha(alpha.value), + contentScale = ContentScale.FillWidth + ) + } + else -> { + LaunchedEffect(Unit) { onFullyLoaded(false) } } } - else -> LaunchedEffect(Unit) { onFullyLoaded(false) } + } + } +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun UploadingImageOverlay( + model: String, + uploadProgress: Int?, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .hazeEffect(style = HazeMaterials.thin()) + ) { + AsyncImage( + model = model, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + Box( + modifier = Modifier + .matchParentSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + ExpressiveUploadIndicator( + uploadProgress = uploadProgress, + modifier = Modifier.size(56.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ExpressiveUploadIndicator( + uploadProgress: Int?, + modifier: Modifier = Modifier +) { + val clampedProgress = uploadProgress?.coerceIn(0, 100) + val animatedProgress by animateFloatAsState( + targetValue = (clampedProgress ?: 0) / 100f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessVeryLow, + visibilityThreshold = 1 / 1000f + ), + label = "uploadProgress" + ) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.82f)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (clampedProgress != null) { + LoadingIndicator( + progress = { animatedProgress }, + modifier = modifier, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "$clampedProgress%", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } else { + LoadingIndicator( + modifier = modifier, + color = MaterialTheme.colorScheme.primary + ) + } } } } @@ -296,45 +410,32 @@ private fun PendingImageContent( .matchParentSize() .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) - if (isUploading) { - Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .hazeEffect(style = HazeMaterials.thin()) - ) { - AsyncImage( - model = uri, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - } else { - AsyncImage( - model = uri, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - AnimatedVisibility( - visible = isUploading, - enter = fadeIn(animationSpec = tween(150)), - exit = fadeOut(animationSpec = tween(300)) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - if (uploadProgress != null) { - DeterminateCircularProgress( - progress = uploadProgress, - modifier = Modifier.size(32.dp) + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + AnimatedContent( + targetState = isUploading, + modifier = Modifier.matchParentSize(), + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + scaleIn(initialScale = 0.98f, animationSpec = tween(220))) + .togetherWith( + fadeOut(animationSpec = tween(450, easing = FastOutSlowInEasing)) + + scaleOut(targetScale = 1.02f, animationSpec = tween(450, easing = FastOutSlowInEasing)) ) - } else { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } + }, + label = "upload_state" + ) { uploading -> + if (uploading) { + UploadingImageOverlay( + model = uri, + uploadProgress = uploadProgress, + modifier = Modifier.matchParentSize() + ) + } else { + Box(modifier = Modifier.matchParentSize()) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 64f62e5..7140f28 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -295,6 +295,7 @@ abstract class ChatPanel( is_read = false, is_edited = false, username = "You", + client_message_id = tempId, reply_to = replyToId?.let { replyId -> _state.messages.find { it.id == replyId } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index a4c5913..9737036 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -479,7 +479,9 @@ fun ChatScreen( val plaintext = text.ifBlank { "" } attachments.forEach { att -> val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}" - val tempId = -jobId.hashCode().let { if (it == 0) -1 else it } + val hc = jobId.hashCode() + val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else kotlin.math.abs(hc) + val tempId = -(absHc.let { if (it == 0) 1 else it }) val isImage = att.isImage val optimisticMessage = Message( id = tempId, @@ -492,7 +494,7 @@ fun ChatScreen( profile_picture = null, verified = null, reply_to = replyTo, - client_message_id = null, + client_message_id = jobId, reactions = null, files = null, pendingFileUri = att.uri, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index d681169..14a2922 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -286,43 +286,51 @@ fun MessageItem( } else { val firstFile = message.files?.firstOrNull() val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false - val isTransitioning = message.pendingFileUri != null && firstFileIsImage && - message.dmEnvelope != null && !(message.fileThumbnails?.firstOrNull().isNullOrBlank()) - if (isTransitioning) { - val file = firstFile - val imageKey = "img_${message.id}_0" - AttachmentPreview( - file = file, - dmEnvelope = message.dmEnvelope, - currentUserId = currentUserId, - pendingFileUri = message.pendingFileUri, - pendingFilename = message.pendingFilename, - isUploading = false, - uploadProgress = null, - fileThumbnail = message.fileThumbnails.first().takeIf { it.isNotBlank() }, - fileAspectRatio = message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } - ?: message.pendingFileAspectRatio, - fileSizeBytes = message.fileSizes?.firstOrNull(), - messageId = message.id, - fileIndex = 0, - onFileClick = null, - onImageClick = { onImageClick?.invoke(message, 0) }, - onImageBounds = if (onImageBounds != null) { rect -> onImageBounds.invoke(imageKey, rect) } else null, - isExpanded = expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, - isAuthor = isAuthor, - modifier = Modifier.padding(all = 2.dp) - ) - } else if (message.pendingFileUri != null) { + val hasPendingServerImage = message.pendingFileUri != null && + firstFileIsImage && + message.dmEnvelope != null + if (message.pendingFileUri != null) { val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false + val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage } + val imageKey = if (isPendingImage) "img_${message.id}_0" else null AttachmentPreview( - file = null, - dmEnvelope = null, - currentUserId = null, + file = pendingImageFile, + dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null, + currentUserId = if (pendingImageFile != null) currentUserId else null, pendingFileUri = message.pendingFileUri, pendingFilename = message.pendingFilename, isUploading = message.uploadProgress != null, uploadProgress = message.uploadProgress, - fileAspectRatio = message.pendingFileAspectRatio, + fileThumbnail = if (pendingImageFile != null) { + message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() } + } else { + null + }, + fileAspectRatio = if (pendingImageFile != null) { + message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } + ?: message.pendingFileAspectRatio + } else { + message.pendingFileAspectRatio + }, + fileSizeBytes = if (pendingImageFile != null) message.fileSizes?.firstOrNull() else null, + messageId = if (pendingImageFile != null && isPendingImage) message.id else null, + fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, + onFileClick = null, + onImageClick = if (isPendingImage && imageKey != null) { + { onImageClick?.invoke(message, 0) } + } else { + null + }, + onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) { + { rect -> onImageBounds.invoke(imageKey, rect) } + } else { + null + }, + isExpanded = isPendingImage && + imageKey != null && + expandedImageKey != null && + expandedImageKey == imageKey && + !isImageClosing, isAuthor = isAuthor, modifier = if (isPendingImage && firstContentIsImage) { Modifier.padding(all = 2.dp) @@ -335,7 +343,7 @@ fun MessageItem( ) } message.files?.forEachIndexed { index, file -> - if (isTransitioning && index == 0) return@forEachIndexed + if (hasPendingServerImage && index == 0) return@forEachIndexed val isImage = isImageFilename(file.name) val imageKey = if (isImage) "img_${message.id}_$index" else null val isFirstImage = index == 0 && isImage diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 69e610c..b4d9eaf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -3,6 +3,8 @@ package ru.fromchat.ui.dm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -36,6 +38,7 @@ class DmPanel( private val json = Json { ignoreUnknownKeys = true } private var otherDisplayName: String = "User $otherUserId" private var otherProfilePicture: String? = null + private val dmEnvelopeMutex = Mutex() init { updateState { it.copy(title = "Direct message", profileUserId = otherUserId) } @@ -67,7 +70,12 @@ class DmPanel( } override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { - ApiClient.sendDm(recipientId = otherUserId, plaintext = content, replyToId = replyToId) + ApiClient.sendDm( + recipientId = otherUserId, + plaintext = content, + clientMessageId = clientMessageId, + replyToId = replyToId + ) } override suspend fun loadMessages() { @@ -152,32 +160,86 @@ class DmPanel( }.getOrNull() ?: return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return scope.launch(Dispatchers.Default) { - val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - if (plaintext != null) { - if (envelope.senderId == currentUserId) { - val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp } - if (oldestOptimistic != null) { - val real = createMessage(envelope, plaintext).copy( - uploadJobId = oldestOptimistic.uploadJobId, - pendingFileUri = oldestOptimistic.pendingFileUri, - pendingFilename = oldestOptimistic.pendingFilename, - pendingFileAspectRatio = oldestOptimistic.pendingFileAspectRatio - ) - updateMessage(oldestOptimistic.id) { real } + dmEnvelopeMutex.withLock { + val alreadyExists = _state.messages.any { it.id == envelope.id } + val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + + if (alreadyExists) return@withLock + + if (plaintext != null) { + if (envelope.senderId == currentUserId) { + mergeConfirmedOwnMessage(envelope, plaintext) } else { addMessage(createMessage(envelope, plaintext)) } - } else { - addMessage(createMessage(envelope, plaintext)) - } - if (envelope.replyToId != null) { - val replyTo = _state.messages.find { it.id == envelope.replyToId } - updateMessage(envelope.id) { it.copy(reply_to = replyTo) } + if (envelope.replyToId != null) { + val replyTo = _state.messages.find { it.id == envelope.replyToId } + updateMessage(envelope.id) { it.copy(reply_to = replyTo) } + } } } } } + private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String) { + val confirmed = createMessage(envelope, plaintext) + val hasAttachments = !envelope.files.isNullOrEmpty() + + updateState { currentState -> + val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id } + val exactOptimisticIndex = currentState.messages.indexOfFirst { message -> + message.user_id == currentUserId && + message.pendingFileUri != null && + envelope.clientMessageId != null && + (message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId) + } + val optimisticIndex = if (exactOptimisticIndex >= 0) { + exactOptimisticIndex + } else { + currentState.messages.indexOfFirst { message -> + message.id < 0 && + message.user_id == currentUserId && + (message.pendingFileUri != null) == hasAttachments + } + } + + val stateSource = when { + optimisticIndex >= 0 -> currentState.messages[optimisticIndex] + existingRealIndex >= 0 -> currentState.messages[existingRealIndex] + else -> null + } + + val merged = confirmed.copy( + uploadJobId = stateSource?.uploadJobId, + pendingFileUri = stateSource?.pendingFileUri, + pendingFilename = stateSource?.pendingFilename, + pendingFileAspectRatio = stateSource?.pendingFileAspectRatio, + uploadProgress = stateSource?.uploadProgress + ) + + val newMessages = when { + optimisticIndex >= 0 -> { + currentState.messages.mapIndexedNotNull { index, message -> + when { + index == optimisticIndex -> merged + message.id == envelope.id -> null + else -> message + } + } + } + + existingRealIndex >= 0 -> { + currentState.messages.mapIndexed { index, message -> + if (index == existingRealIndex) merged else message + } + } + + else -> currentState.messages + merged + } + currentState.copy(messages = newMessages) + } + } + private fun processEditedEnvelope(element: JsonElement) { val envelope = runCatching { json.decodeFromJsonElement(DmEnvelope.serializer(), element) @@ -257,7 +319,7 @@ class DmPanel( profile_picture = null, verified = null, reply_to = null, - client_message_id = null, + client_message_id = envelope.clientMessageId, reactions = null, files = envelope.files, dmEnvelope = envelope, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt index 10c06af..e191729 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt @@ -22,6 +22,7 @@ actual object AttachmentUploadQueue { ApiClient.sendDm( recipientId = job.recipientId, plaintext = job.plaintext, + clientMessageId = job.jobId, replyToId = job.replyToId ) }.onSuccess {