mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix progress and optimistic UI
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user