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 612c22d..a366ca6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -105,7 +105,9 @@ data class Message( /** 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 + @kotlinx.serialization.Transient val fileAspectRatios: List? = null, + /** File sizes in bytes (by index); from decrypted message JSON. */ + @kotlinx.serialization.Transient val fileSizes: 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 3d848c5..0699051 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 @@ -7,17 +7,21 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Download import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -34,8 +38,10 @@ 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.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.rememberAsyncImagePainter import com.pr0gramm3r101.utils.conditional @@ -63,6 +69,9 @@ fun AttachmentPreview( isUploading: Boolean, fileThumbnail: String? = null, fileAspectRatio: Float? = null, + fileSizeBytes: Long? = null, + onFileClick: (() -> Unit)? = null, + isAuthor: Boolean = false, modifier: Modifier = Modifier ) { val isImage = when { @@ -77,53 +86,81 @@ fun AttachmentPreview( else -> false } - Box( - modifier = modifier - .conditional( - fileAspectRatio != null && fileAspectRatio > 0f, - `if` = { - Modifier - .aspectRatio(fileAspectRatio!!) - .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) - }, - `else` = { - Modifier.size(IMAGE_SIZE) - } + val isFile = file != null && !isImage + val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() + val isPendingImage = pendingFileUri != null && isImage + val isPendingFile = pendingFileUri != null && !isImage + + when { + isFile -> { + FileIconContent( + filename = file!!.name, + sizeBytes = fileSizeBytes, + onClick = onFileClick, + isAuthor = isAuthor, + modifier = modifier ) - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .background(MaterialTheme.colorScheme.surfaceVariant), - 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 && !fileThumbnail.isNullOrBlank() -> { - Logger.d("AttachmentPreview", "Rendering: DecryptedImageContent file=${file.name} thumbLen=${fileThumbnail.length} aspectRatio=$fileAspectRatio") + } + isPendingFile -> { + FileIconContent( + filename = "File", + sizeBytes = null, + onClick = null, + isAuthor = isAuthor, + modifier = modifier + ) + } + isImageWithThumb -> { + Box( + modifier = modifier + .conditional( + fileAspectRatio != null && fileAspectRatio!! > 0f, + `if` = { + Modifier + .aspectRatio(fileAspectRatio!!) + .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + }, + `else` = { + Modifier + .size(IMAGE_SIZE) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + } + ), + contentAlignment = Alignment.Center + ) { DecryptedImageContent( - file = file, - envelope = dmEnvelope, + file = file!!, + envelope = dmEnvelope!!, currentUserId = currentUserId, - thumbnailBase64 = fileThumbnail, + 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, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + } + isPendingImage -> { + Box( + modifier = modifier + .conditional( + fileAspectRatio != null && fileAspectRatio!! > 0f, + `if` = { + Modifier + .aspectRatio(fileAspectRatio!!) + .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + }, + `else` = { + Modifier + .size(IMAGE_SIZE) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + } + ), + contentAlignment = Alignment.Center + ) { + PendingImageContent( + uri = pendingFileUri!!, + isUploading = isUploading, + isImage = true ) } } @@ -285,23 +322,64 @@ private fun DecryptedImageContent( } } -@Composable -private fun FileIconContent(filename: String) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.AttachFile, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = filename.take(20) + if (filename.length > 20) "…" else "", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2 - ) +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB" + else -> "${bytes / (1024 * 1024 * 1024)} GB" + } +} + +@Composable +private fun FileIconContent( + filename: String, + sizeBytes: Long?, + onClick: (() -> Unit)?, + isAuthor: Boolean, + modifier: Modifier = Modifier +) { + val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface + val circleBackground = if (isAuthor) Color.White else MaterialTheme.colorScheme.primary + val iconTint = if (isAuthor) MaterialTheme.colorScheme.primary else Color.White + Row( + modifier = modifier + .widthIn(max = 240.dp) + .padding(vertical = 8.dp, horizontal = 4.dp) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background(circleBackground, RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = iconTint + ) + } + Column( + modifier = Modifier.padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = filename.take(70) + if (filename.length > 70) "…" else "", + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + maxLines = 2 + ) + if (sizeBytes != null) { + Text( + text = formatFileSize(sizeBytes), + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp, + color = contentColor.copy(alpha = 0.8f) + ) + } + } } } 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 f5a6881..32541f4 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 @@ -44,6 +44,20 @@ import ru.fromchat.api.Message import kotlin.time.ExperimentalTime import kotlin.time.Instant +private fun isImageFilename(name: String): Boolean = + name.endsWith(".png", true) || name.endsWith(".jpg", true) || + name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true) + +private fun isMessageCorrupted(message: Message): Boolean { + val files = message.files ?: return false + return files.withIndex().any { (index, file) -> + isImageFilename(file.name) && ( + message.dmEnvelope == null || + message.fileThumbnails?.getOrNull(index)?.isBlank() != false + ) + } +} + @OptIn(ExperimentalTime::class) @Composable fun MessageItem( @@ -200,30 +214,46 @@ fun MessageItem( } } - // Attachments (images/files) - if (message.pendingFileUri != null) { - AttachmentPreview( - file = null, - dmEnvelope = null, - currentUserId = null, - pendingFileUri = message.pendingFileUri, - isUploading = message.uploadProgress != null, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + // Attachments (images/files) or corrupted message + if (isMessageCorrupted(message)) { + Text( + text = "_This message is corrupted and cannot be displayed.", + style = MaterialTheme.typography.bodyMedium, + color = if (isAuthor) { + Color.White.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) ) + } else { + if (message.pendingFileUri != null) { + AttachmentPreview( + file = null, + dmEnvelope = null, + currentUserId = null, + pendingFileUri = message.pendingFileUri, + isUploading = message.uploadProgress != null, + isAuthor = isAuthor, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + 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 }, + fileSizeBytes = message.fileSizes?.getOrNull(index), + isAuthor = isAuthor, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } } - 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) - ) - } - if (message.content.isNotBlank()) { + if (message.content.isNotBlank() && !isMessageCorrupted(message)) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, 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 bb034ee..156bafd 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 @@ -176,13 +176,14 @@ class DmPanel( scope.launch(Dispatchers.Default) { val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() if (plaintext != null) { - val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) + val dec = parseDecryptedContent(plaintext) updateMessage(envelope.id) { it.copy( - content = content, + content = dec.text, is_edited = true, - fileThumbnails = fileThumbnails ?: it.fileThumbnails, - fileAspectRatios = fileAspectRatios ?: it.fileAspectRatios + fileThumbnails = dec.thumbnails ?: it.fileThumbnails, + fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, + fileSizes = dec.fileSizes ?: it.fileSizes ) } } else { @@ -191,11 +192,18 @@ class DmPanel( } } - private fun parseDecryptedContent(plaintext: String): Triple?, List?> { + private data class DecryptedContent( + val text: String, + val thumbnails: List?, + val aspectRatios: List?, + val fileSizes: List? + ) + + private fun parseDecryptedContent(plaintext: String): DecryptedContent { 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 text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, null, null, null) + val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null) val thumbnails = thumbArr.map { it.jsonPrimitive.content } val arArr = obj["fileAspectRatios"]?.jsonArray val aspectRatios = arArr?.mapNotNull { elem -> @@ -206,16 +214,18 @@ class DmPanel( 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) + val sizesArr = obj["fileSizes"]?.jsonArray + val fileSizes = sizesArr?.mapNotNull { (it as? JsonPrimitive)?.content?.toLongOrNull() }?.takeIf { it.size == thumbnails.size } + Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size}, aspectRatios=${aspectRatios?.size}, fileSizes=${fileSizes?.size}") + DecryptedContent(text, thumbnails.ifEmpty { null }, aspectRatios, fileSizes) }.getOrElse { Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback") - Triple(plaintext, null, null) + DecryptedContent(plaintext, null, null, null) } } private fun createMessage(envelope: DmEnvelope, plaintext: String): Message { - val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) + val dec = parseDecryptedContent(plaintext) val username = if (envelope.senderId == currentUserId) { "You" } else { @@ -224,7 +234,7 @@ class DmPanel( return Message( id = envelope.id, user_id = envelope.senderId, - content = content, + content = dec.text, timestamp = envelope.timestamp, is_read = envelope.recipientId == currentUserId, is_edited = false, @@ -236,8 +246,9 @@ class DmPanel( reactions = null, files = envelope.files, dmEnvelope = envelope, - fileThumbnails = fileThumbnails, - fileAspectRatios = fileAspectRatios + fileThumbnails = dec.thumbnails, + fileAspectRatios = dec.aspectRatios, + fileSizes = dec.fileSizes ) }