From ea2529f26546149dc813c7fe4b0bf3bacdcc1b87 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 15 Feb 2026 15:58:08 +0300 Subject: [PATCH] Implement thumbnails Signed-off-by: denis0001-dev --- app/shared/build.gradle.kts | 1 - .../kotlin/ru/fromchat/api/ApiClient.kt | 13 +- .../kotlin/ru/fromchat/api/Models.kt | 6 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 172 +++++++++++------- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 18 ++ .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 4 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 63 ++++++- gradle/libs.versions.toml | 2 +- 8 files changed, 192 insertions(+), 87 deletions(-) diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index cdf7639..4b6d8ff 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -40,7 +40,6 @@ kotlin { implementation(libs.ktor.client.okhttp) implementation(libs.androidx.activity.compose) implementation(libs.androidx.work.runtime.ktx) - // NaCl box implementation for transport encryption (Android/JVM only) implementation("org.purejava:tweetnacl-java:1.1.3") } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index e2718fc..3c2547a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -334,10 +334,9 @@ object ApiClient { /** * Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg". * Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api. - * @param thumb if true, appends ?thumb=1 for thumbnail (JPEG, ~5 KB) */ - suspend fun fetchEncryptedFile(path: String, thumb: Boolean = false): ByteArray { - val baseUrl = when { + suspend fun fetchEncryptedFile(path: String): ByteArray { + val url = when { path.startsWith("http") -> path path.startsWith("/api") -> { val serverBase = Config.apiBaseUrl.removeSuffix("/api") @@ -345,17 +344,9 @@ object ApiClient { } else -> "${Config.apiBaseUrl}$path" } - val url = if (thumb) "$baseUrl?thumb=1" else baseUrl return http.get(url).body() } - /** - * Fetch thumbnail for an image file. Returns null if thumbnail not available (404). - */ - suspend fun fetchThumbnail(path: String): ByteArray? = runCatching { - fetchEncryptedFile(path, thumb = true) - }.getOrNull() - /** * Edit an existing direct message using the same transport encryption scheme as /dm/send. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 48298a5..612c22d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -101,7 +101,11 @@ data class Message( /** For optimistic UI: 0-100 upload progress, null when complete. */ val uploadProgress: Int? = null, /** For DM file decryption; not serialized over network. */ - @kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null + @kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null, + /** Blurhashes for image files (by index); from decrypted message JSON. */ + @kotlinx.serialization.Transient val fileThumbnails: List? = null, + /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ + @kotlinx.serialization.Transient val fileAspectRatios: List? = null ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 1fde985..b244c7c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -1,16 +1,19 @@ package ru.fromchat.ui.chat import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile @@ -21,26 +24,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import ru.fromchat.api.ApiClient import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmFile +import ru.fromchat.core.Logger import ru.fromchat.crypto.decryptFile private val IMAGE_SIZE = 160.dp @@ -57,6 +60,8 @@ fun AttachmentPreview( currentUserId: Int?, pendingFileUri: String?, isUploading: Boolean, + fileThumbnail: String? = null, + fileAspectRatio: Float? = null, modifier: Modifier = Modifier ) { val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let { @@ -65,32 +70,45 @@ fun AttachmentPreview( it.endsWith(".gif", true) || it.endsWith(".webp", true) } ?: false + val baseModifier = modifier + .then( + if (fileAspectRatio != null && fileAspectRatio > 0f) { + Modifier.aspectRatio(fileAspectRatio).sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) + } else { + Modifier.size(IMAGE_SIZE) + } + ) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .background(MaterialTheme.colorScheme.surfaceVariant) Box( - modifier = modifier - .size(IMAGE_SIZE) - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .background(MaterialTheme.colorScheme.surfaceVariant), + modifier = baseModifier, contentAlignment = Alignment.Center ) { when { pendingFileUri != null -> { + Logger.d("AttachmentPreview", "Rendering: pendingFileUri, isUploading=$isUploading") PendingImageContent( uri = pendingFileUri, isUploading = isUploading, isImage = isImage ) } - file != null && isImage && dmEnvelope != null -> { + file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() -> { + Logger.d("AttachmentPreview", "Rendering: DecryptedImageContent file=${file.name} thumbLen=${fileThumbnail.length} aspectRatio=$fileAspectRatio") DecryptedImageContent( file = file, envelope = dmEnvelope, - currentUserId = currentUserId + currentUserId = currentUserId, + thumbnailBase64 = fileThumbnail, + aspectRatio = fileAspectRatio ) } file != null && !isImage -> { + Logger.d("AttachmentPreview", "Rendering: FileIconContent file=${file.name}") FileIconContent(filename = file.name) } else -> { + Logger.d("AttachmentPreview", "Rendering: fallback Icon (file=$file, isImage=$isImage, hasEnvelope=${dmEnvelope != null}, thumbBlank=${fileThumbnail.isNullOrBlank()})") Icon( imageVector = Icons.Default.Image, contentDescription = null, @@ -165,65 +183,95 @@ private fun InfiniteCircularProgress() { private fun DecryptedImageContent( file: DmFile, envelope: DmEnvelope, - currentUserId: Int? + currentUserId: Int?, + thumbnailBase64: String, + aspectRatio: Float? ) { - var thumbnailBytes by remember(file.path) { mutableStateOf(null) } var fullBytes by remember(file.path) { mutableStateOf(null) } - var imageReadyToUnblur by remember(file.path) { mutableStateOf(false) } + val thumbnailBytes = remember(thumbnailBase64) { + runCatching { Base64.decode(thumbnailBase64) }.getOrNull() + } LaunchedEffect(file.path) { + Logger.d("AttachmentPreview", "DecryptedImageContent: fetching full image path=${file.path}") withContext(Dispatchers.Default) { - coroutineScope { - val thumbDeferred = async { - ApiClient.fetchThumbnail(file.path) - } - val fullDeferred = async { - runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull() - } - thumbnailBytes = thumbDeferred.await() - fullBytes = fullDeferred.await() - } + fullBytes = runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull() + Logger.d("AttachmentPreview", "DecryptedImageContent: full image fetch done path=${file.path} success=${fullBytes != null} size=${fullBytes?.size ?: 0}") } } - val hasThumb = thumbnailBytes != null - val hasFull = fullBytes != null - val showContent = hasThumb || hasFull - - if (!showContent) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - return - } - - LaunchedEffect(hasFull) { - if (hasFull) { - delay(80) - imageReadyToUnblur = true - } - } - val blurProgress by animateFloatAsState( - targetValue = if (imageReadyToUnblur) 0f else 1f, - animationSpec = tween(300), - label = "blur" - ) - val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() } - val displayBytes = fullBytes ?: thumbnailBytes!! - Box(modifier = Modifier.fillMaxSize()) { - AsyncImage( - model = displayBytes, - contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier), - contentScale = ContentScale.Crop - ) + when { + thumbnailBytes == null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + InfiniteCircularProgress() + } + } + else -> { + val thumbPainter = rememberAsyncImagePainter( + model = thumbnailBytes, + contentScale = ContentScale.Crop + ) + val thumbState by thumbPainter.state.collectAsState() + when (thumbState) { + is coil3.compose.AsyncImagePainter.State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + InfiniteCircularProgress() + } + } + is coil3.compose.AsyncImagePainter.State.Success -> { + Image( + painter = thumbPainter, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .blur(8.dp), + contentScale = ContentScale.Crop + ) + if (fullBytes != null) { + val fullPainter = rememberAsyncImagePainter( + model = fullBytes, + contentScale = ContentScale.Crop + ) + val fullState by fullPainter.state.collectAsState() + when (fullState) { + is coil3.compose.AsyncImagePainter.State.Success -> { + val alpha = remember { Animatable(0f) } + LaunchedEffect(Unit) { + alpha.animateTo(1f, animationSpec = tween(300)) + } + Image( + painter = fullPainter, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .alpha(alpha.value), + contentScale = ContentScale.Crop + ) + } + else -> { } + } + } + } + else -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + InfiniteCircularProgress() + } + } + } + } + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index bd140fa..b49b785 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -103,6 +103,24 @@ abstract class ChatPanel( } } + /** + * Add multiple messages at once. Use when loading history so the list is not shown + * until all messages (including thumbnails) are ready. + */ + suspend fun addMessages(messages: List) { + if (messages.isEmpty()) return + addMessageMutex.withLock { + val existingIds = _state.messages.mapTo(mutableSetOf()) { it.id } + val newOnes = messages.filter { it.id !in existingIds } + if (newOnes.isNotEmpty()) { + updateState { currentState -> + val newMessages = (currentState.messages + newOnes).sortedBy { it.timestamp } + currentState.copy(messages = newMessages) + } + } + } + } + /** * Update existing message (public for ChatScreen optimistic UI) */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index f5a0efd..f5a6881 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -211,13 +211,15 @@ fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) ) } - message.files?.forEach { file -> + message.files?.forEachIndexed { index, file -> AttachmentPreview( file = file, dmEnvelope = message.dmEnvelope, currentUserId = currentUserId, pendingFileUri = null, isUploading = false, + fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, + fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 8ba5e58..bb034ee 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonObject @@ -75,17 +77,24 @@ class DmPanel( ApiClient.getDmHistory(otherUserId) }.onSuccess { response -> clearMessages() - response.messages.mapNotNull { envelope -> + val decryptedForLog = mutableListOf>() + val messages = response.messages.mapNotNull { envelope -> decryptEnvelope(envelope, currentUserId)?.let { plaintext -> + decryptedForLog.add(envelope.id to plaintext) createMessage(envelope, plaintext) } - }.forEach { addMessage(it) } - response.messages.forEach { envelope -> - if (envelope.replyToId != null) { - val replyTo = _state.messages.find { it.id == envelope.replyToId } - updateMessage(envelope.id) { it.copy(reply_to = replyTo) } - } } + decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> + Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") + } + val replyToMap = messages.associateBy { it.id } + val messagesWithReplies = messages.map { msg -> + val envelope = response.messages.find { it.id == msg.id } + if (envelope?.replyToId != null) { + msg.copy(reply_to = replyToMap[envelope.replyToId]) + } else msg + } + addMessages(messagesWithReplies) setHasMoreMessages(false) }.onFailure { error -> Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error) @@ -167,14 +176,46 @@ class DmPanel( scope.launch(Dispatchers.Default) { val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() if (plaintext != null) { - updateMessage(envelope.id) { it.copy(content = plaintext, is_edited = true) } + val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) + updateMessage(envelope.id) { + it.copy( + content = content, + is_edited = true, + fileThumbnails = fileThumbnails ?: it.fileThumbnails, + fileAspectRatios = fileAspectRatios ?: it.fileAspectRatios + ) + } } else { updateMessage(envelope.id) { it.copy(is_edited = true) } } } } + private fun parseDecryptedContent(plaintext: String): Triple?, List?> { + return runCatching { + val obj = json.parseToJsonElement(plaintext).jsonObject + val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching Triple(plaintext, null, null) + val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching Triple(text, null, null) + val thumbnails = thumbArr.map { it.jsonPrimitive.content } + val arArr = obj["fileAspectRatios"]?.jsonArray + val aspectRatios = arArr?.mapNotNull { elem -> + val arr = elem as? JsonArray ?: return@mapNotNull null + if (arr.size == 2) { + val w = (arr.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull() + val h = (arr.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull() + if (w != null && h != null && h > 0) w.toFloat() / h else null + } else null + }?.takeIf { it.size == thumbnails.size } + Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size} [${thumbnails.map { "len=${it.length}" }.joinToString()}], aspectRatios=${aspectRatios?.joinToString() ?: "null"}") + Triple(text, thumbnails.ifEmpty { null }, aspectRatios) + }.getOrElse { + Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback") + Triple(plaintext, null, null) + } + } + private fun createMessage(envelope: DmEnvelope, plaintext: String): Message { + val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) val username = if (envelope.senderId == currentUserId) { "You" } else { @@ -183,7 +224,7 @@ class DmPanel( return Message( id = envelope.id, user_id = envelope.senderId, - content = plaintext, + content = content, timestamp = envelope.timestamp, is_read = envelope.recipientId == currentUserId, is_edited = false, @@ -194,7 +235,9 @@ class DmPanel( client_message_id = null, reactions = null, files = envelope.files, - dmEnvelope = envelope + dmEnvelope = envelope, + fileThumbnails = fileThumbnails, + fileAspectRatios = fileAspectRatios ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c898cdd..cc10e1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.0" +agp = "9.0.1" androidx-activityCompose = "1.12.3" androidx-appcompat = "1.7.1" androidx-core-ktx = "1.17.0"