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. */
@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
@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
@@ -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
}
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(
modifier = modifier
.conditional(
fileAspectRatio != null && fileAspectRatio > 0f,
fileAspectRatio != null && fileAspectRatio!! > 0f,
`if` = {
Modifier
.aspectRatio(fileAspectRatio!!)
.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE)
.clip(RoundedCornerShape(IMAGE_RADIUS))
},
`else` = {
Modifier.size(IMAGE_SIZE)
}
)
Modifier
.size(IMAGE_SIZE)
.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")
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(
}
}
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) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
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.AttachFile,
imageVector = Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.size(22.dp),
tint = iconTint
)
}
Column(
modifier = Modifier.padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = filename.take(20) + if (filename.length > 20) "" else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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.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,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) {
AttachmentPreview(
file = null,
@@ -208,6 +234,7 @@ fun MessageItem(
currentUserId = null,
pendingFileUri = message.pendingFileUri,
isUploading = message.uploadProgress != null,
isAuthor = isAuthor,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
@@ -220,10 +247,13 @@ fun MessageItem(
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)
)
}
if (message.content.isNotBlank()) {
}
if (message.content.isNotBlank() && !isMessageCorrupted(message)) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
@@ -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<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 {
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
)
}