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)
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)
)
}
@@ -244,6 +244,7 @@ object ApiClient {
suspend fun sendDm(
recipientId: Int,
plaintext: String,
clientMessageId: String? = null,
replyToId: Int? = null,
transportFiles: List<SendDmFile> = emptyList(),
uploadedFileIds: List<String> = emptyList()
@@ -270,6 +271,7 @@ object ApiClient {
transportCiphertextB64 = transportCipher.ciphertextB64,
senderPublicKeyB64 = senderPublicKeyB64,
recipientPublicKeyB64 = recipientPublicKey,
clientMessageId = clientMessageId,
replyToId = replyToId,
transportFiles = transportFiles,
uploadedFileIds = uploadedFileIds
@@ -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<DmFile>? = 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<SendDmFile> = emptyList(),
@SerialName("uploaded_file_ids") val uploadedFileIds: List<String> = emptyList()
@@ -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())
}
}
}
@@ -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 }
}
@@ -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,
@@ -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
@@ -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,
@@ -22,6 +22,7 @@ actual object AttachmentUploadQueue {
ApiClient.sendDm(
recipientId = job.recipientId,
plaintext = job.plaintext,
clientMessageId = job.jobId,
replyToId = job.replyToId
)
}.onSuccess {