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)
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user