Implement image preview

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-15 21:10:35 +03:00
Unverified
parent c5c7ed7697
commit 928d00b665
8 changed files with 743 additions and 330 deletions
+1
View File
@@ -53,6 +53,7 @@ kotlin {
implementation(libs.constraintlayout)
implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended)
implementation("androidx.compose.animation:animation:1.8.4")
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx)
@@ -1,6 +1,8 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -53,17 +55,13 @@ import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.core.Logger
import ru.fromchat.crypto.decryptFile
private val IMAGE_SIZE = 160.dp
private val IMAGE_RADIUS = 8.dp
private fun isImageFilename(name: String): Boolean =
internal 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)
@@ -77,7 +75,13 @@ fun AttachmentPreview(
fileThumbnail: String? = null,
fileAspectRatio: Float? = null,
fileSizeBytes: Long? = null,
messageId: Int? = null,
fileIndex: Int? = null,
onFileClick: (() -> Unit)? = null,
onImageClick: (() -> Unit)? = null,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
isAuthor: Boolean = false,
modifier: Modifier = Modifier
) {
@@ -118,30 +122,48 @@ fun AttachmentPreview(
)
}
isImageWithThumb -> {
var isFullyLoaded by remember { mutableStateOf(false) }
val sharedModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else Modifier
Box(
modifier = modifier
.then(
if (onImageClick != null && isFullyLoaded) Modifier.clickable(onClick = onImageClick)
else 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))
Modifier.size(IMAGE_SIZE)
}
),
)
.then(sharedModifier)
.clip(RoundedCornerShape(IMAGE_RADIUS)),
contentAlignment = Alignment.Center
) {
DecryptedImageContent(
messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio
aspectRatio = fileAspectRatio,
sharedImageKey = sharedImageKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onFullyLoaded = { isFullyLoaded = it }
)
}
}
@@ -235,23 +257,30 @@ private fun InfiniteCircularProgress() {
@Composable
private fun DecryptedImageContent(
messageId: Int,
fileIndex: Int,
file: DmFile,
envelope: DmEnvelope,
currentUserId: Int?,
thumbnailBase64: String,
aspectRatio: Float?
aspectRatio: Float?,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onFullyLoaded: (Boolean) -> Unit = {}
) {
var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
var fullBytes by remember(messageId, fileIndex, file.path) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path))
}
val thumbnailBytes = remember(thumbnailBase64) {
runCatching { Base64.decode(thumbnailBase64) }.getOrNull()
}
LaunchedEffect(file.path) {
Logger.d("AttachmentPreview", "DecryptedImageContent: fetching full image path=${file.path}")
withContext(Dispatchers.Default) {
fullBytes = runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
Logger.d("AttachmentPreview", "DecryptedImageContent: full image fetch done path=${file.path} success=${fullBytes != null} size=${fullBytes?.size ?: 0}")
LaunchedEffect(messageId, fileIndex, file.path, envelope) {
fullBytes = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
}
LaunchedEffect(fullBytes) {
onFullyLoaded(fullBytes != null)
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -270,8 +299,9 @@ private fun DecryptedImageContent(
contentScale = ContentScale.Crop
)
val thumbState by thumbPainter.state.collectAsState()
when (thumbState) {
is coil3.compose.AsyncImagePainter.State.Loading -> {
val showThumbnailLoading = fullBytes == null && thumbState is coil3.compose.AsyncImagePainter.State.Loading
when {
showThumbnailLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -279,7 +309,7 @@ private fun DecryptedImageContent(
InfiniteCircularProgress()
}
}
is coil3.compose.AsyncImagePainter.State.Success -> {
thumbState is coil3.compose.AsyncImagePainter.State.Success -> {
Image(
painter = thumbPainter,
contentDescription = file.name,
@@ -292,7 +322,7 @@ private fun DecryptedImageContent(
if (fullBytes != null) {
val fullPainter = rememberAsyncImagePainter(
model = fullBytes,
contentScale = ContentScale.Crop
contentScale = ContentScale.FillWidth
)
val fullState by fullPainter.state.collectAsState()
when (fullState) {
@@ -308,7 +338,7 @@ private fun DecryptedImageContent(
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.alpha(alpha.value),
contentScale = ContentScale.Crop
contentScale = ContentScale.FillWidth
)
}
else -> { }
@@ -316,6 +346,9 @@ private fun DecryptedImageContent(
}
}
else -> {
if (fullBytes != null) {
Box(modifier = Modifier.fillMaxSize())
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -328,6 +361,7 @@ private fun DecryptedImageContent(
}
}
}
}
private fun formatFileSize(bytes: Long): String {
return when {
@@ -1,8 +1,10 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
@@ -79,6 +81,7 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback
@@ -179,6 +182,12 @@ fun ChatScreen(
)
)
}
var expandedImage by remember { mutableStateOf<Pair<Message, Int>?>(null) }
BackHandler(enabled = expandedImage != null) {
expandedImage = null
}
// Collect WebSocket messages
LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message ->
@@ -291,6 +300,15 @@ fun ChatScreen(
}
}
AnimatedContent(
targetState = expandedImage,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300))
},
label = "image_fullscreen"
) { expanded ->
if (expanded == null) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -537,10 +555,10 @@ fun ChatScreen(
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
items(
items = panelState.messages,
@@ -576,16 +594,18 @@ fun ChatScreen(
onTapPosition = { offset ->
tapOffset = offset
},
onImageClick = { msg, idx -> expandedImage = msg to idx },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
showUsername = panel.showUsernamesInMessages
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
}
}
// Context menu
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
@@ -611,4 +631,33 @@ fun ChatScreen(
)
}
}
} else {
expanded.let { (msg, idx) ->
ImageFullscreenPreview(
message = msg,
fileIndex = idx,
currentUserId = currentUserId,
onDismiss = { expandedImage = null },
onReply = { m ->
replyTo = m
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
expandedImage = null
},
onDelete = { m ->
scope.launch {
panel.handleDeleteMessage(m.id)
}
},
onSave = { _, _ -> /* TODO: platform-specific save to gallery */ },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
sharedImageKey = "img_${msg.id}_$idx",
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@@ -0,0 +1,57 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.crypto.decryptFile
/**
* Cache for decrypted image bytes. Key includes messageId, fileIndex, and file.path
* so that server updates (e.g. image replacement) produce cache misses via path change.
*/
object DecryptedImageCache {
private val cache = mutableMapOf<String, ByteArray>()
private val lock = Any()
private fun key(messageId: Int, fileIndex: Int, filePath: String): String =
"img_${messageId}_${fileIndex}_$filePath"
fun getCached(messageId: Int, fileIndex: Int, filePath: String): ByteArray? =
synchronized(lock) { cache[key(messageId, fileIndex, filePath)] }
suspend fun getOrDecrypt(
messageId: Int,
fileIndex: Int,
file: DmFile,
envelope: DmEnvelope?,
currentUserId: Int?
): ByteArray? {
if (envelope == null) return null
val k = key(messageId, fileIndex, file.path)
synchronized(lock) {
cache[k]?.let { return it }
}
val bytes = runCatching {
withContext(Dispatchers.Default) {
decryptFile(file, envelope, currentUserId)
}
}.getOrNull() ?: return null
synchronized(lock) {
cache[k] = bytes
}
return bytes
}
suspend fun invalidateForMessage(messageId: Int) {
synchronized(lock) {
cache.keys.removeAll { it.startsWith("img_${messageId}_") }
}
}
suspend fun invalidateForFile(messageId: Int, fileIndex: Int, filePath: String) {
synchronized(lock) {
cache.remove(key(messageId, fileIndex, filePath))
}
}
}
@@ -0,0 +1,257 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
private val MENU_BG_ALPHA = 0.5f
@OptIn(ExperimentalTime::class)
private fun formatDateTime(timestamp: String): String {
return try {
Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
val hour = it.hour.toString().padStart(2, '0')
val minute = it.minute.toString().padStart(2, '0')
val month = (it.month.ordinal + 1).toString().padStart(2, '0')
val day = it.day.toString().padStart(2, '0')
val year = it.year
"$month/$day/$year $hour:$minute"
}
} catch (_: Exception) {
timestamp
}
}
@Composable
fun ImageFullscreenPreview(
message: Message,
fileIndex: Int,
currentUserId: Int?,
onDismiss: () -> Unit,
onReply: (Message) -> Unit,
onDelete: (Message) -> Unit,
onSave: (Message, Int) -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedImageKey: Any? = null,
modifier: Modifier = Modifier
) {
val file = message.files?.getOrNull(fileIndex) ?: return
val envelope = message.dmEnvelope
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
var fullBytes by remember(message.id, fileIndex, file.path) {
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, file.path))
}
val thumbnailBytes = remember(thumbnailBase64) {
thumbnailBase64?.let { runCatching { com.pr0gramm3r101.utils.crypto.Base64.decode(it) }.getOrNull() }
}
LaunchedEffect(message.id, fileIndex, file.path, envelope) {
fullBytes = DecryptedImageCache.getOrDecrypt(message.id, fileIndex, file, envelope, currentUserId)
}
Box(
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(onTap = { onDismiss() })
}
) {
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
// Center image - scale to width when aspect ratio known
Box(
modifier = Modifier
.fillMaxSize()
.clip(RectangleShape)
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
when (val bytes = fullBytes ?: thumbnailBytes) {
null -> androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = Color.White
)
else -> {
val imageModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier
.then(
if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize()
)
.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
else Modifier.fillMaxSize()
AsyncImage(
model = bytes,
contentDescription = file.name,
modifier = imageModifier,
contentScale = ContentScale.FillWidth
)
}
}
}
// Top bar: back, display name + date/time, 3-dot menu
Row(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message.username,
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
Text(
text = formatDateTime(message.timestamp),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f)
)
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
tint = Color.White
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Reply", color = Color.White)
}
},
onClick = {
menuExpanded = false
onReply(message)
onDismiss()
}
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Save", color = Color.White)
}
},
onClick = {
menuExpanded = false
onSave(message, fileIndex)
}
)
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text("Delete", color = Color.White)
}
},
onClick = {
menuExpanded = false
onDelete(message)
onDismiss()
}
)
}
}
}
// Bottom: message text
if (message.content.isNotBlank()) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(16.dp)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
}
}
}
@@ -1,6 +1,8 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -44,10 +46,6 @@ 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) ->
@@ -65,6 +63,9 @@ fun MessageItem(
isAuthor: Boolean,
onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {},
onImageClick: ((Message, Int) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier,
showUsername: Boolean = true,
currentUserId: Int? = null
@@ -239,6 +240,7 @@ fun MessageItem(
)
}
message.files?.forEachIndexed { index, file ->
val isImage = isImageFilename(file.name)
AttachmentPreview(
file = file,
dmEnvelope = message.dmEnvelope,
@@ -248,8 +250,17 @@ fun MessageItem(
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f },
fileSizeBytes = message.fileSizes?.getOrNull(index),
messageId = if (isImage) message.id else null,
fileIndex = if (isImage) index else null,
isAuthor = isAuthor,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
sharedImageKey = if (isImage && sharedTransitionScope != null && animatedVisibilityScope != null) "img_${message.id}_$index" else null,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
modifier = Modifier.padding(
horizontal = if (isImage) 2.dp else 12.dp,
vertical = if (isImage) 2.dp else 4.dp
)
)
}
}
@@ -108,11 +108,13 @@ class PublicChatPanel(
"messageEdited" -> {
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { editedMsg }
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id)
}
"reactionUpdate" -> {
@@ -19,6 +19,7 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.DecryptedImageCache
import ru.fromchat.ui.chat.DmTypingHandler
import ru.fromchat.ui.chat.TypingHandler
import ru.fromchat.ui.chat.TypingUser
@@ -174,6 +175,7 @@ class DmPanel(
}.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.Default) {
DecryptedImageCache.invalidateForMessage(envelope.id)
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) {
val dec = parseDecryptedContent(plaintext)