Fix progress and optimistic UI

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-03-20 16:27:45 +03:00
Unverified
parent da81bff9f5
commit fedfa117d7
9 changed files with 321 additions and 139 deletions
@@ -103,7 +103,7 @@ class DmAttachmentUploadWorker(
val encryptedBlob = encryptFileBlob(fileUri) val encryptedBlob = encryptFileBlob(fileUri)
if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
sendInline(recipientId, plaintext, filename, encryptedBlob) sendInline(jobId, recipientId, plaintext, filename, encryptedBlob)
} else { } else {
sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob) sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob)
} }
@@ -133,6 +133,7 @@ class DmAttachmentUploadWorker(
} }
private suspend fun sendInline( private suspend fun sendInline(
jobId: String,
recipientId: Int, recipientId: Int,
plaintext: String, plaintext: String,
filename: String, filename: String,
@@ -146,6 +147,7 @@ class DmAttachmentUploadWorker(
ApiClient.sendDm( ApiClient.sendDm(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = plaintext,
clientMessageId = jobId,
transportFiles = listOf(file) transportFiles = listOf(file)
) )
} }
@@ -185,6 +187,7 @@ class DmAttachmentUploadWorker(
ApiClient.sendDm( ApiClient.sendDm(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = plaintext,
clientMessageId = jobId,
uploadedFileIds = listOf(completed.fileId) uploadedFileIds = listOf(completed.fileId)
) )
} }
@@ -244,6 +244,7 @@ object ApiClient {
suspend fun sendDm( suspend fun sendDm(
recipientId: Int, recipientId: Int,
plaintext: String, plaintext: String,
clientMessageId: String? = null,
replyToId: Int? = null, replyToId: Int? = null,
transportFiles: List<SendDmFile> = emptyList(), transportFiles: List<SendDmFile> = emptyList(),
uploadedFileIds: List<String> = emptyList() uploadedFileIds: List<String> = emptyList()
@@ -270,6 +271,7 @@ object ApiClient {
transportCiphertextB64 = transportCipher.ciphertextB64, transportCiphertextB64 = transportCipher.ciphertextB64,
senderPublicKeyB64 = senderPublicKeyB64, senderPublicKeyB64 = senderPublicKeyB64,
recipientPublicKeyB64 = recipientPublicKey, recipientPublicKeyB64 = recipientPublicKey,
clientMessageId = clientMessageId,
replyToId = replyToId, replyToId = replyToId,
transportFiles = transportFiles, transportFiles = transportFiles,
uploadedFileIds = uploadedFileIds uploadedFileIds = uploadedFileIds
@@ -195,6 +195,7 @@ data class DmEnvelope(
@SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
val timestamp: String, val timestamp: String,
@SerialName("client_message_id") val clientMessageId: String? = null,
@SerialName("reply_to_id") val replyToId: Int? = null, @SerialName("reply_to_id") val replyToId: Int? = null,
val files: List<DmFile>? = null val files: List<DmFile>? = null
) )
@@ -237,6 +238,7 @@ data class SendDmRequest(
@SerialName("transport_ciphertext_b64") val transportCiphertextB64: String, @SerialName("transport_ciphertext_b64") val transportCiphertextB64: String,
@SerialName("sender_public_key_b64") val senderPublicKeyB64: String, @SerialName("sender_public_key_b64") val senderPublicKeyB64: String,
@SerialName("recipient_public_key_b64") val recipientPublicKeyB64: 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("reply_to_id") val replyToId: Int? = null,
@SerialName("transport_files") val transportFiles: List<SendDmFile> = emptyList(), @SerialName("transport_files") val transportFiles: List<SendDmFile> = emptyList(),
@SerialName("uploaded_file_ids") val uploadedFileIds: List<String> = emptyList() @SerialName("uploaded_file_ids") val uploadedFileIds: List<String> = emptyList()
@@ -1,12 +1,17 @@
package ru.fromchat.ui.chat 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.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.filled.AttachFile
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -175,22 +182,16 @@ fun AttachmentPreview(
) { ) {
if (!isExpanded) { if (!isExpanded) {
when { when {
isPendingImage && isImageWithThumb -> UnifiedImageContent( isPendingImage -> UnifiedImageContent(
localUri = pendingFileUri, localUri = pendingFileUri,
messageId = messageId!!, messageId = messageId,
fileIndex = fileIndex!!, fileIndex = fileIndex,
file = file, file = file,
envelope = dmEnvelope, envelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio,
onFullyLoaded = { isFullyLoaded = it }
)
isPendingImage -> PendingImageContent(
uri = pendingFileUri,
isUploading = isUploading, isUploading = isUploading,
uploadProgress = uploadProgress, uploadProgress = uploadProgress,
isImage = true onFullyLoaded = { isFullyLoaded = it }
) )
else -> DecryptedImageContent( else -> DecryptedImageContent(
messageId = messageId ?: -1, messageId = messageId ?: -1,
@@ -211,24 +212,35 @@ fun AttachmentPreview(
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun UnifiedImageContent( private fun UnifiedImageContent(
localUri: String, localUri: String,
messageId: Int, messageId: Int?,
fileIndex: Int, fileIndex: Int?,
file: DmFile, file: DmFile?,
envelope: DmEnvelope, envelope: DmEnvelope?,
currentUserId: Int?, currentUserId: Int?,
thumbnailBase64: String, isUploading: Boolean,
aspectRatio: Float?, uploadProgress: Int?,
onFullyLoaded: (Boolean) -> Unit = {} onFullyLoaded: (Boolean) -> Unit = {}
) { ) {
var cachedPath by remember(messageId, fileIndex, file.path) { var cachedPath by remember(messageId, fileIndex, file?.path) {
mutableStateOf(DecryptedImageCache.getCached(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) { LaunchedEffect(messageId, fileIndex, file?.path, envelope) {
cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) cachedPath = if (messageId != null && fileIndex != null && file != null && envelope != null) {
DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
} else {
null
}
} }
LaunchedEffect(Unit) { onFullyLoaded(true) } LaunchedEffect(Unit) { onFullyLoaded(true) }
@@ -244,35 +256,137 @@ private fun UnifiedImageContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
when { AnimatedContent(
cachedPath != null -> { targetState = isUploading,
val fullPainter = rememberAsyncImagePainter( modifier = Modifier.matchParentSize(),
model = cachedPath!!, transitionSpec = {
contentScale = ContentScale.FillWidth (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() } else {
when (fullState) { Box(modifier = Modifier.matchParentSize())
is coil3.compose.AsyncImagePainter.State.Success -> { }
LaunchedEffect(Unit) { onFullyLoaded(true) } }
val alpha = remember { Animatable(0f) } if (cachedPath != null && file != null) {
LaunchedEffect(Unit) { val fullPainter = rememberAsyncImagePainter(
alpha.animateTo(1f, animationSpec = tween(300)) model = cachedPath!!,
} contentScale = ContentScale.FillWidth
Image( )
painter = fullPainter, val fullState by fullPainter.state.collectAsState()
contentDescription = file.name, when (fullState) {
modifier = Modifier is coil3.compose.AsyncImagePainter.State.Success -> {
.fillMaxSize() LaunchedEffect(Unit) { onFullyLoaded(true) }
.alpha(alpha.value), val alpha = remember { Animatable(0f) }
contentScale = ContentScale.FillWidth LaunchedEffect(Unit) {
) alpha.animateTo(1f, animationSpec = tween(300))
}
else -> {
LaunchedEffect(Unit) { onFullyLoaded(false) }
} }
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() .matchParentSize()
.background(MaterialTheme.colorScheme.surfaceContainerHighest) .background(MaterialTheme.colorScheme.surfaceContainerHighest)
) )
if (isUploading) { AsyncImage(
Box( model = uri,
modifier = Modifier contentDescription = null,
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.clip(RoundedCornerShape(IMAGE_RADIUS)) contentScale = ContentScale.Crop
.hazeEffect(style = HazeMaterials.thin()) )
) { AnimatedContent(
AsyncImage( targetState = isUploading,
model = uri, modifier = Modifier.matchParentSize(),
contentDescription = null, transitionSpec = {
modifier = Modifier.fillMaxSize(), (fadeIn(animationSpec = tween(220)) + scaleIn(initialScale = 0.98f, animationSpec = tween(220)))
contentScale = ContentScale.Crop .togetherWith(
) fadeOut(animationSpec = tween(450, easing = FastOutSlowInEasing)) +
} scaleOut(targetScale = 1.02f, animationSpec = tween(450, easing = FastOutSlowInEasing))
} 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)
) )
} 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())
} }
} }
} }
@@ -295,6 +295,7 @@ abstract class ChatPanel(
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = "You",
client_message_id = tempId,
reply_to = replyToId?.let { replyId -> reply_to = replyToId?.let { replyId ->
_state.messages.find { it.id == replyId } _state.messages.find { it.id == replyId }
} }
@@ -479,7 +479,9 @@ fun ChatScreen(
val plaintext = text.ifBlank { "" } val plaintext = text.ifBlank { "" }
attachments.forEach { att -> attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}" 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 isImage = att.isImage
val optimisticMessage = Message( val optimisticMessage = Message(
id = tempId, id = tempId,
@@ -492,7 +494,7 @@ fun ChatScreen(
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = replyTo, reply_to = replyTo,
client_message_id = null, client_message_id = jobId,
reactions = null, reactions = null,
files = null, files = null,
pendingFileUri = att.uri, pendingFileUri = att.uri,
@@ -286,43 +286,51 @@ fun MessageItem(
} else { } else {
val firstFile = message.files?.firstOrNull() val firstFile = message.files?.firstOrNull()
val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false
val isTransitioning = message.pendingFileUri != null && firstFileIsImage && val hasPendingServerImage = message.pendingFileUri != null &&
message.dmEnvelope != null && !(message.fileThumbnails?.firstOrNull().isNullOrBlank()) firstFileIsImage &&
if (isTransitioning) { message.dmEnvelope != null
val file = firstFile if (message.pendingFileUri != null) {
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 isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false 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( AttachmentPreview(
file = null, file = pendingImageFile,
dmEnvelope = null, dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null,
currentUserId = null, currentUserId = if (pendingImageFile != null) currentUserId else null,
pendingFileUri = message.pendingFileUri, pendingFileUri = message.pendingFileUri,
pendingFilename = message.pendingFilename, pendingFilename = message.pendingFilename,
isUploading = message.uploadProgress != null, isUploading = message.uploadProgress != null,
uploadProgress = message.uploadProgress, 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, isAuthor = isAuthor,
modifier = if (isPendingImage && firstContentIsImage) { modifier = if (isPendingImage && firstContentIsImage) {
Modifier.padding(all = 2.dp) Modifier.padding(all = 2.dp)
@@ -335,7 +343,7 @@ fun MessageItem(
) )
} }
message.files?.forEachIndexed { index, file -> message.files?.forEachIndexed { index, file ->
if (isTransitioning && index == 0) return@forEachIndexed if (hasPendingServerImage && index == 0) return@forEachIndexed
val isImage = isImageFilename(file.name) val isImage = isImageFilename(file.name)
val imageKey = if (isImage) "img_${message.id}_$index" else null val imageKey = if (isImage) "img_${message.id}_$index" else null
val isFirstImage = index == 0 && isImage val isFirstImage = index == 0 && isImage
@@ -3,6 +3,8 @@ package ru.fromchat.ui.dm
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -36,6 +38,7 @@ class DmPanel(
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private var otherDisplayName: String = "User $otherUserId" private var otherDisplayName: String = "User $otherUserId"
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex()
init { init {
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) } updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
@@ -67,7 +70,12 @@ class DmPanel(
} }
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { 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() { override suspend fun loadMessages() {
@@ -152,32 +160,86 @@ class DmPanel(
}.getOrNull() ?: return }.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() dmEnvelopeMutex.withLock {
if (plaintext != null) { val alreadyExists = _state.messages.any { it.id == envelope.id }
if (envelope.senderId == currentUserId) { val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp }
if (oldestOptimistic != null) { if (alreadyExists) return@withLock
val real = createMessage(envelope, plaintext).copy(
uploadJobId = oldestOptimistic.uploadJobId, if (plaintext != null) {
pendingFileUri = oldestOptimistic.pendingFileUri, if (envelope.senderId == currentUserId) {
pendingFilename = oldestOptimistic.pendingFilename, mergeConfirmedOwnMessage(envelope, plaintext)
pendingFileAspectRatio = oldestOptimistic.pendingFileAspectRatio
)
updateMessage(oldestOptimistic.id) { real }
} else { } else {
addMessage(createMessage(envelope, plaintext)) addMessage(createMessage(envelope, plaintext))
} }
} else { if (envelope.replyToId != null) {
addMessage(createMessage(envelope, plaintext)) 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) { private fun processEditedEnvelope(element: JsonElement) {
val envelope = runCatching { val envelope = runCatching {
json.decodeFromJsonElement(DmEnvelope.serializer(), element) json.decodeFromJsonElement(DmEnvelope.serializer(), element)
@@ -257,7 +319,7 @@ class DmPanel(
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
client_message_id = null, client_message_id = envelope.clientMessageId,
reactions = null, reactions = null,
files = envelope.files, files = envelope.files,
dmEnvelope = envelope, dmEnvelope = envelope,
@@ -22,6 +22,7 @@ actual object AttachmentUploadQueue {
ApiClient.sendDm( ApiClient.sendDm(
recipientId = job.recipientId, recipientId = job.recipientId,
plaintext = job.plaintext, plaintext = job.plaintext,
clientMessageId = job.jobId,
replyToId = job.replyToId replyToId = job.replyToId
) )
}.onSuccess { }.onSuccess {