diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 4b6d8ff..c0d9c36 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 4f7bd2b..e93f250 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -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(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,11 +346,15 @@ private fun DecryptedImageContent( } } else -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - InfiniteCircularProgress() + if (fullBytes != null) { + Box(modifier = Modifier.fillMaxSize()) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + InfiniteCircularProgress() + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 8cf3d98..3c5ea76 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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?>(null) } + + BackHandler(enabled = expandedImage != null) { + expandedImage = null + } + // Collect WebSocket messages LaunchedEffect(Unit) { WebSocketManager.messages.collect { message -> @@ -199,7 +208,7 @@ fun ChatScreen( val updatesMessage = json.decodeFromJsonElement(data) Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates") // 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}") val wsMessage = WebSocketMessage( type = update.type, @@ -291,324 +300,364 @@ fun ChatScreen( } } - Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier - .fillMaxWidth() - .scaleOnPress( - scale = 0.96f, - onClick = if (profileUserId != null && onTitleClick != null) { - { onTitleClick() } - } else null - ), - verticalAlignment = Alignment.CenterVertically - ) { - when { - sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> { - val avatar = panelState.titleAvatar - val displayName = avatar?.displayName?.takeIf { it.isNotBlank() } - ?: panelState.title.takeIf { it.isNotBlank() } - ?: "?" - with(sharedTransitionScope) { - Avatar( - profilePictureUrl = avatar?.profilePictureUrl, - displayName = displayName, - modifier = Modifier - .sharedElement( - rememberSharedContentState(key = sharedAvatarKey), - animatedVisibilityScope = animatedVisibilityScope - ) - .size(36.dp) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - } - !hideTitleBarAvatar -> { - panelState.titleAvatar?.let { avatar -> - Avatar( - profilePictureUrl = avatar.profilePictureUrl, - 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() - ) + 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 = { + TopAppBar( + title = { + Row( + modifier = Modifier + .fillMaxWidth() + .scaleOnPress( + scale = 0.96f, + onClick = if (profileUserId != null && onTitleClick != null) { + { onTitleClick() } + } else null + ), + verticalAlignment = Alignment.CenterVertically + ) { + when { + sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> { + val avatar = panelState.titleAvatar + val displayName = avatar?.displayName?.takeIf { it.isNotBlank() } + ?: panelState.title.takeIf { it.isNotBlank() } + ?: "?" + with(sharedTransitionScope) { + Avatar( + profilePictureUrl = avatar?.profilePictureUrl, + displayName = displayName, + modifier = Modifier + .sharedElement( + rememberSharedContentState(key = sharedAvatarKey), + animatedVisibilityScope = animatedVisibilityScope + ) + .size(36.dp) ) } - ) - Spacer(modifier = Modifier.width(8.dp)) - } - else -> { - panelState.titleAvatar?.let { - Spacer(modifier = Modifier.width(36.dp)) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) + } + !hideTitleBarAvatar -> { + panelState.titleAvatar?.let { avatar -> + Avatar( + profilePictureUrl = avatar.profilePictureUrl, + 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()) { - Text( - text = panelState.title, - 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) + Column(Modifier.fillMaxWidth()) { + Text( + text = panelState.title, + style = MaterialTheme.typography.titleLarge ) - } 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, + + 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] + 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() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + 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 = { - 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 - ) - ) - }, - bottomBar = { - Column( // New Column to hold ChatInput below the LazyColumn - modifier = Modifier - .windowInsetsPadding(WindowInsets.ime) - .fillMaxWidth() - .hazeEffect( - state = hazeState, - style = HazeMaterials.thin() + 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( + startIntensity = 0f, + endIntensity = 1f + ) + } ) { - progressive = HazeProgressive.verticalGradient( - startIntensity = 0f, - endIntensity = 1f + ChatInput( + text = inputText, + 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( - text = inputText, - 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) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures { + // Close context menu on outside tap + if (contextMenuState.isOpen) { + contextMenuState = contextMenuState.copy(isOpen = false) } - 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(), - replyTo = replyTo, - editingMessage = editingMessage, - onClearReply = { replyTo = null }, - onClearEdit = { - editingMessage = null - inputText = "" + onDelete = { m -> + scope.launch { + panel.handleDeleteMessage(m.id) + } }, - hazeState = hazeState, - recipientId = panel.getRecipientId() + onSave = { _, _ -> /* TODO: platform-specific save to gallery */ }, + 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) - } - }, - ) - } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt new file mode 100644 index 0000000..cb33483 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt @@ -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() + 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)) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt new file mode 100644 index 0000000..1be5fde --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -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 + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 32541f4..f4cc8c6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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 + ) ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index b860507..6dbed51 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -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" -> { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 156bafd..c9cb458 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -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)