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() LaunchedEffect(fullBytes) {
Logger.d("AttachmentPreview", "DecryptedImageContent: full image fetch done path=${file.path} success=${fullBytes != null} size=${fullBytes?.size ?: 0}") 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,11 +346,15 @@ private fun DecryptedImageContent(
} }
} }
else -> { else -> {
Box( if (fullBytes != null) {
modifier = Modifier.fillMaxSize(), Box(modifier = Modifier.fillMaxSize())
contentAlignment = Alignment.Center } else {
) { Box(
InfiniteCircularProgress() modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
InfiniteCircularProgress()
}
} }
} }
} }
@@ -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 ->
@@ -199,7 +208,7 @@ fun ChatScreen(
val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data) val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates") Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
// Process each update in the batch // Process each update in the batch
updatesMessage.updates.forEach { update -> updatesMessage.updates.forEach { update ->
Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}") Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}")
val wsMessage = WebSocketMessage( val wsMessage = WebSocketMessage(
type = update.type, type = update.type,
@@ -291,324 +300,364 @@ fun ChatScreen(
} }
} }
Scaffold( AnimatedContent(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), targetState = expandedImage,
topBar = { modifier = Modifier.fillMaxSize(),
TopAppBar( transitionSpec = {
title = { fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300))
Row( },
modifier = Modifier label = "image_fullscreen"
.fillMaxWidth() ) { expanded ->
.scaleOnPress( if (expanded == null) {
scale = 0.96f, Scaffold(
onClick = if (profileUserId != null && onTitleClick != null) { modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
{ onTitleClick() } topBar = {
} else null TopAppBar(
), title = {
verticalAlignment = Alignment.CenterVertically Row(
) { modifier = Modifier
when { .fillMaxWidth()
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> { .scaleOnPress(
val avatar = panelState.titleAvatar scale = 0.96f,
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() } onClick = if (profileUserId != null && onTitleClick != null) {
?: panelState.title.takeIf { it.isNotBlank() } { onTitleClick() }
?: "?" } else null
with(sharedTransitionScope) { ),
Avatar( verticalAlignment = Alignment.CenterVertically
profilePictureUrl = avatar?.profilePictureUrl, ) {
displayName = displayName, when {
modifier = Modifier sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
.sharedElement( val avatar = panelState.titleAvatar
rememberSharedContentState(key = sharedAvatarKey), val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
animatedVisibilityScope = animatedVisibilityScope ?: panelState.title.takeIf { it.isNotBlank() }
) ?: "?"
.size(36.dp) with(sharedTransitionScope) {
) Avatar(
} profilePictureUrl = avatar?.profilePictureUrl,
Spacer(modifier = Modifier.width(8.dp)) displayName = displayName,
} modifier = Modifier
!hideTitleBarAvatar -> { .sharedElement(
panelState.titleAvatar?.let { avatar -> rememberSharedContentState(key = sharedAvatarKey),
Avatar( animatedVisibilityScope = animatedVisibilityScope
profilePictureUrl = avatar.profilePictureUrl, )
displayName = avatar.displayName, .size(36.dp)
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
) )
} }
) Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(8.dp)) }
} !hideTitleBarAvatar -> {
else -> { panelState.titleAvatar?.let { avatar ->
panelState.titleAvatar?.let { Avatar(
Spacer(modifier = Modifier.width(36.dp)) profilePictureUrl = avatar.profilePictureUrl,
Spacer(modifier = Modifier.width(8.dp)) displayName = avatar.displayName,
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
panelState.titleAvatar?.let {
Spacer(modifier = Modifier.width(36.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
} }
}
}
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( Text(
text = panelState.title, text = panelState.title,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
)
AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "typing_status"
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
) )
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId] AnimatedContent(
if (userStatus != null) { targetState = currentTypingUsers.isNotEmpty(),
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen) transitionSpec = {
if (statusText.isNotEmpty()) { fadeIn() togetherWith fadeOut()
Text( },
text = statusText, label = "typing_status"
style = MaterialTheme.typography.bodySmall, ) { hasTyping ->
color = MaterialTheme.colorScheme.onSurfaceVariant, if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId]
if (userStatus != null) {
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen)
if (statusText.isNotEmpty()) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
} }
} }
} }
} }
} },
} navigationIcon = {
}, IconButton(onClick = { navController.navigateUp() }) {
navigationIcon = { Icon(
IconButton(onClick = { navController.navigateUp() }) { imageVector = Icons.AutoMirrored.Filled.ArrowBack,
Icon( contentDescription = stringResource(Res.string.back)
imageVector = Icons.AutoMirrored.Filled.ArrowBack, )
contentDescription = stringResource(Res.string.back) }
},
actions = {
if (panel.showCallButton()) {
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call"
)
}
}
},
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
) )
} )
}, },
actions = { bottomBar = {
if (panel.showCallButton()) { Column( // New Column to hold ChatInput below the LazyColumn
IconButton(onClick = { /* TODO: Handle call */ }) { modifier = Modifier
Icon( .windowInsetsPadding(WindowInsets.ime)
imageVector = Icons.Default.Call, .fillMaxWidth()
contentDescription = "Call" .hazeEffect(
) state = hazeState,
} style = HazeMaterials.thin()
} ) {
}, progressive = HazeProgressive.verticalGradient(
scrollBehavior = scrollBehavior, startIntensity = 0f,
modifier = Modifier.hazeEffect( endIntensity = 1f
state = hazeState, )
style = HazeMaterials.thin() }
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
},
bottomBar = {
Column( // New Column to hold ChatInput below the LazyColumn
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
) { ) {
progressive = HazeProgressive.verticalGradient( ChatInput(
startIntensity = 0f, text = inputText,
endIntensity = 1f onTextChange = { inputText = it },
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
panel.handleEditMessage(editingMessage!!.id, text)
editingMessage = null
}
} else {
scope.launch {
val replyToId = replyTo?.id
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
content = plaintext.ifBlank { att.filename },
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
profile_picture = null,
verified = null,
reply_to = replyTo,
client_message_id = null,
reactions = null,
files = null,
pendingFileUri = att.uri,
uploadJobId = jobId,
uploadProgress = 0
)
panel.addMessage(optimisticMessage)
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = jobId,
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
plaintext = plaintext.ifBlank { att.filename },
replyToId = replyToId
)
)
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
}
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
}
}
inputText = ""
},
typingHandler = panel.getTypingHandler(),
replyTo = replyTo,
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState,
recipientId = panel.getRecipientId()
) )
} }
) { }
ChatInput( ) { innerPadding ->
text = inputText, Box(
onTextChange = { inputText = it }, modifier = Modifier
onSend = { text, attachments -> .fillMaxSize()
if (editingMessage != null) { .pointerInput(Unit) {
scope.launch { detectTapGestures {
panel.handleEditMessage(editingMessage!!.id, text) // Close context menu on outside tap
editingMessage = null if (contextMenuState.isOpen) {
} contextMenuState = contextMenuState.copy(isOpen = false)
} else {
scope.launch {
val replyToId = replyTo?.id
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
content = plaintext.ifBlank { att.filename },
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
profile_picture = null,
verified = null,
reply_to = replyTo,
client_message_id = null,
reactions = null,
files = null,
pendingFileUri = att.uri,
uploadJobId = jobId,
uploadProgress = 0
)
panel.addMessage(optimisticMessage)
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = jobId,
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
plaintext = plaintext.ifBlank { att.filename },
replyToId = replyToId
)
)
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
} }
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
} }
} }
inputText = "" ) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
items(
items = panelState.messages,
key = { it.id }
) { message ->
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
currentUserId = currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
)
},
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())) }
}
}
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
},
onEdit = { message ->
editingMessage = message
inputText = message.content
replyTo = null
},
onDelete = { message ->
scope.launch {
panel.handleDeleteMessage(message.id)
}
},
)
}
}
} 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
}, },
typingHandler = panel.getTypingHandler(), onDelete = { m ->
replyTo = replyTo, scope.launch {
editingMessage = editingMessage, panel.handleDeleteMessage(m.id)
onClearReply = { replyTo = null }, }
onClearEdit = {
editingMessage = null
inputText = ""
}, },
hazeState = hazeState, onSave = { _, _ -> /* TODO: platform-specific save to gallery */ },
recipientId = panel.getRecipientId() sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
sharedImageKey = "img_${msg.id}_$idx",
modifier = Modifier.fillMaxSize()
) )
} }
} }
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
items(
items = panelState.messages,
key = { it.id }
) { message ->
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
currentUserId = currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
)
},
onTapPosition = { offset ->
tapOffset = offset
},
showUsername = panel.showUsernamesInMessages
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
}
}
// Context menu
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
},
onEdit = { message ->
editingMessage = message
inputText = message.content
replyTo = null
},
onDelete = { message ->
scope.launch {
panel.handleDeleteMessage(message.id)
}
},
)
}
} }
} }
@@ -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)