diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 426f11c..e2718fc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -331,6 +331,31 @@ object ApiClient { } } + /** + * Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg". + * Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api. + * @param thumb if true, appends ?thumb=1 for thumbnail (JPEG, ~5 KB) + */ + suspend fun fetchEncryptedFile(path: String, thumb: Boolean = false): ByteArray { + val baseUrl = when { + path.startsWith("http") -> path + path.startsWith("/api") -> { + val serverBase = Config.apiBaseUrl.removeSuffix("/api") + "$serverBase$path" + } + else -> "${Config.apiBaseUrl}$path" + } + val url = if (thumb) "$baseUrl?thumb=1" else baseUrl + return http.get(url).body() + } + + /** + * Fetch thumbnail for an image file. Returns null if thumbnail not available (404). + */ + suspend fun fetchThumbnail(path: String): ByteArray? = runCatching { + fetchEncryptedFile(path, thumb = true) + }.getOrNull() + /** * Edit an existing direct message using the same transport encryption scheme as /dm/send. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 41f4464..48298a5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -92,7 +92,16 @@ data class Message( val verified: Boolean? = null, val reply_to: Message? = null, val client_message_id: String? = null, - val reactions: List? = null + val reactions: List? = null, + val files: List? = null, + /** For optimistic UI: local URI when sending, null when confirmed. */ + val pendingFileUri: String? = null, + /** For optimistic UI: jobId to track upload progress. */ + val uploadJobId: String? = null, + /** For optimistic UI: 0-100 upload progress, null when complete. */ + val uploadProgress: Int? = null, + /** For DM file decryption; not serialized over network. */ + @kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt index 1e5da5b..4c261be 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt @@ -46,3 +46,25 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String { val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek) return plaintext.decodeToString() } + +/** + * Decrypt a DM file attachment. Fetches encrypted bytes, unwraps file MEK, decrypts. + */ +suspend fun decryptFile( + file: ru.fromchat.api.DmFile, + envelope: DmEnvelope, + currentUserId: Int? +): ByteArray { + val wrappedMekB64 = file.wrappedMekB64 + ?: envelope.files?.find { it.path == file.path }?.wrappedMekB64 + ?: envelope.wrappedMekB64 + ?: throw IllegalArgumentException("No MEK available for file decryption: ${file.path}") + val nonceB64 = file.nonceB64 + ?: envelope.files?.find { it.path == file.path }?.nonceB64 + ?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}") + + val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) + val encryptedBytes = ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path) + val ciphertextB64 = com.pr0gramm3r101.utils.crypto.Base64.encode(encryptedBytes) + return DmCrypto.decryptEnvelope(nonceB64, ciphertextB64, mek) +} 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 new file mode 100644 index 0000000..1fde985 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -0,0 +1,249 @@ +package ru.fromchat.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import ru.fromchat.api.ApiClient +import ru.fromchat.api.DmEnvelope +import ru.fromchat.api.DmFile +import ru.fromchat.crypto.decryptFile + +private val IMAGE_SIZE = 160.dp +private val IMAGE_RADIUS = 8.dp + +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) + +@Composable +fun AttachmentPreview( + file: DmFile?, + dmEnvelope: DmEnvelope?, + currentUserId: Int?, + pendingFileUri: String?, + isUploading: Boolean, + modifier: Modifier = Modifier +) { + val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let { + it.contains("image", ignoreCase = true) || it.endsWith(".jpg", true) || + it.endsWith(".png", true) || it.endsWith(".jpeg", true) || + it.endsWith(".gif", true) || it.endsWith(".webp", true) + } ?: false + + Box( + modifier = modifier + .size(IMAGE_SIZE) + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + when { + pendingFileUri != null -> { + PendingImageContent( + uri = pendingFileUri, + isUploading = isUploading, + isImage = isImage + ) + } + file != null && isImage && dmEnvelope != null -> { + DecryptedImageContent( + file = file, + envelope = dmEnvelope, + currentUserId = currentUserId + ) + } + file != null && !isImage -> { + FileIconContent(filename = file.name) + } + else -> { + Icon( + imageVector = Icons.Default.Image, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PendingImageContent( + uri: String, + isUploading: Boolean, + isImage: Boolean +) { + if (isImage) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .then(if (isUploading) Modifier.blur(8.dp) else Modifier), + contentScale = ContentScale.Crop + ) + AnimatedVisibility( + visible = isUploading, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(300)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + InfiniteCircularProgress() + } + } + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isUploading) { + InfiniteCircularProgress() + } else { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun InfiniteCircularProgress() { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp + ) +} + +@Composable +private fun DecryptedImageContent( + file: DmFile, + envelope: DmEnvelope, + currentUserId: Int? +) { + var thumbnailBytes by remember(file.path) { mutableStateOf(null) } + var fullBytes by remember(file.path) { mutableStateOf(null) } + var imageReadyToUnblur by remember(file.path) { mutableStateOf(false) } + + LaunchedEffect(file.path) { + withContext(Dispatchers.Default) { + coroutineScope { + val thumbDeferred = async { + ApiClient.fetchThumbnail(file.path) + } + val fullDeferred = async { + runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull() + } + thumbnailBytes = thumbDeferred.await() + fullBytes = fullDeferred.await() + } + } + } + + val hasThumb = thumbnailBytes != null + val hasFull = fullBytes != null + val showContent = hasThumb || hasFull + + if (!showContent) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + return + } + + LaunchedEffect(hasFull) { + if (hasFull) { + delay(80) + imageReadyToUnblur = true + } + } + val blurProgress by animateFloatAsState( + targetValue = if (imageReadyToUnblur) 0f else 1f, + animationSpec = tween(300), + label = "blur" + ) + val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() } + val displayBytes = fullBytes ?: thumbnailBytes!! + + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = displayBytes, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier), + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun FileIconContent(filename: String) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = filename.take(20) + if (filename.length > 20) "…" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 16574bd..bd140fa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -87,7 +87,7 @@ abstract class ChatPanel( * Add message to list. Mutex prevents duplicate adds when same update * is processed concurrently from multiple WebSocket connections. */ - protected suspend fun addMessage(message: Message) { + suspend fun addMessage(message: Message) { addMessageMutex.withLock { val messageExists = _state.messages.any { it.id == message.id } if (!messageExists) { @@ -104,9 +104,9 @@ abstract class ChatPanel( } /** - * Update existing message + * Update existing message (public for ChatScreen optimistic UI) */ - protected fun updateMessage(messageId: Int, updates: (Message) -> Message) { + fun updateMessage(messageId: Int, updates: (Message) -> Message) { updateState { currentState -> currentState.copy( messages = currentState.messages.map { msg -> 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 00aa941..8cf3d98 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 @@ -273,6 +273,24 @@ fun ChatScreen( } } + LaunchedEffect(panel) { + if (panel.getRecipientId() != null) { + AttachmentUploadQueue.progressFlow.collect { progress -> + when (progress) { + is ru.fromchat.api.AttachmentUploadProgress.InProgress -> + panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) { + if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = progress.percent) else it + } + is ru.fromchat.api.AttachmentUploadProgress.Success -> + panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) { + if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = null) else it + } + else -> {} + } + } + } + } + Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -442,9 +460,30 @@ fun ChatScreen( 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 = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}", + jobId = jobId, fileUri = att.uri, filename = att.filename, recipientId = recipientId, @@ -523,6 +562,7 @@ fun ChatScreen( MessageItem( message = message, isAuthor = message.user_id == currentUserId, + currentUserId = currentUserId, onLongPress = { contextMenuState = ContextMenuState( isOpen = true, 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 4eb50c3..f5a0efd 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 @@ -52,7 +52,8 @@ fun MessageItem( onLongPress: () -> Unit, onTapPosition: (Offset) -> Unit = {}, modifier: Modifier = Modifier, - showUsername: Boolean = true + showUsername: Boolean = true, + currentUserId: Int? = null ) { AnimatedVisibility( visible = true, @@ -199,17 +200,39 @@ fun MessageItem( } } - // Message content - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = if (isAuthor) { - Color.White - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = Modifier.padding(horizontal = 12.dp) - ) + // Attachments (images/files) + if (message.pendingFileUri != null) { + AttachmentPreview( + file = null, + dmEnvelope = null, + currentUserId = null, + pendingFileUri = message.pendingFileUri, + isUploading = message.uploadProgress != null, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + message.files?.forEach { file -> + AttachmentPreview( + file = file, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = null, + isUploading = false, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + if (message.content.isNotBlank()) { + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = if (isAuthor) { + Color.White + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } // Timestamp and edited indicator Row( 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 464f703..8ba5e58 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 @@ -146,6 +146,10 @@ class DmPanel( scope.launch(Dispatchers.Default) { val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() if (plaintext != null) { + if (envelope.senderId == currentUserId) { + val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp } + oldestOptimistic?.let { removeMessage(it.id) } + } addMessage(createMessage(envelope, plaintext)) if (envelope.replyToId != null) { val replyTo = _state.messages.find { it.id == envelope.replyToId } @@ -188,7 +192,9 @@ class DmPanel( verified = null, reply_to = null, client_message_id = null, - reactions = null + reactions = null, + files = envelope.files, + dmEnvelope = envelope ) }