mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement thumbnails
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -40,7 +40,6 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
// NaCl box implementation for transport encryption (Android/JVM only)
|
|
||||||
implementation("org.purejava:tweetnacl-java:1.1.3")
|
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".
|
* 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.
|
* 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 {
|
suspend fun fetchEncryptedFile(path: String): ByteArray {
|
||||||
val baseUrl = when {
|
val url = when {
|
||||||
path.startsWith("http") -> path
|
path.startsWith("http") -> path
|
||||||
path.startsWith("/api") -> {
|
path.startsWith("/api") -> {
|
||||||
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
|
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
|
||||||
@@ -345,17 +344,9 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
else -> "${Config.apiBaseUrl}$path"
|
else -> "${Config.apiBaseUrl}$path"
|
||||||
}
|
}
|
||||||
val url = if (thumb) "$baseUrl?thumb=1" else baseUrl
|
|
||||||
return http.get(url).body()
|
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.
|
* 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. */
|
/** For optimistic UI: 0-100 upload progress, null when complete. */
|
||||||
val uploadProgress: Int? = null,
|
val uploadProgress: Int? = null,
|
||||||
/** For DM file decryption; not serialized over network. */
|
/** 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
|
@Serializable
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
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.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.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
|
||||||
@@ -21,26 +24,26 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
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.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.rememberAsyncImagePainter
|
||||||
|
import com.pr0gramm3r101.utils.crypto.Base64
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fromchat.api.ApiClient
|
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.crypto.decryptFile
|
import ru.fromchat.crypto.decryptFile
|
||||||
|
|
||||||
private val IMAGE_SIZE = 160.dp
|
private val IMAGE_SIZE = 160.dp
|
||||||
@@ -57,6 +60,8 @@ fun AttachmentPreview(
|
|||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
pendingFileUri: String?,
|
pendingFileUri: String?,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
|
fileThumbnail: String? = null,
|
||||||
|
fileAspectRatio: Float? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let {
|
val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let {
|
||||||
@@ -65,32 +70,45 @@ fun AttachmentPreview(
|
|||||||
it.endsWith(".gif", true) || it.endsWith(".webp", true)
|
it.endsWith(".gif", true) || it.endsWith(".webp", true)
|
||||||
} ?: false
|
} ?: false
|
||||||
|
|
||||||
|
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)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = baseModifier,
|
||||||
.size(IMAGE_SIZE)
|
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
pendingFileUri != null -> {
|
pendingFileUri != null -> {
|
||||||
|
Logger.d("AttachmentPreview", "Rendering: pendingFileUri, isUploading=$isUploading")
|
||||||
PendingImageContent(
|
PendingImageContent(
|
||||||
uri = pendingFileUri,
|
uri = pendingFileUri,
|
||||||
isUploading = isUploading,
|
isUploading = isUploading,
|
||||||
isImage = isImage
|
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(
|
DecryptedImageContent(
|
||||||
file = file,
|
file = file,
|
||||||
envelope = dmEnvelope,
|
envelope = dmEnvelope,
|
||||||
currentUserId = currentUserId
|
currentUserId = currentUserId,
|
||||||
|
thumbnailBase64 = fileThumbnail,
|
||||||
|
aspectRatio = fileAspectRatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
file != null && !isImage -> {
|
file != null && !isImage -> {
|
||||||
|
Logger.d("AttachmentPreview", "Rendering: FileIconContent file=${file.name}")
|
||||||
FileIconContent(filename = file.name)
|
FileIconContent(filename = file.name)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
Logger.d("AttachmentPreview", "Rendering: fallback Icon (file=$file, isImage=$isImage, hasEnvelope=${dmEnvelope != null}, thumbBlank=${fileThumbnail.isNullOrBlank()})")
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Image,
|
imageVector = Icons.Default.Image,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -165,65 +183,95 @@ private fun InfiniteCircularProgress() {
|
|||||||
private fun DecryptedImageContent(
|
private fun DecryptedImageContent(
|
||||||
file: DmFile,
|
file: DmFile,
|
||||||
envelope: DmEnvelope,
|
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 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) {
|
LaunchedEffect(file.path) {
|
||||||
|
Logger.d("AttachmentPreview", "DecryptedImageContent: fetching full image path=${file.path}")
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
coroutineScope {
|
fullBytes = runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
|
||||||
val thumbDeferred = async {
|
Logger.d("AttachmentPreview", "DecryptedImageContent: full image fetch done path=${file.path} success=${fullBytes != null} size=${fullBytes?.size ?: 0}")
|
||||||
ApiClient.fetchThumbnail(file.path)
|
|
||||||
}
|
|
||||||
val fullDeferred = async {
|
|
||||||
runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
|
|
||||||
}
|
|
||||||
thumbnailBytes = thumbDeferred.await()
|
|
||||||
fullBytes = fullDeferred.await()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasThumb = thumbnailBytes != null
|
|
||||||
val hasFull = fullBytes != null
|
|
||||||
val showContent = hasThumb || hasFull
|
|
||||||
|
|
||||||
if (!showContent) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hasFull) {
|
|
||||||
if (hasFull) {
|
|
||||||
delay(80)
|
|
||||||
imageReadyToUnblur = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val blurProgress by animateFloatAsState(
|
|
||||||
targetValue = if (imageReadyToUnblur) 0f else 1f,
|
|
||||||
animationSpec = tween(300),
|
|
||||||
label = "blur"
|
|
||||||
)
|
|
||||||
val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() }
|
|
||||||
val displayBytes = fullBytes ?: thumbnailBytes!!
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AsyncImage(
|
when {
|
||||||
model = displayBytes,
|
thumbnailBytes == null -> {
|
||||||
contentDescription = file.name,
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
contentAlignment = Alignment.Center
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
) {
|
||||||
.then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier),
|
InfiniteCircularProgress()
|
||||||
contentScale = ContentScale.Crop
|
}
|
||||||
)
|
}
|
||||||
|
else -> {
|
||||||
|
val thumbPainter = rememberAsyncImagePainter(
|
||||||
|
model = thumbnailBytes,
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
.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)
|
* Update existing message (public for ChatScreen optimistic UI)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -211,13 +211,15 @@ fun MessageItem(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
message.files?.forEach { file ->
|
message.files?.forEachIndexed { index, file ->
|
||||||
AttachmentPreview(
|
AttachmentPreview(
|
||||||
file = file,
|
file = file,
|
||||||
dmEnvelope = message.dmEnvelope,
|
dmEnvelope = message.dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
pendingFileUri = null,
|
pendingFileUri = null,
|
||||||
isUploading = false,
|
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)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -75,17 +77,24 @@ class DmPanel(
|
|||||||
ApiClient.getDmHistory(otherUserId)
|
ApiClient.getDmHistory(otherUserId)
|
||||||
}.onSuccess { response ->
|
}.onSuccess { response ->
|
||||||
clearMessages()
|
clearMessages()
|
||||||
response.messages.mapNotNull { envelope ->
|
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
||||||
|
val messages = response.messages.mapNotNull { envelope ->
|
||||||
decryptEnvelope(envelope, currentUserId)?.let { plaintext ->
|
decryptEnvelope(envelope, currentUserId)?.let { plaintext ->
|
||||||
|
decryptedForLog.add(envelope.id to plaintext)
|
||||||
createMessage(envelope, 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)
|
setHasMoreMessages(false)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error)
|
Logger.e("DmPanel", "Failed to load DM history: ${error.message}", error)
|
||||||
@@ -167,14 +176,46 @@ 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) {
|
||||||
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 {
|
} else {
|
||||||
updateMessage(envelope.id) { it.copy(is_edited = true) }
|
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 {
|
private fun createMessage(envelope: DmEnvelope, plaintext: String): Message {
|
||||||
|
val (content, fileThumbnails, fileAspectRatios) = parseDecryptedContent(plaintext)
|
||||||
val username = if (envelope.senderId == currentUserId) {
|
val username = if (envelope.senderId == currentUserId) {
|
||||||
"You"
|
"You"
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +224,7 @@ class DmPanel(
|
|||||||
return Message(
|
return Message(
|
||||||
id = envelope.id,
|
id = envelope.id,
|
||||||
user_id = envelope.senderId,
|
user_id = envelope.senderId,
|
||||||
content = plaintext,
|
content = content,
|
||||||
timestamp = envelope.timestamp,
|
timestamp = envelope.timestamp,
|
||||||
is_read = envelope.recipientId == currentUserId,
|
is_read = envelope.recipientId == currentUserId,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
@@ -194,7 +235,9 @@ class DmPanel(
|
|||||||
client_message_id = null,
|
client_message_id = null,
|
||||||
reactions = null,
|
reactions = null,
|
||||||
files = envelope.files,
|
files = envelope.files,
|
||||||
dmEnvelope = envelope
|
dmEnvelope = envelope,
|
||||||
|
fileThumbnails = fileThumbnails,
|
||||||
|
fileAspectRatios = fileAspectRatios
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.0.0"
|
agp = "9.0.1"
|
||||||
androidx-activityCompose = "1.12.3"
|
androidx-activityCompose = "1.12.3"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.17.0"
|
androidx-core-ktx = "1.17.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user