Implement thumbnails

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-15 15:58:08 +03:00
Unverified
parent 7da7cf6018
commit ea2529f265
8 changed files with 192 additions and 87 deletions
-1
View File
@@ -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")
}
@@ -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.
*/
@@ -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<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null
)
@Serializable
@@ -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
Box(
modifier = modifier
.size(IMAGE_SIZE)
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),
.background(MaterialTheme.colorScheme.surfaceVariant)
Box(
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<ByteArray?>(null) }
var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(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()) {
when {
thumbnailBytes == null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
return
}
LaunchedEffect(hasFull) {
if (hasFull) {
delay(80)
imageReadyToUnblur = true
InfiniteCircularProgress()
}
}
val blurProgress by animateFloatAsState(
targetValue = if (imageReadyToUnblur) 0f else 1f,
animationSpec = tween(300),
label = "blur"
else -> {
val thumbPainter = rememberAsyncImagePainter(
model = thumbnailBytes,
contentScale = ContentScale.Crop
)
val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() }
val displayBytes = fullBytes ?: thumbnailBytes!!
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = displayBytes,
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))
.then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier),
.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()
}
}
}
}
}
}
}
@@ -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<Message>) {
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)
*/
@@ -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)
)
}
@@ -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<Pair<Int, String>>()
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<String, List<String>?, List<Float>?> {
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
)
}
+1 -1
View File
@@ -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"