Improve file UI

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-15 17:50:59 +03:00
Unverified
parent e7d22e0358
commit e94c41e707
4 changed files with 218 additions and 97 deletions
@@ -105,7 +105,9 @@ data class Message(
/** Blurhashes for image files (by index); from decrypted message JSON. */ /** Blurhashes for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileThumbnails: List<String>? = null, @kotlinx.serialization.Transient val fileThumbnails: List<String>? = null,
/** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null @kotlinx.serialization.Transient val fileAspectRatios: List<Float>? = null,
/** File sizes in bytes (by index); from decrypted message JSON. */
@kotlinx.serialization.Transient val fileSizes: List<Long>? = null
) )
@Serializable @Serializable
@@ -7,17 +7,21 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
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.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.filled.Image import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.alpha
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.conditional
@@ -63,6 +69,9 @@ fun AttachmentPreview(
isUploading: Boolean, isUploading: Boolean,
fileThumbnail: String? = null, fileThumbnail: String? = null,
fileAspectRatio: Float? = null, fileAspectRatio: Float? = null,
fileSizeBytes: Long? = null,
onFileClick: (() -> Unit)? = null,
isAuthor: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isImage = when { val isImage = when {
@@ -77,53 +86,81 @@ fun AttachmentPreview(
else -> false else -> false
} }
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
)
}
isPendingFile -> {
FileIconContent(
filename = "File",
sizeBytes = null,
onClick = null,
isAuthor = isAuthor,
modifier = modifier
)
}
isImageWithThumb -> {
Box( Box(
modifier = modifier modifier = modifier
.conditional( .conditional(
fileAspectRatio != null && fileAspectRatio > 0f, fileAspectRatio != null && fileAspectRatio!! > 0f,
`if` = { `if` = {
Modifier Modifier
.aspectRatio(fileAspectRatio!!) .aspectRatio(fileAspectRatio!!)
.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE)
.clip(RoundedCornerShape(IMAGE_RADIUS))
}, },
`else` = { `else` = {
Modifier.size(IMAGE_SIZE) Modifier
} .size(IMAGE_SIZE)
)
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.background(MaterialTheme.colorScheme.surfaceVariant), }
),
contentAlignment = Alignment.Center 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")
DecryptedImageContent( DecryptedImageContent(
file = file, file = file!!,
envelope = dmEnvelope, envelope = dmEnvelope!!,
currentUserId = currentUserId, currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail, thumbnailBase64 = fileThumbnail!!,
aspectRatio = fileAspectRatio aspectRatio = fileAspectRatio
) )
} }
file != null && !isImage -> {
Logger.d("AttachmentPreview", "Rendering: FileIconContent file=${file.name}")
FileIconContent(filename = file.name)
} }
else -> { isPendingImage -> {
Logger.d("AttachmentPreview", "Rendering: fallback Icon (file=$file, isImage=$isImage, hasEnvelope=${dmEnvelope != null}, thumbBlank=${fileThumbnail.isNullOrBlank()})") Box(
Icon( modifier = modifier
imageVector = Icons.Default.Image, .conditional(
contentDescription = null, fileAspectRatio != null && fileAspectRatio!! > 0f,
modifier = Modifier.size(48.dp), `if` = {
tint = MaterialTheme.colorScheme.onSurfaceVariant 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 formatFileSize(bytes: Long): String {
private fun FileIconContent(filename: String) { return when {
Column( bytes < 1024 -> "$bytes B"
modifier = Modifier.padding(16.dp), bytes < 1024 * 1024 -> "${bytes / 1024} KB"
horizontalAlignment = Alignment.CenterHorizontally bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
) { else -> "${bytes / (1024 * 1024 * 1024)} GB"
Icon( }
imageVector = Icons.Default.AttachFile, }
contentDescription = null,
modifier = Modifier.size(40.dp), @Composable
tint = MaterialTheme.colorScheme.onSurfaceVariant private fun FileIconContent(
) filename: String,
Text( sizeBytes: Long?,
text = filename.take(20) + if (filename.length > 20) "" else "", onClick: (() -> Unit)?,
style = MaterialTheme.typography.labelSmall, isAuthor: Boolean,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier: Modifier = Modifier
maxLines = 2 ) {
) 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)
)
}
}
} }
} }
@@ -44,6 +44,20 @@ import ru.fromchat.api.Message
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant 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) @OptIn(ExperimentalTime::class)
@Composable @Composable
fun MessageItem( fun MessageItem(
@@ -200,7 +214,19 @@ fun MessageItem(
} }
} }
// Attachments (images/files) // 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) { if (message.pendingFileUri != null) {
AttachmentPreview( AttachmentPreview(
file = null, file = null,
@@ -208,6 +234,7 @@ fun MessageItem(
currentUserId = null, currentUserId = null,
pendingFileUri = message.pendingFileUri, pendingFileUri = message.pendingFileUri,
isUploading = message.uploadProgress != null, isUploading = message.uploadProgress != null,
isAuthor = isAuthor,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
} }
@@ -220,10 +247,13 @@ fun MessageItem(
isUploading = false, isUploading = false,
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f }, fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f },
fileSizeBytes = message.fileSizes?.getOrNull(index),
isAuthor = isAuthor,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) )
} }
if (message.content.isNotBlank()) { }
if (message.content.isNotBlank() && !isMessageCorrupted(message)) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -176,13 +176,14 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) { if (plaintext != null) {
val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) val dec = parseDecryptedContent(plaintext)
updateMessage(envelope.id) { updateMessage(envelope.id) {
it.copy( it.copy(
content = content, content = dec.text,
is_edited = true, is_edited = true,
fileThumbnails = fileThumbnails ?: it.fileThumbnails, fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
fileAspectRatios = fileAspectRatios ?: it.fileAspectRatios fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes
) )
} }
} else { } else {
@@ -191,11 +192,18 @@ class DmPanel(
} }
} }
private fun parseDecryptedContent(plaintext: String): Triple<String, List<String>?, List<Float>?> { private data class DecryptedContent(
val text: String,
val thumbnails: List<String>?,
val aspectRatios: List<Float>?,
val fileSizes: List<Long>?
)
private fun parseDecryptedContent(plaintext: String): DecryptedContent {
return runCatching { return runCatching {
val obj = json.parseToJsonElement(plaintext).jsonObject val obj = json.parseToJsonElement(plaintext).jsonObject
val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching Triple(plaintext, null, null) val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, null, null, null)
val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching Triple(text, null, null) val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null)
val thumbnails = thumbArr.map { it.jsonPrimitive.content } val thumbnails = thumbArr.map { it.jsonPrimitive.content }
val arArr = obj["fileAspectRatios"]?.jsonArray val arArr = obj["fileAspectRatios"]?.jsonArray
val aspectRatios = arArr?.mapNotNull { elem -> val aspectRatios = arArr?.mapNotNull { elem ->
@@ -206,16 +214,18 @@ class DmPanel(
if (w != null && h != null && h > 0) w.toFloat() / h else null if (w != null && h != null && h > 0) w.toFloat() / h else null
} else null } else null
}?.takeIf { it.size == thumbnails.size } }?.takeIf { it.size == thumbnails.size }
Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size} [${thumbnails.map { "len=${it.length}" }.joinToString()}], aspectRatios=${aspectRatios?.joinToString() ?: "null"}") val sizesArr = obj["fileSizes"]?.jsonArray
Triple(text, thumbnails.ifEmpty { null }, aspectRatios) 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 { }.getOrElse {
Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback") 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 { private fun createMessage(envelope: DmEnvelope, plaintext: String): Message {
val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext) val dec = parseDecryptedContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
} else { } else {
@@ -224,7 +234,7 @@ class DmPanel(
return Message( return Message(
id = envelope.id, id = envelope.id,
user_id = envelope.senderId, user_id = envelope.senderId,
content = content, content = dec.text,
timestamp = envelope.timestamp, timestamp = envelope.timestamp,
is_read = envelope.recipientId == currentUserId, is_read = envelope.recipientId == currentUserId,
is_edited = false, is_edited = false,
@@ -236,8 +246,9 @@ class DmPanel(
reactions = null, reactions = null,
files = envelope.files, files = envelope.files,
dmEnvelope = envelope, dmEnvelope = envelope,
fileThumbnails = fileThumbnails, fileThumbnails = dec.thumbnails,
fileAspectRatios = fileAspectRatios fileAspectRatios = dec.aspectRatios,
fileSizes = dec.fileSizes
) )
} }