mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user