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.constraintlayout)
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended) implementation(libs.compose.materialIconsExtended)
implementation("androidx.compose.animation:animation:1.8.4")
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -1,6 +1,8 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility 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.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -53,17 +55,13 @@ import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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
private val IMAGE_SIZE = 160.dp private val IMAGE_SIZE = 160.dp
private val IMAGE_RADIUS = 8.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(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true) name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
@@ -77,7 +75,13 @@ fun AttachmentPreview(
fileThumbnail: String? = null, fileThumbnail: String? = null,
fileAspectRatio: Float? = null, fileAspectRatio: Float? = null,
fileSizeBytes: Long? = null, fileSizeBytes: Long? = null,
messageId: Int? = null,
fileIndex: Int? = null,
onFileClick: (() -> Unit)? = null, onFileClick: (() -> Unit)? = null,
onImageClick: (() -> Unit)? = null,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
isAuthor: Boolean = false, isAuthor: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -118,30 +122,48 @@ fun AttachmentPreview(
) )
} }
isImageWithThumb -> { 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( Box(
modifier = modifier modifier = modifier
.then(
if (onImageClick != null && isFullyLoaded) Modifier.clickable(onClick = onImageClick)
else 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 Modifier.size(IMAGE_SIZE)
.size(IMAGE_SIZE)
.clip(RoundedCornerShape(IMAGE_RADIUS))
} }
), )
.then(sharedModifier)
.clip(RoundedCornerShape(IMAGE_RADIUS)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
DecryptedImageContent( DecryptedImageContent(
messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
file = file, file = file,
envelope = dmEnvelope, envelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail, thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio aspectRatio = fileAspectRatio,
sharedImageKey = sharedImageKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onFullyLoaded = { isFullyLoaded = it }
) )
} }
} }
@@ -235,23 +257,30 @@ private fun InfiniteCircularProgress() {
@Composable @Composable
private fun DecryptedImageContent( private fun DecryptedImageContent(
messageId: Int,
fileIndex: Int,
file: DmFile, file: DmFile,
envelope: DmEnvelope, envelope: DmEnvelope,
currentUserId: Int?, currentUserId: Int?,
thumbnailBase64: String, 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) { val thumbnailBytes = remember(thumbnailBase64) {
runCatching { Base64.decode(thumbnailBase64) }.getOrNull() runCatching { Base64.decode(thumbnailBase64) }.getOrNull()
} }
LaunchedEffect(file.path) { LaunchedEffect(messageId, fileIndex, file.path, envelope) {
Logger.d("AttachmentPreview", "DecryptedImageContent: fetching full image path=${file.path}") fullBytes = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
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(fullBytes) {
onFullyLoaded(fullBytes != null)
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -270,8 +299,9 @@ private fun DecryptedImageContent(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
val thumbState by thumbPainter.state.collectAsState() val thumbState by thumbPainter.state.collectAsState()
when (thumbState) { val showThumbnailLoading = fullBytes == null && thumbState is coil3.compose.AsyncImagePainter.State.Loading
is coil3.compose.AsyncImagePainter.State.Loading -> { when {
showThumbnailLoading -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -279,7 +309,7 @@ private fun DecryptedImageContent(
InfiniteCircularProgress() InfiniteCircularProgress()
} }
} }
is coil3.compose.AsyncImagePainter.State.Success -> { thumbState is coil3.compose.AsyncImagePainter.State.Success -> {
Image( Image(
painter = thumbPainter, painter = thumbPainter,
contentDescription = file.name, contentDescription = file.name,
@@ -292,7 +322,7 @@ private fun DecryptedImageContent(
if (fullBytes != null) { if (fullBytes != null) {
val fullPainter = rememberAsyncImagePainter( val fullPainter = rememberAsyncImagePainter(
model = fullBytes, model = fullBytes,
contentScale = ContentScale.Crop contentScale = ContentScale.FillWidth
) )
val fullState by fullPainter.state.collectAsState() val fullState by fullPainter.state.collectAsState()
when (fullState) { when (fullState) {
@@ -308,7 +338,7 @@ private fun DecryptedImageContent(
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.alpha(alpha.value), .alpha(alpha.value),
contentScale = ContentScale.Crop contentScale = ContentScale.FillWidth
) )
} }
else -> { } else -> { }
@@ -316,6 +346,9 @@ private fun DecryptedImageContent(
} }
} }
else -> { else -> {
if (fullBytes != null) {
Box(modifier = Modifier.fillMaxSize())
} else {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -328,6 +361,7 @@ private fun DecryptedImageContent(
} }
} }
} }
}
private fun formatFileSize(bytes: Long): String { private fun formatFileSize(bytes: Long): String {
return when { return when {
@@ -1,8 +1,10 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
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.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -79,6 +81,7 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback 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 // Collect WebSocket messages
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message -> 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( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
@@ -537,10 +555,10 @@ fun ChatScreen(
} else { } else {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom) 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(
items = panelState.messages, items = panelState.messages,
@@ -576,16 +594,18 @@ fun ChatScreen(
onTapPosition = { offset -> onTapPosition = { offset ->
tapOffset = offset tapOffset = offset
}, },
onImageClick = { msg, idx -> expandedImage = msg to idx },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
showUsername = panel.showUsernamesInMessages showUsername = panel.showUsernamesInMessages
) )
} }
} }
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
} }
} }
// Context menu
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
MessageContextMenu( MessageContextMenu(
state = contextMenuState, 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 package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility 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.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -44,10 +46,6 @@ 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 { private fun isMessageCorrupted(message: Message): Boolean {
val files = message.files ?: return false val files = message.files ?: return false
return files.withIndex().any { (index, file) -> return files.withIndex().any { (index, file) ->
@@ -65,6 +63,9 @@ fun MessageItem(
isAuthor: Boolean, isAuthor: Boolean,
onLongPress: () -> Unit, onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {}, onTapPosition: (Offset) -> Unit = {},
onImageClick: ((Message, Int) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showUsername: Boolean = true, showUsername: Boolean = true,
currentUserId: Int? = null currentUserId: Int? = null
@@ -239,6 +240,7 @@ fun MessageItem(
) )
} }
message.files?.forEachIndexed { index, file -> message.files?.forEachIndexed { index, file ->
val isImage = isImageFilename(file.name)
AttachmentPreview( AttachmentPreview(
file = file, file = file,
dmEnvelope = message.dmEnvelope, dmEnvelope = message.dmEnvelope,
@@ -248,8 +250,17 @@ fun MessageItem(
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), fileSizeBytes = message.fileSizes?.getOrNull(index),
messageId = if (isImage) message.id else null,
fileIndex = if (isImage) index else null,
isAuthor = isAuthor, 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" -> { "messageEdited" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { editedMsg } updateMessage(editedMsg.id) { editedMsg }
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
@@ -19,6 +19,7 @@ import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.DecryptedImageCache
import ru.fromchat.ui.chat.DmTypingHandler import ru.fromchat.ui.chat.DmTypingHandler
import ru.fromchat.ui.chat.TypingHandler import ru.fromchat.ui.chat.TypingHandler
import ru.fromchat.ui.chat.TypingUser import ru.fromchat.ui.chat.TypingUser
@@ -174,6 +175,7 @@ class DmPanel(
}.getOrNull() ?: return }.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
DecryptedImageCache.invalidateForMessage(envelope.id)
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) { if (plaintext != null) {
val dec = parseDecryptedContent(plaintext) val dec = parseDecryptedContent(plaintext)