From f90e2cafef65c1d8e153517b425b6652c0e9ff95 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 11 Jul 2026 23:49:47 +0300 Subject: [PATCH] Implement attachements in public chat Signed-off-by: denis0001-dev --- .../cache/AttachmentDiskThumbnail.android.kt | 21 + .../LocalDecodedImageCache.android.kt | 6 +- .../LocalImageFileDimensions.android.kt | 23 + .../kotlin/ru/fromchat/api/ApiClient.kt | 73 +++ .../fromchat/api/local/AttachmentMediaLog.kt | 6 + .../local/cache/AttachmentDiskThumbnail.kt | 14 + .../api/local/cache/DecryptedFileCache.kt | 178 +++++- .../api/local/cache/DecryptedImageCache.kt | 301 +++++++++- .../api/local/cache/ResumableUploadStorage.kt | 30 + .../api/local/db/DmStoredMessageContent.kt | 131 +++++ .../api/local/db/store/MessageCacheStore.kt | 199 +++++-- .../local/download/LocalDecodedImageCache.kt | 4 + .../download/LocalImageFileDimensions.kt | 4 + .../api/local/download/MessageImageSave.kt | 13 +- .../local/send/DmAttachmentOutboxHandler.kt | 5 +- .../api/local/send/OutboundFileStaging.kt | 7 +- .../api/local/send/OutboundImageStaging.kt | 17 +- .../fromchat/api/local/send/OutboxPayloads.kt | 12 + .../local/send/OutgoingMessageCoordinator.kt | 87 ++- .../send/PublicAttachmentOutboxHandler.kt | 460 +++++++++++++++ .../workers/AttachmentTransferBootstrap.kt | 15 +- .../local/workers/AttachmentUploadNotifier.kt | 26 +- .../fromchat/api/schema/messages/Message.kt | 19 +- .../publicchat/PublicAttachmentLayout.kt | 40 ++ .../messages/publicchat/SendMessageRequest.kt | 3 +- .../upload/PublicUploadChunkRequest.kt | 10 + .../upload/PublicUploadChunkResponse.kt | 10 + .../upload/PublicUploadCompleteResponse.kt | 10 + .../upload/PublicUploadInitRequest.kt | 11 + .../upload/PublicUploadInitResponse.kt | 11 + .../upload/PublicUploadStatusResponse.kt | 12 + .../ru/fromchat/ui/chat/AttachmentPreview.kt | 547 ++++++++++-------- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 4 +- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 222 ++++++- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 185 +++++- .../ui/chat/ImageFullscreenPreview.kt | 147 +++-- .../fromchat/ui/chat/MessageDissolveEffect.kt | 17 + .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 84 ++- .../chat/components/ChatFileAttachmentTile.kt | 50 +- .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 5 +- .../chat/panels/publicchat/PublicChatPanel.kt | 79 ++- .../ui/chat/utils/AttachmentImageGeometry.kt | 119 +++- .../fromchat/ui/chat/utils/MessageUiMerge.kt | 67 ++- .../cache/AttachmentDiskThumbnail.ios.kt | 42 ++ .../download/LocalImageFileDimensions.ios.kt | 3 + 45 files changed, 2859 insertions(+), 470 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/PublicAttachmentOutboxHandler.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/PublicAttachmentLayout.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkRequest.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkResponse.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadCompleteResponse.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitRequest.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitResponse.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadStatusResponse.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDissolveEffect.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.android.kt new file mode 100644 index 0000000..b294fc4 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.android.kt @@ -0,0 +1,21 @@ +package ru.fromchat.api.local.cache + +import java.io.FileOutputStream + +actual fun generateAttachmentDiskThumbnail( + sourceAbsolutePath: String, + destAbsolutePath: String, + maxEdgePx: Int, +): Boolean { + val bitmap = ru.fromchat.api.local.download.decodeSampledImageFile( + sourceAbsolutePath, + maxEdgePx, + maxEdgePx, + ) ?: return false + return runCatching { + FileOutputStream(destAbsolutePath).use { stream -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, stream) + } + true + }.getOrDefault(false) +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt index 4796dc1..2713c5e 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt @@ -39,7 +39,11 @@ actual object PlatformDecodedBitmapCache { } actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? = - decodeSampledFromFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap() + decodeSampledImageFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap() + +/** Sampled decode for disk thumbnail generation and tile loads. */ +internal fun decodeSampledImageFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? = + decodeSampledFromFile(path, reqWidthPx, reqHeightPx) actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? = decodeSampledFromBytes(bytes, reqWidthPx, reqHeightPx)?.asImageBitmap() diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt new file mode 100644 index 0000000..4e75430 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt @@ -0,0 +1,23 @@ +package ru.fromchat.api.local.download + +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface + +internal actual fun readLocalImageDimensions(absolutePath: String): Pair? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val orientation = runCatching { + ExifInterface(absolutePath).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + return when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth + else -> bounds.outWidth to bounds.outHeight + } +} 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 a85d981..c74339b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -33,6 +33,8 @@ import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.MainScope @@ -81,6 +83,12 @@ import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadChunkRequest +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadChunkResponse +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadCompleteResponse +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadInitRequest +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadInitResponse +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadStatusResponse import ru.fromchat.api.schema.server.RegisteredUserCountResponse import ru.fromchat.api.schema.server.ServerInstanceIdResponse import ru.fromchat.api.schema.server.TransportKeyResponse @@ -796,6 +804,54 @@ object ApiClient { } } + suspend fun initPublicUpload( + filename: String, + totalSize: Long, + chunkSize: Int? = null, + ): PublicUploadInitResponse = + http.post("${ServerConfig.apiBaseUrl}/public/upload/init") { + contentType(ContentType.Application.Json) + setBody( + PublicUploadInitRequest( + filename = filename, + totalSize = totalSize, + chunkSize = chunkSize, + ), + ) + }.body() + + suspend fun getPublicUploadStatus(uploadId: String): PublicUploadStatusResponse = + http.get("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") { + contentType(ContentType.Application.Json) + }.body() + + suspend fun uploadPublicChunk( + uploadId: String, + offset: Long, + dataB64: String, + ): PublicUploadChunkResponse = + http.patch("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") { + contentType(ContentType.Application.Json) + setBody( + PublicUploadChunkRequest( + offset = offset, + dataB64 = dataB64, + ), + ) + }.body() + + suspend fun completePublicUpload(uploadId: String): PublicUploadCompleteResponse = + http.post("${ServerConfig.apiBaseUrl}/public/upload/$uploadId/complete") { + contentType(ContentType.Application.Json) + setBody(mapOf("upload_id" to uploadId)) + }.body() + + suspend fun abortPublicUpload(uploadId: String) { + http.delete("${ServerConfig.apiBaseUrl}/public/upload/$uploadId") { + contentType(ContentType.Application.Json) + } + } + /** * 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. @@ -809,6 +865,9 @@ object ApiClient { else -> "${ServerConfig.apiBaseUrl}$path" } + /** Plain public-chat attachment URL (same path resolution as [encryptedFileUrl]). */ + fun normalFileUrl(path: String): String = encryptedFileUrl(path) + /** Encrypted ciphertext stored on disk after a resumable download. */ data class EncryptedFileOnDisk( val path: String, @@ -1448,6 +1507,9 @@ object ApiClient { content: String, replyToId: Int? = null, clientMessageId: String? = null, + uploadedFileIds: List = emptyList(), + fileBytes: ByteArray? = null, + filename: String? = null, ): ru.fromchat.api.schema.messages.Message { if (_suspensionState.value.isSuspended) { throw IllegalStateException("Account suspended") @@ -1457,12 +1519,23 @@ object ApiClient { content = content.trim(), reply_to_id = replyToId, client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }, + uploaded_file_ids = uploadedFileIds.takeIf { it.isNotEmpty() }, ), ) val response = http.submitFormWithBinaryData( url = "${ServerConfig.apiBaseUrl}/send_message", formData = formData { append("payload", payloadJson) + if (fileBytes != null && filename != null) { + append( + "files", + fileBytes, + Headers.build { + append(HttpHeaders.ContentType, "application/octet-stream") + append(HttpHeaders.ContentDisposition, "filename=\"$filename\"") + }, + ) + } }, ).body() if (!response.status.equals("success", ignoreCase = true)) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt index 82bd364..53ba617 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/AttachmentMediaLog.kt @@ -22,6 +22,12 @@ object AttachmentMediaLog { fun persist(message: String, vararg fields: Pair) = log("PERSIST", message, fields) + /** Image tile layout aspect ratio (upload → confirm). Filter: `adb logcat -s AttachmentMedia`. */ + fun aspect(message: String, vararg fields: Pair) = log("ASPECT", message, fields) + + /** End-to-end outbound send timeline (tap → confirmed). Filter logcat: `AttachmentMedia`. */ + fun send(message: String, vararg fields: Pair) = log("SEND", message, fields) + fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() /** Short message text for download/upload log lines (not for UI). */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.kt new file mode 100644 index 0000000..c4abdde --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.kt @@ -0,0 +1,14 @@ +package ru.fromchat.api.local.cache + +/** Matches server public-chat thumbnail long edge ([_THUMB_SIZE] in messaging.py). */ +const val ATTACHMENT_DISK_THUMB_MAX_EDGE_PX = 80 + +/** + * Writes a tiny JPEG next to a cached full image for instant cold-start tiles. + * Returns true when [destAbsolutePath] exists after the call. + */ +expect fun generateAttachmentDiskThumbnail( + sourceAbsolutePath: String, + destAbsolutePath: String, + maxEdgePx: Int = ATTACHMENT_DISK_THUMB_MAX_EDGE_PX, +): Boolean diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt index e063483..37ea2a2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt @@ -46,7 +46,17 @@ object DecryptedFileCache { fileIndex: Int, clientMessageId: String? = null, ): String? { - val key = storageKey(messageId, fileIndex, clientMessageId) + val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + if (cid != null) { + val clientKey = storageKey(-1, fileIndex, cid) + memoryCache[clientKey]?.takeIf { uriFileExists(it) }?.let { return it } + readDisk(clientKey)?.let { return it } + } + val key = if (messageId > 0) { + storageKey(messageId, fileIndex, null) + } else { + storageKey(messageId, fileIndex, cid) + } memoryCache[key]?.takeIf { uriFileExists(it) }?.let { return it } return readDisk(key) } @@ -116,7 +126,7 @@ object DecryptedFileCache { val idKey = storageKey(messageId, fileIndex, null) if (getCached(messageId, fileIndex, null) != null) return val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return - val cidKey = storageKey(messageId, fileIndex, cid) + val cidKey = storageKey(-1, fileIndex, cid) val sourceUri = cacheMutex.withLock { resolveUriLocked(cidKey) } ?: readDisk(cidKey) ?: return @@ -223,6 +233,170 @@ object DecryptedFileCache { } } + /** + * Downloads a plain (non-encrypted) public attachment into the file cache. + */ + suspend fun getOrDownloadPlain( + messageId: Int, + fileIndex: Int, + file: DmFile, + clientMessageId: String? = null, + messageLabel: String? = null, + ): String? { + val key = storageKey(messageId, fileIndex, clientMessageId) + getCached(messageId, fileIndex, clientMessageId)?.let { return it } + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + + val label = AttachmentMediaLog.messageLabel(messageLabel) + AttachmentDownloadNotifier.beginDownload( + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + + return withContext(Dispatchers.Default) { + runCatching { + AttachmentDownloadScheduler.run( + storageKey = key, + messageId = messageId, + keepAliveInBackground = true, + work = { + downloadPlainAndPersist( + key = key, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + file = file, + messageLabel = label, + ) + }, + ) + }.onFailure { error -> + if (error !is CancellationException) { + ApiClient.clearPartialEncryptedDownload(key) + } + }.getOrNull() + } + } + + private suspend fun downloadPlainAndPersist( + key: String, + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + file: DmFile, + messageLabel: String?, + ): String? { + ensureAttachmentDownloadActive(key) + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, 1), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + + val outputPath = diskPath(key, file.name) + if (outputPath == null) { + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + return null + } + + val downloaded = try { + ApiClient.fetchEncryptedFileResumable( + path = file.path, + resumeKey = key, + onProgress = { percent -> + checkAttachmentDownloadActive(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + }, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (PlatformFileSystem.exists(outputPath)) { + PlatformFileSystem.delete(outputPath) + } + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, error.message ?: "download_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + return null + } + + ensureAttachmentDownloadActive(key) + if (downloaded.path != outputPath) { + runCatching { + copyOutboundFileToPath("file://${downloaded.path}", outputPath) + }.onFailure { + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + return null + } + } + ApiClient.clearPartialEncryptedDownload(key) + val uri = cacheMutex.withLock { + resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath) + } + if (uri == null) { + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + return null + } + DownloadedFileRegistry.setExportUri( + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + exportUri = uri, + ) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + mirrorAsFileAttachment = true, + ) + PendingFileSaveRegistry.onCacheReady(key) + return uri + } + private suspend fun decryptAndPersist( key: String, messageId: Int, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt index 1eeaee6..50a461e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt @@ -19,6 +19,8 @@ import ru.fromchat.api.local.download.ensureAttachmentDownloadActive import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility import ru.fromchat.api.local.cache.DecryptedImageCache.storageKey import ru.fromchat.api.local.download.LocalDecodedImageCache +import ru.fromchat.api.local.download.ChatPreviewDecodeSize +import ru.fromchat.api.local.download.decodeLocalImageFile /** * Disk + in-memory cache for decrypted DM images. @@ -26,6 +28,7 @@ import ru.fromchat.api.local.download.LocalDecodedImageCache */ object DecryptedImageCache { private const val SUBDIR = "decrypted_images" + private const val DISK_THUMB_SUFFIX = "_thumb.jpg" /** True when [uri] points at a file under this cache (safe to persist for offline preview). */ fun isDecryptedImageCacheUri(uri: String?): Boolean { @@ -93,14 +96,101 @@ object DecryptedImageCache { clientMessageId: String? = null, ): String? { val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + // Always try the client-id key first — outbound seeds live there until aliasing. if (cid != null) { + readDisk(storageKey(-1, fileIndex, cid))?.let { return it } + } + if (messageId > 0) { + readDisk(storageKey(messageId, fileIndex, null))?.let { return it } + } else { readDisk(storageKey(messageId, fileIndex, cid))?.let { return it } } - return readDisk(storageKey(messageId, fileIndex, null)) + return null } fun getUriForStorageKey(storageKey: String): String? = readDisk(storageKey) + /** Synchronous lookup for the tiny on-disk JPEG written with the full cache file. */ + fun getCachedThumbUri( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ): String? { + for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) { + readThumbDisk(lookupKey)?.let { return it } + } + return null + } + + suspend fun ensureDiskThumbIfNeeded( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ) { + if (getCachedThumbUri(messageId, fileIndex, clientMessageId) != null) return + withContext(Dispatchers.Default) { + cacheMutex.withLock { + for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) { + val thumbPath = thumbDiskPath(lookupKey) ?: continue + if (PlatformFileSystem.exists(thumbPath)) return@withContext + val fullPath = diskPath(lookupKey)?.takeIf { PlatformFileSystem.exists(it) } + ?: continue + writeDiskThumbLocked(lookupKey, fullPath) + return@withContext + } + } + } + } + + suspend fun decodePlaceholderThumb( + memoryKey: String, + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + serverThumbBytes: ByteArray?, + ): androidx.compose.ui.graphics.ImageBitmap? = withContext(Dispatchers.Default) { + peekPlaceholderThumb(memoryKey, messageId, fileIndex, clientMessageId)?.let { + return@withContext it + } + // Prefer server base64 before generating a disk thumb from a huge full file. + val thumbBytes = serverThumbBytes?.takeIf { it.isNotEmpty() } + if (thumbBytes != null) { + val target = ChatPreviewDecodeSize( + ATTACHMENT_DISK_THUMB_MAX_EDGE_PX, + ATTACHMENT_DISK_THUMB_MAX_EDGE_PX, + ) + LocalDecodedImageCache.loadThumb(memoryKey, thumbBytes, target)?.let { + return@withContext it + } + } + ensureDiskThumbIfNeeded(messageId, fileIndex, clientMessageId) + peekPlaceholderThumb(memoryKey, messageId, fileIndex, clientMessageId) + } + + /** Sync disk/memory thumb for first composition (avoids empty tile before produceState). */ + fun peekPlaceholderThumb( + memoryKey: String, + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + ): androidx.compose.ui.graphics.ImageBitmap? { + LocalDecodedImageCache.peekThumb(memoryKey)?.let { return it } + getCachedThumbUri(messageId, fileIndex, clientMessageId)?.let { uri -> + val path = uri.removePrefix("file://") + if (path.isNotEmpty()) { + val target = ChatPreviewDecodeSize( + ATTACHMENT_DISK_THUMB_MAX_EDGE_PX, + ATTACHMENT_DISK_THUMB_MAX_EDGE_PX, + ) + decodeLocalImageFile(path, target.widthPx, target.heightPx)?.let { bitmap -> + LocalDecodedImageCache.putThumb(memoryKey, bitmap) + return bitmap + } + } + } + return null + } + fun messageIdFromStorageKey(storageKey: String): Int? { if (!storageKey.startsWith("img_") || storageKey.startsWith("img_c_")) return null return storageKey.removePrefix("img_").substringBefore('_').toIntOrNull() @@ -116,7 +206,7 @@ object DecryptedImageCache { val idKey = storageKey(messageId, fileIndex, null) if (readDisk(idKey) != null) return val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return - val cidKey = storageKey(messageId, fileIndex, cid) + val cidKey = storageKey(-1, fileIndex, cid) val sourceUri = readDisk(cidKey) ?: return val bytes = runCatching { readOutboundFileBytes(sourceUri) @@ -126,13 +216,16 @@ object DecryptedImageCache { cacheMutex.withLock { if (readDisk(idKey) == null) { writeCacheLocked(idKey, bytes) - AttachmentMediaLog.diskCache( - "alias_ok", - "from" to cidKey, - "to" to idKey, - "bytes" to bytes.size, - ) } + diskPath(idKey)?.let { fullPath -> + writeDiskThumbLocked(idKey, fullPath) + } + AttachmentMediaLog.diskCache( + "alias_ok", + "from" to cidKey, + "to" to idKey, + "bytes" to bytes.size, + ) } } } @@ -234,6 +327,178 @@ object DecryptedImageCache { return uri } + /** + * Downloads a plain (non-encrypted) public attachment into the image cache. + * Reuses the resumable Range download path without decrypt. + */ + suspend fun getOrDownloadPlain( + messageId: Int, + fileIndex: Int, + file: DmFile, + clientMessageId: String? = null, + messageLabel: String? = null, + ): String? { + val key = storageKey(messageId, fileIndex, clientMessageId) + getCached(messageId, fileIndex, clientMessageId)?.let { uri -> + AttachmentMediaLog.diskCache( + "getOrDownloadPlain_hit", + "key" to key, + "msgId" to messageId, + "uri" to uri, + ) + return uri + } + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + + val label = AttachmentMediaLog.messageLabel(messageLabel) + AttachmentDownloadNotifier.beginDownload( + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + val uri = withContext(Dispatchers.Default) { + runCatching { + AttachmentDownloadScheduler.run( + storageKey = key, + messageId = messageId, + work = { + downloadPlainAndPersist( + key = key, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + file = file, + messageLabel = label, + ) + }, + ) + }.onFailure { error -> + if (error !is CancellationException) { + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed( + storageKey = key, + error = error.message ?: "download_failed", + ), + messageLabel = label, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + } + }.getOrNull() + } + if (uri != null && messageId > 0) { + ensureDiskAliasForMessageId(messageId, fileIndex, clientMessageId) + } + return uri + } + + private suspend fun downloadPlainAndPersist( + key: String, + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + file: DmFile, + messageLabel: String?, + ): String? { + ensureAttachmentDownloadActive(key) + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, 1), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + val t0 = AttachmentMediaLog.nowMs() + val outputPath = diskPath(key) + if (outputPath == null) { + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + return null + } + val downloaded = runCatching { + ApiClient.fetchEncryptedFileResumable( + path = file.path, + resumeKey = key, + onProgress = { percent -> + checkAttachmentDownloadActive(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + }, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, error.message ?: "download_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + }.getOrNull() ?: return null + + ensureAttachmentDownloadActive(key) + if (downloaded.path != outputPath) { + runCatching { + copyOutboundFileToPath("file://${downloaded.path}", outputPath) + }.onFailure { + ApiClient.clearPartialEncryptedDownload(key) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + return null + } + } + ApiClient.clearPartialEncryptedDownload(key) + val uri = cacheMutex.withLock { + resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath) + } + if (uri == null) { + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + return null + } + AttachmentMediaLog.download( + "plain_persist_ok", + "key" to key, + "bytes" to downloaded.sizeBytes, + "ms" to (AttachmentMediaLog.nowMs() - t0), + "uri" to uri, + "msg" to messageLabel, + ) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + return uri + } + suspend fun invalidateForMessage(messageId: Int) { val dir = ensureCacheDir() ?: return withContext(Dispatchers.Default) { @@ -272,6 +537,7 @@ object DecryptedImageCache { withContext(Dispatchers.Default) { cacheMutex.withLock { memoryCache.remove(key) } diskPath(key)?.let { invalidatePath(it) } + thumbDiskPath(key)?.let { invalidatePath(it) } LocalDecodedImageCache.evict(key) } } @@ -470,6 +736,23 @@ object DecryptedImageCache { return "$dir/$storageKey" } + private fun thumbDiskPath(storageKey: String): String? { + val dir = ensureCacheDir() ?: return null + return "$dir/$storageKey$DISK_THUMB_SUFFIX" + } + + private fun readThumbDisk(storageKey: String): String? { + val path = thumbDiskPath(storageKey) ?: return null + if (!PlatformFileSystem.exists(path)) return null + return "file://$path" + } + + private fun writeDiskThumbLocked(storageKey: String, sourceAbsolutePath: String) { + val dest = thumbDiskPath(storageKey) ?: return + if (PlatformFileSystem.exists(dest)) return + generateAttachmentDiskThumbnail(sourceAbsolutePath, dest, ATTACHMENT_DISK_THUMB_MAX_EDGE_PX) + } + private fun readDisk(storageKey: String): String? { val path = diskPath(storageKey) ?: return null if (!PlatformFileSystem.exists(path)) return null @@ -484,6 +767,7 @@ object DecryptedImageCache { if (!PlatformFileSystem.exists(path)) return null val uri = "file://$path" memoryCache[storageKey] = uri + writeDiskThumbLocked(storageKey, path) uri }.getOrElse { invalidatePath(path) @@ -495,6 +779,7 @@ object DecryptedImageCache { if (!PlatformFileSystem.exists(path)) return null val uri = "file://$path" memoryCache[storageKey] = uri + writeDiskThumbLocked(storageKey, path) return uri } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt index 4755240..e99840c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/ResumableUploadStorage.kt @@ -39,6 +39,36 @@ interface OutboundFileInputStream { suspend fun close() } +/** Reads [length] bytes from [fileUri] starting at [offset] (seek via skip/read). */ +suspend fun readOutboundFileRange(fileUri: String, offset: Long, length: Int): ByteArray { + if (length <= 0) return ByteArray(0) + val input = openOutboundFileInputStream(fileUri) + ?: throw OutboundFileUnavailableException("Cannot open outbound file") + try { + var remaining = offset.coerceAtLeast(0L) + val skipBuf = ByteArray(8192) + while (remaining > 0L) { + val toRead = minOf(skipBuf.size.toLong(), remaining).toInt() + val n = input.read(skipBuf, 0, toRead) + if (n <= 0) throw OutboundFileUnavailableException("Unexpected EOF while seeking") + remaining -= n.toLong() + } + val buffer = ByteArray(length) + var read = 0 + while (read < length) { + val n = input.read(buffer, read, length - read) + if (n <= 0) break + read += n + } + if (read < length) { + throw OutboundFileUnavailableException("Outbound file truncated") + } + return buffer + } finally { + input.close() + } +} + expect suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt index 78473a2..59a0fd5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt @@ -10,9 +10,12 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.download.readLocalImageDimensions private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } @@ -50,15 +53,30 @@ private data class PersistedDmMessagePayload( @SerialName("localPreviewUri") val localPreviewUri: String? = null, ) +@Serializable +private data class PersistedPublicMessagePayload( + @SerialName("text") val text: String, + @SerialName("files") val files: List, + @SerialName("fileThumbnails") val fileThumbnails: List? = null, + @SerialName("fileAspectRatios") val fileAspectRatios: List? = null, + @SerialName("fileSizes") val fileSizes: List? = null, + @SerialName("fileDimensions") val fileDimensions: List>? = null, + /** Canonical API [width, height] pairs — survives reopen without network. */ + @SerialName("fileAspectRatioPairs") val fileAspectRatioPairs: List>? = null, + @SerialName("localPreviewUri") val localPreviewUri: String? = null, +) + data class ParsedDmMessageContent( val text: String, /** Reply target from encrypted JSON payload (`reply_to_id`), when present. */ val replyToId: Int? = null, val envelope: DmEnvelope? = null, + val files: List? = null, val fileThumbnails: List? = null, val fileAspectRatios: List? = null, val fileSizes: List? = null, val fileDimensions: List>? = null, + val fileAspectRatioPairs: List>? = null, val isContentCorrupted: Boolean = false, val localPreviewUri: String? = null, val pendingFileUri: String? = null, @@ -114,6 +132,65 @@ fun resolveLocalPreviewUri(message: Message): String? { return null } +/** Sync disk lookup for cold-start chat open (no suspend alias copy). */ +fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message { + val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message) + val withPreview = if (message.pendingFileUri == previewUri) { + message + } else { + message.copy(pendingFileUri = previewUri) + } + return hydrateDiskAspectRatioSync(withPreview) +} + +private fun hydrateDiskAspectRatioSync(message: Message): Message { + val file = message.files?.firstOrNull() ?: return message + if (!isImageAttachmentFilename(file.name)) return message + if (messageHasLayoutAspect(message)) return message + val aspect = readDiskAspectRatioForMessage(message) ?: return message + return message.copy(pendingFileAspectRatio = aspect) +} + +private fun isImageAttachmentFilename(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 messageHasLayoutAspect(message: Message): Boolean { + message.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { pair -> + val w = pair[0] + val h = pair[1] + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) return true + } + message.fileDimensions?.firstOrNull()?.let { (w, h) -> + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) return true + } + message.fileAspectRatios?.firstOrNull()?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { + return true + } + message.pendingFileAspectRatio?.takeIf { it > 0f && !isPlaceholderAttachmentAspectRatio(it) }?.let { + return true + } + return false +} + +private fun readDiskAspectRatioForMessage(message: Message): Float? { + val paths = buildList { + DecryptedImageCache.getCachedThumbUri(message.id, 0, message.client_message_id) + ?.removePrefix("file://") + ?.takeIf { it.isNotEmpty() } + ?.let(::add) + message.pendingFileUri?.removePrefix("file://")?.takeIf { it.isNotEmpty() }?.let(::add) + resolveLocalPreviewUri(message)?.removePrefix("file://")?.takeIf { it.isNotEmpty() }?.let(::add) + }.distinct() + for (path in paths) { + if (!localPreviewFileExists("file://$path")) continue + val (w, h) = readLocalImageDimensions(path) ?: continue + if (w <= 0 || h <= 0 || isPlaceholderAttachmentDimensions(w, h)) continue + return aspectRatioFromDimensionPair(w, h) + } + return null +} + /** * Layout width/height. Pixel pairs keep [w,h] order (landscape vs portrait). * Do not min/max swap — that forces every landscape 4K image into portrait. @@ -128,6 +205,12 @@ internal fun aspectRatioFromDimensionPair(w: Int, h: Int): Float { return dw.toFloat() / dh.toFloat() } +/** Server fallback when thumb meta is missing (e.g. very large images). */ +internal fun isPlaceholderAttachmentDimensions(w: Int, h: Int): Boolean = w == 1 && h == 1 + +internal fun isPlaceholderAttachmentAspectRatio(ratio: Float): Boolean = + ratio in 0.999f..1.001f + private fun localPreviewFileExists(uri: String): Boolean { val path = uri.removePrefix("file://") return path.isNotEmpty() && PlatformFileSystem.exists(path) @@ -158,6 +241,33 @@ fun encodePersistedDmMessage(message: Message): String { return json.encodeToString(payload) } +fun encodePersistedPublicMessage(message: Message): String { + val laidOut = message.resolvePublicAttachmentLayout() + val files = laidOut.files?.takeIf { it.isNotEmpty() } ?: return laidOut.content + val dims = laidOut.fileDimensions?.map { listOf(it.first, it.second) } + val pairs = laidOut.fileAspectRatioPairs + val payload = PersistedPublicMessagePayload( + text = laidOut.content, + files = files, + fileThumbnails = laidOut.fileThumbnails, + fileAspectRatios = laidOut.fileAspectRatios, + fileSizes = laidOut.fileSizes, + fileDimensions = dims, + fileAspectRatioPairs = pairs, + localPreviewUri = resolveLocalPreviewUri(laidOut), + ) + AttachmentMediaLog.persist( + "encode_public", + "msgId" to laidOut.id, + "clientId" to laidOut.client_message_id, + "files" to files.size, + "localPreview" to (payload.localPreviewUri?.take(64) ?: "null"), + "dims" to (dims?.firstOrNull()?.joinToString("x") ?: "null"), + "pairs" to (pairs?.firstOrNull()), + ) + return json.encodeToString(payload) +} + fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { val trimmed = plaintext.trim() if (trimmed.startsWith("{")) { @@ -194,6 +304,7 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { ParsedDmMessageContent( text = payload.text, envelope = payload.envelope, + files = payload.envelope.files, fileThumbnails = payload.fileThumbnails, fileAspectRatios = payload.fileAspectRatios, fileSizes = payload.fileSizes, @@ -207,6 +318,26 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { ParsedDmMessageContent(text = plaintext) } } + val isPersistedPublic = root?.containsKey("files") == true + if (isPersistedPublic) { + return runCatching { + val payload = json.decodeFromString(trimmed) + ParsedDmMessageContent( + text = payload.text, + files = payload.files, + fileThumbnails = payload.fileThumbnails, + fileAspectRatios = payload.fileAspectRatios, + fileSizes = payload.fileSizes, + fileDimensions = payload.fileDimensions?.mapNotNull { pair -> + if (pair.size >= 2) pair[0] to pair[1] else null + }, + fileAspectRatioPairs = payload.fileAspectRatioPairs, + localPreviewUri = payload.localPreviewUri?.takeIf { localPreviewFileExists(it) }, + ) + }.getOrElse { + ParsedDmMessageContent(text = plaintext) + } + } return parseLegacyDmContentJson(trimmed) } return ParsedDmMessageContent(text = trimmed) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 88cae8e..29a7f56 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -24,15 +24,19 @@ import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.dmOtherUserIdFromConversationId import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage import ru.fromchat.api.local.db.encodePersistedDmMessage +import ru.fromchat.api.local.db.encodePersistedPublicMessage import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync import ru.fromchat.api.local.db.resolveLocalPreviewUri import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.send.DmAttachmentOutboxPayload +import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload import ru.fromchat.api.local.send.SEND_ERROR_FAILED import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheValidator import ru.fromchat.db.Conversation @@ -42,6 +46,7 @@ import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.ui.chat.utils.attachPublicReplyReferences +import ru.fromchat.ui.chat.isImageFilename import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import kotlin.concurrent.Volatile @@ -74,22 +79,29 @@ object MessageCacheStore { return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t } + @OptIn(ExperimentalCoroutinesApi::class) fun observeMessages(instanceId: String, conversationId: String): Flow> = db.messageDatabaseQueries .selectMessagesByConversation(instanceId, conversationId) .asFlow() .mapToList(Dispatchers.Default) - .map { rows -> - val raw = hydrateReplyReferencesFromRows(rows) - val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) - sortMessagesForChatDisplay( - validatedOrEmpty( - conversationId, - dedupeMessagesByClientId( - enrichQueuedOutboundUi(withoutSuperseded, conversationId), + .mapLatest { rows -> + withContext(Dispatchers.Default) { + val raw = hydrateReplyReferencesFromRows(rows) + val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) + val hydrated = hydrateAttachmentPreviewsFromDisk(withoutSuperseded) + sortMessagesForChatDisplay( + validatedOrEmpty( + conversationId, + dedupeMessagesByClientId( + enrichQueuedOutboundUi( + hydrated, + conversationId, + ), + ), ), - ), - ) + ) + } } suspend fun loadPublicMessages(): List = @@ -106,12 +118,13 @@ object MessageCacheStore { .executeAsList() val raw = hydrateReplyReferencesFromRows(rows).reversed() val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) + val withDiskPreviews = withoutSuperseded.map { hydrateAttachmentPreviewFromDiskSync(it) } return ProfileCache.enrichPublicMessagesForDisplay( sortMessagesForChatDisplay( validatedOrEmpty( convId, dedupeMessagesByClientId( - enrichQueuedOutboundUi(withoutSuperseded, convId), + enrichQueuedOutboundUi(withDiskPreviews, convId), ), ), ), @@ -153,15 +166,16 @@ object MessageCacheStore { } suspend fun replacePublicMessages(messages: List) { - ProfileCache.mergePreviewFromPublicMessages(messages) + val resolved = messages.map { it.resolvePublicAttachmentLayout() } + ProfileCache.mergePreviewFromPublicMessages(resolved) conversationIdForPublic().let { replaceMessages( it, sortMessagesForChatDisplay( dedupeMessagesByClientId( - messages + loadPendingMessages(it).filter { p -> + resolved + loadPendingMessages(it).filter { p -> val cid = p.client_message_id - cid == null || messages.none { it.client_message_id == cid } + cid == null || resolved.none { it.client_message_id == cid } } ) ) @@ -230,8 +244,9 @@ object MessageCacheStore { } suspend fun upsertPublicMessage(message: Message) { - ProfileCache.mergePreviewFromPublicMessage(message) - upsertSingle(conversationIdForPublic(), message) + val resolved = message.resolvePublicAttachmentLayout() + ProfileCache.mergePreviewFromPublicMessage(resolved) + upsertSingle(conversationIdForPublic(), resolved) } suspend fun markSendFailed(conversationId: String, clientMessageId: String) { @@ -286,14 +301,50 @@ object MessageCacheStore { } suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { - ProfileCache.mergePreviewFromPublicMessage(confirmed) - confirmMessage(conversationIdForPublic(), clientMessageId, confirmed) + withContext(Dispatchers.Default) { + var resolved = confirmed.resolvePublicAttachmentLayout() + resolved = hydrateAttachmentPreviewFromDisk(resolved) + ProfileCache.mergePreviewFromPublicMessage(resolved) + confirmMessage(conversationIdForPublic(), clientMessageId, resolved) + } } suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) { confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed) } + /** After download/decrypt, persist [localPreviewUri] so reopen skips network. */ + suspend fun patchPublicMessageLocalPreview( + messageId: Int, + localPreviewUri: String, + ) { + if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return + val convId = conversationIdForPublic() + val iid = instanceId() + withContext(Dispatchers.Default) { + val row = db.messageDatabaseQueries + .selectMessageById(iid, convId, messageId.toLong()) + .executeAsOneOrNull() ?: return@withContext + val msg = row.toAppMessage().copy(pendingFileUri = localPreviewUri) + if (msg.files.isNullOrEmpty() || msg.dmEnvelope != null) return@withContext + val laidOut = msg.resolvePublicAttachmentLayout() + db.messageDatabaseQueries.upsertMessage( + instanceId = iid, + id = laidOut.id.toLong(), + conversationId = convId, + userId = laidOut.user_id.toLong(), + content = encodePersistedPublicMessage(laidOut), + timestamp = laidOut.timestamp, + isRead = if (laidOut.is_read) 1L else 0L, + isEdited = if (laidOut.is_edited) 1L else 0L, + replyToId = resolveReplyToIdForPersistence(laidOut, row.replyToId), + clientMessageId = laidOut.client_message_id, + deletedFlag = 0L, + sendStatus = "sent", + ) + } + } + /** After decrypt, persist [localPreviewUri] so reopen skips network. */ suspend fun patchDmMessageLocalPreview( otherUserId: Int, @@ -856,7 +907,12 @@ object MessageCacheStore { private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) { val iid = instanceId() - val storedContent = encodePersistedDmMessage(confirmed) + val storedContent = when { + !confirmed.files.isNullOrEmpty() && confirmed.dmEnvelope != null -> + encodePersistedDmMessage(confirmed) + !confirmed.files.isNullOrEmpty() -> encodePersistedPublicMessage(confirmed) + else -> confirmed.content + } withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { val existingReplyToId = db.messageDatabaseQueries @@ -901,7 +957,10 @@ object MessageCacheStore { validatedOrEmpty( conversationId, dedupeMessagesByClientId( - enrichQueuedOutboundUi(withoutSuperseded, conversationId), + enrichQueuedOutboundUi( + hydrateAttachmentPreviewsFromDisk(withoutSuperseded), + conversationId, + ), ), ), ) @@ -915,6 +974,7 @@ object MessageCacheStore { .selectRecentMessagesByConversation(iid, conversationId, limit) .executeAsList() hydrateReplyReferencesFromRows(rows).reversed() + .let { hydrateAttachmentPreviewsFromDisk(it) } } } @@ -951,7 +1011,9 @@ object MessageCacheStore { it.conversationId == conversationId && ( it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT || - it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK + it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK || + it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT || + it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK ) } if (attachmentOutbox.isEmpty()) return messages @@ -961,11 +1023,33 @@ object MessageCacheStore { .toSet() val payloads = attachmentOutbox.associate { row -> row.clientMessageId to runCatching { - Triple( - outboxJson.decodeFromString(row.payloadJson), - row.bytesUploaded, - row.kind, - ) + when (row.kind) { + OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT, + OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> { + val payload = outboxJson.decodeFromString(row.payloadJson) + AttachmentOutboxUi( + fileUri = payload.fileUri, + filename = payload.filename, + fileSizeBytes = payload.fileSizeBytes, + aspectRatio = payload.aspectRatio, + encryptedFileSizeBytes = 0L, + bytesUploaded = row.bytesUploaded, + kind = row.kind, + ) + } + else -> { + val payload = outboxJson.decodeFromString(row.payloadJson) + AttachmentOutboxUi( + fileUri = payload.fileUri, + filename = payload.filename, + fileSizeBytes = payload.fileSizeBytes, + aspectRatio = payload.aspectRatio, + encryptedFileSizeBytes = payload.encryptedFileSizeBytes, + bytesUploaded = row.bytesUploaded, + kind = row.kind, + ) + } + } }.getOrNull() } return messages.mapNotNull { msg -> @@ -974,32 +1058,43 @@ object MessageCacheStore { if (cid.isNotEmpty() && cid in confirmedClientIds) return@mapNotNull null if (cid.isEmpty()) return@mapNotNull msg val entry = payloads[cid] ?: return@mapNotNull msg - val (payload, bytesUploaded, kind) = entry - val uploadFinished = kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK + val uploadFinished = + entry.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK || + entry.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK val totalBytes = when { - payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes - else -> payload.fileSizeBytes + entry.encryptedFileSizeBytes > 0L -> entry.encryptedFileSizeBytes + else -> entry.fileSizeBytes } val percent = when { uploadFinished -> null - totalBytes > 0L && bytesUploaded > 0L -> - ((bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99) - bytesUploaded > 0L -> 1 + totalBytes > 0L && entry.bytesUploaded > 0L -> + ((entry.bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99) + entry.bytesUploaded > 0L -> 1 else -> msg.uploadProgress ?: 0 } msg.copy( - pendingFileUri = payload.fileUri, - pendingFilename = payload.filename, - pendingFileAspectRatio = payload.aspectRatio?.takeIf { it > 0f } + pendingFileUri = entry.fileUri, + pendingFilename = entry.filename, + pendingFileAspectRatio = entry.aspectRatio?.takeIf { it > 0f } ?: msg.pendingFileAspectRatio, uploadJobId = cid, uploadProgress = percent, fileSizes = msg.fileSizes - ?: payload.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) }, + ?: entry.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) }, ) } } + private data class AttachmentOutboxUi( + val fileUri: String, + val filename: String, + val fileSizeBytes: Long, + val aspectRatio: Float?, + val encryptedFileSizeBytes: Long, + val bytesUploaded: Long, + val kind: String, + ) + private fun purgeSupersededPendingRows( instanceId: String, conversationId: String, @@ -1088,15 +1183,16 @@ object MessageCacheStore { reply_to = null, client_message_id = clientMessageId, reactions = null, - files = parsed.envelope?.files, + files = parsed.files ?: parsed.envelope?.files, dmEnvelope = parsed.envelope, fileThumbnails = parsed.fileThumbnails, fileAspectRatios = parsed.fileAspectRatios, fileSizes = parsed.fileSizes, fileDimensions = parsed.fileDimensions, + fileAspectRatioPairs = parsed.fileAspectRatioPairs, isContentCorrupted = parsed.isContentCorrupted, ) - return base.copy( + val hydrated = base.copy( pendingFileUri = parsed.pendingFileUri ?: parsed.localPreviewUri ?: resolveLocalPreviewUri(base), @@ -1109,11 +1205,36 @@ object MessageCacheStore { }, uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null, ) + return if (!hydrated.files.isNullOrEmpty() && hydrated.dmEnvelope == null) { + hydrated.resolvePublicAttachmentLayout() + } else { + hydrated + } } + private suspend fun ensureAttachmentDiskAlias(message: Message) { + val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } ?: return + if (message.id <= 0) return + val file = message.files?.firstOrNull() ?: return + if (isImageFilename(file.name)) { + DecryptedImageCache.ensureDiskAliasForMessageId(message.id, 0, cid) + } else if (message.dmEnvelope != null) { + DecryptedFileCache.ensureDiskAliasForMessageId(message.id, 0, cid) + } + } + + private suspend fun hydrateAttachmentPreviewFromDisk(message: Message): Message { + ensureAttachmentDiskAlias(message) + return hydrateAttachmentPreviewFromDiskSync(message) + } + + private suspend fun hydrateAttachmentPreviewsFromDisk(messages: List): List = + messages.map { hydrateAttachmentPreviewFromDisk(it) } + private fun storedMessageContent(msg: Message): String = when { msg.id < 0 -> encodeOptimisticOutboundMessage(msg) !msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> encodePersistedDmMessage(msg) + !msg.files.isNullOrEmpty() -> encodePersistedPublicMessage(msg) else -> msg.content } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt index 9c7665f..199f68c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.kt @@ -121,6 +121,10 @@ object LocalDecodedImageCache { return bitmap } + fun putThumb(storageKey: String, bitmap: ImageBitmap) { + PlatformDecodedBitmapCache.put(storageKey + THUMB_SUFFIX, bitmap) + } + fun evict(storageKey: String) { PlatformDecodedBitmapCache.remove(previewCacheKey(storageKey)) PlatformDecodedBitmapCache.remove(fullscreenCacheKey(storageKey)) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt new file mode 100644 index 0000000..f7f4f31 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt @@ -0,0 +1,4 @@ +package ru.fromchat.api.local.download + +/** EXIF-oriented width/height from a local file (bounds read only; no full decode). */ +internal expect fun readLocalImageDimensions(absolutePath: String): Pair? diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt index 17e59e0..db9bb45 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/MessageImageSave.kt @@ -11,6 +11,8 @@ import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.local.cache.readOutboundFileBytes import ru.fromchat.api.local.mimeTypeForFilename import ru.fromchat.ui.chat.isImageFilename +import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys +import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap data class SavableMessageImage( val fileIndex: Int, @@ -25,13 +27,20 @@ fun mimeTypeForImageFilename(filename: String): String = mimeTypeForFilename(fil fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean { val file = message.files?.getOrNull(fileIndex) if (file != null && isImageFilename(file.name)) { - if (message.dmEnvelope != null) { - return DecryptedImageCache.getCached( + val cacheKeys = attachmentDecodeCacheKeys( + messageId = message.id, + fileIndex = fileIndex, + clientMessageId = message.client_message_id, + ) + if (DecryptedImageCache.getCached( messageId = message.id, fileIndex = fileIndex, clientMessageId = message.client_message_id, ) != null + ) { + return true } + if (peekDecodedAttachmentBitmap(cacheKeys) != null) return true return false } if (fileIndex != 0) return false diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt index 293a24e..19841ae 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/DmAttachmentOutboxHandler.kt @@ -557,6 +557,9 @@ object DmAttachmentOutboxHandler { MessageDatabaseProvider.database.messageDatabaseQueries .selectPendingOutboxForInstance(instanceId) .executeAsList() - .any { it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT } + .any { + it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT || + it.kind == OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt index ecce8ef..829f9bf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundFileStaging.kt @@ -37,5 +37,10 @@ suspend fun prepareOutboundFileForSend( exportUri = cacheUri, ) - StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = null, sizeBytes = staged.sizeBytes) + StagedOutboundPreview( + stagedUri = staged.uri, + previewUri = cacheUri, + aspectRatio = null, + sizeBytes = staged.sizeBytes, + ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt index 1f636d5..1608054 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundImageStaging.kt @@ -10,6 +10,8 @@ import ru.fromchat.api.local.download.LocalDecodedImageCache data class StagedOutboundPreview( val stagedUri: String, + /** Decrypted-image cache URI for UI preview (preferred over [stagedUri]). */ + val previewUri: String? = null, val aspectRatio: Float?, val sizeBytes: Long = 0L, ) @@ -30,7 +32,7 @@ suspend fun prepareOutboundImageForSend( }.getOrNull() ?: return@withContext null if (staged.sizeBytes <= 0L) return@withContext null - DecryptedImageCache.seedFromLocalFile( + val previewUri = DecryptedImageCache.seedFromLocalFile( messageId = optimisticMessageId, fileIndex = 0, localFileUri = staged.uri, @@ -39,9 +41,18 @@ suspend fun prepareOutboundImageForSend( val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId) val decodeTarget = previewSeedDecodeSize(aspectRatio) - LocalDecodedImageCache.loadFull(storageKey, staged.uri, decodeTarget) + LocalDecodedImageCache.loadFull( + storageKey, + (previewUri ?: staged.uri).removePrefix("file://"), + decodeTarget, + ) - StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = aspectRatio, sizeBytes = staged.sizeBytes) + StagedOutboundPreview( + stagedUri = staged.uri, + previewUri = previewUri, + aspectRatio = aspectRatio, + sizeBytes = staged.sizeBytes, + ) } /** High-quality seed decode before the tile is measured (refined when laid out). */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt index ce12941..ed6e564 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboxPayloads.kt @@ -33,3 +33,15 @@ data class DmAttachmentOutboxPayload( val encryptedFileSizeBytes: Long = 0L, val uploadId: String = "", ) + +@Serializable +data class PublicAttachmentOutboxPayload( + val content: String, + val clientMessageId: String, + val replyToId: Int? = null, + val fileUri: String, + val filename: String, + val fileSizeBytes: Long = 0L, + val aspectRatio: Float? = null, + val uploadId: String = "", +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index a31285b..32993e7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -255,6 +255,53 @@ object OutgoingMessageCoordinator { kickOutboxDrain(instanceId) } + suspend fun enqueuePublicAttachment( + content: String, + clientMessageId: String, + replyToId: Int?, + fileUri: String, + filename: String, + optimisticMessage: Message, + aspectRatio: Float? = null, + fileSizeBytes: Long = 0L, + ) { + val instanceId = CacheContext.requireActiveInstanceId() + val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Pending(clientMessageId, filename), + messageLabel = content, + ) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.InProgress(clientMessageId, 1, filename), + messageLabel = content, + ) + withContext(Dispatchers.Default) { + MessageRepository.upsertPublicMessage(optimisticMessage) + val payload = json.encodeToString( + PublicAttachmentOutboxPayload( + content = content, + clientMessageId = clientMessageId, + replyToId = replyToId, + fileUri = fileUri, + filename = filename, + fileSizeBytes = fileSizeBytes.coerceAtLeast(0L), + aspectRatio = aspectRatio?.takeIf { it > 0f }, + ), + ) + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = conversationId, + kind = KIND_SEND_PUBLIC_ATTACHMENT, + payloadJson = payload, + retryCount = 0L, + nextAttemptAt = null, + bytesUploaded = 0L, + ) + } + kickOutboxDrain(instanceId) + } + suspend fun clearAttachmentOutboxAfterAck(clientMessageId: String) { val cid = clientMessageId.trim() if (cid.isEmpty()) return @@ -281,6 +328,22 @@ object OutgoingMessageCoordinator { } } + suspend fun abortPublicServerUploadIfNeeded(uploadId: String) { + val id = uploadId.trim() + if (id.isEmpty()) return + runCatching { ApiClient.abortPublicUpload(id) } + .onSuccess { + AttachmentMediaLog.upload("public_server_abort_ok", "uploadId" to id) + } + .onFailure { error -> + AttachmentMediaLog.upload( + "public_server_abort_failed", + "uploadId" to id, + "error" to (error.message ?: "unknown"), + ) + } + } + /** Re-queues a failed public / text outbound row (outbox row must still exist). */ fun retryOutboundMessage(clientMessageId: String, conversationId: String) { val cid = clientMessageId.trim() @@ -316,11 +379,15 @@ object OutgoingMessageCoordinator { val row = MessageDatabaseProvider.database.messageDatabaseQueries .selectOutboxItem(instanceId, cid) .executeAsOneOrNull() - if (row?.kind == KIND_SEND_DM_ATTACHMENT) { - runCatching { + when (row?.kind) { + KIND_SEND_DM_ATTACHMENT -> runCatching { val payload = json.decodeFromString(row.payloadJson) abortDmServerUploadIfNeeded(payload.uploadId) } + KIND_SEND_PUBLIC_ATTACHMENT -> runCatching { + val payload = json.decodeFromString(row.payloadJson) + abortPublicServerUploadIfNeeded(payload.uploadId) + } } MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid) MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid) @@ -366,7 +433,14 @@ object OutgoingMessageCoordinator { scheduleOutboxRetry(id) } } - KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit + KIND_SEND_PUBLIC_ATTACHMENT -> { + if (!PublicAttachmentOutboxHandler.process(row)) { + allOk = false + scheduleOutboxRetry(id) + } + } + KIND_SEND_DM_ATTACHMENT_AWAITING_ACK, + KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> Unit } } allOk @@ -378,6 +452,9 @@ object OutgoingMessageCoordinator { const val KIND_SEND_DM_ATTACHMENT = "send_dm_attachment" /** Upload+send finished; row kept until DM ack so UI can still resolve local preview from outbox. */ const val KIND_SEND_DM_ATTACHMENT_AWAITING_ACK = "send_dm_attachment_awaiting_ack" + const val KIND_SEND_PUBLIC_ATTACHMENT = "send_public_attachment" + /** Upload+send finished; row kept briefly so UI can still resolve local preview from outbox. */ + const val KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK = "send_public_attachment_awaiting_ack" suspend fun pruneStaleAttachmentOutboxForInstance(instanceId: String) { val id = instanceId.trim() @@ -392,7 +469,9 @@ object OutgoingMessageCoordinator { for (row in rows) { when (row.kind) { KIND_SEND_DM_ATTACHMENT, - KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> { + KIND_SEND_DM_ATTACHMENT_AWAITING_ACK, + KIND_SEND_PUBLIC_ATTACHMENT, + KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK -> { if (!MessageCacheStore.hasSentMessageWithClientId( row.conversationId, row.clientMessageId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/PublicAttachmentOutboxHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/PublicAttachmentOutboxHandler.kt new file mode 100644 index 0000000..9b08c1c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/PublicAttachmentOutboxHandler.kt @@ -0,0 +1,460 @@ +package ru.fromchat.api.local.send + +import com.pr0gramm3r101.utils.crypto.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.cache.OutboundFileUnavailableException +import ru.fromchat.api.local.cache.UPLOAD_ERROR_FILE_TOO_LARGE +import ru.fromchat.api.local.cache.isFileTooLargeForUpload +import ru.fromchat.api.local.cache.isLikelyUploadMemoryError +import ru.fromchat.api.local.cache.isOutboundFileUnavailable +import ru.fromchat.api.local.cache.openOutboundFileInputStream +import ru.fromchat.api.local.cache.queryOutboundUriSizeBytes +import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts +import ru.fromchat.api.local.cache.stageOutboundFileForUpload +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId +import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.local.workers.AttachmentUploadProgress +import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio +import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout +import ru.fromchat.api.schema.messages.publicchat.upload.PublicUploadCompleteResponse +import ru.fromchat.db.Outbox +import ru.fromchat.ui.chat.isImageFilename + +private const val DEFAULT_CHUNK_SIZE = 262_144 + +object PublicAttachmentOutboxHandler { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private fun outboxRowExists(instanceId: String, clientMessageId: String): Boolean = + MessageDatabaseProvider.database.messageDatabaseQueries + .selectOutboxItem(instanceId, clientMessageId) + .executeAsOneOrNull() != null + + private fun ensureStillQueued(instanceId: String, clientMessageId: String) { + if (!outboxRowExists(instanceId, clientMessageId)) { + AttachmentMediaLog.upload("cancelled_in_flight", "job" to clientMessageId) + throw kotlinx.coroutines.CancellationException("Upload cancelled") + } + } + + suspend fun process(row: Outbox): Boolean { + if (row.kind != OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT) return false + val instanceId = row.instanceId + val payload = json.decodeFromString(row.payloadJson) + val clientMessageId = payload.clientMessageId.trim() + if (clientMessageId.isEmpty()) return true + if (!outboxRowExists(instanceId, clientMessageId)) return true + + val conversationId = row.conversationId + if (MessageCacheStore.hasSentMessageWithClientId(conversationId, clientMessageId)) { + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem( + instanceId, + clientMessageId, + ) + MessageDatabaseProvider.database.messageDatabaseQueries.deletePendingMessageByClientMessageId( + instanceId, + conversationId, + clientMessageId, + ) + } + return true + } + + val serverUploadId = arrayOf(payload.uploadId.trim()) + return runCatching { + repairInterruptedUploadArtifacts(instanceId, clientMessageId) + ensureStillQueued(instanceId, clientMessageId) + AttachmentMediaLog.send( + "outbox_start", + "job" to clientMessageId.take(12), + "file" to payload.filename, + "bytes" to payload.fileSizeBytes, + "uploadId" to payload.uploadId.take(12), + ) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Pending(clientMessageId, payload.filename), + messageLabel = payload.content, + ) + val stagedPayload = ensureStagedPayload(instanceId, row, payload) + if (isFileTooLargeForUpload(stagedPayload.fileSizeBytes)) { + throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE) + } + if (!isImageFilename(stagedPayload.filename)) { + seedOutboundFileAsDownloaded( + messageId = optimisticMessageIdForClientMessageId(clientMessageId), + fileIndex = 0, + localFileUri = stagedPayload.fileUri, + displayFilename = stagedPayload.filename, + clientMessageId = clientMessageId, + ) + } + ensureStillQueued(instanceId, clientMessageId) + val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload) + AttachmentMediaLog.send( + "outbox_upload_begin", + "job" to clientMessageId.take(12), + "restoredPct" to restoredPercent, + "bytes" to stagedPayload.fileSizeBytes, + ) + if (restoredPercent > 0) { + emitProgress(clientMessageId, restoredPercent, stagedPayload.filename, stagedPayload.content) + } else { + emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.content) + } + + val completed = sendResumable( + instanceId = instanceId, + row = row, + payload = stagedPayload, + serverUploadId = serverUploadId, + ) + ensureStillQueued(instanceId, clientMessageId) + AttachmentMediaLog.send( + "outbox_upload_complete", + "job" to clientMessageId.take(12), + "fileId" to completed.fileId, + ) + + val confirmed = ApiClient.sendMessageViaHttp( + content = stagedPayload.content, + replyToId = stagedPayload.replyToId, + clientMessageId = clientMessageId, + uploadedFileIds = listOf(completed.fileId), + ) + val resolvedConfirmed = mergeConfirmedPublicAttachment( + confirmed = confirmed.copy(client_message_id = clientMessageId), + payload = stagedPayload, + ) + AttachmentMediaLog.send( + "outbox_http_ack", + "job" to clientMessageId.take(12), + "realId" to resolvedConfirmed.id, + "files" to (resolvedConfirmed.files?.size ?: 0), + "aspect" to resolvedConfirmed.pendingFileAspectRatio, + "dims" to resolvedConfirmed.fileDimensions?.firstOrNull(), + ) + withContext(Dispatchers.Default) { + if (resolvedConfirmed.id > 0 && isImageFilename(stagedPayload.filename)) { + DecryptedImageCache.seedFromLocalFile( + messageId = resolvedConfirmed.id, + fileIndex = 0, + localFileUri = stagedPayload.fileUri, + clientMessageId = clientMessageId, + ) + DecryptedImageCache.ensureDiskAliasForMessageId( + messageId = resolvedConfirmed.id, + fileIndex = 0, + clientMessageId = clientMessageId, + ) + } + MessageCacheStore.confirmPublicMessage( + clientMessageId, + resolvedConfirmed, + ) + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = row.conversationId, + kind = OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT_AWAITING_ACK, + payloadJson = row.payloadJson, + retryCount = row.retryCount, + nextAttemptAt = null, + bytesUploaded = row.bytesUploaded, + ) + } + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Success(clientMessageId), + messageLabel = payload.content, + ) + AttachmentMediaLog.send( + "outbox_success", + "job" to clientMessageId.take(12), + "realId" to confirmed.id, + ) + true + }.getOrElse { error -> + if (error is kotlinx.coroutines.CancellationException) { + OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(serverUploadId[0]) + AttachmentMediaLog.send("outbox_cancelled", "job" to clientMessageId.take(12)) + return true + } + AttachmentMediaLog.send( + "outbox_error", + "job" to clientMessageId.take(12), + "err" to (error.message ?: error::class.simpleName), + ) + if (error.isOutboundFileUnavailable()) { + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Failed( + jobId = clientMessageId, + error = error.message ?: "Attachment unavailable", + ), + messageLabel = payload.content, + ) + runCatching { + val optimisticId = optimisticMessageIdForClientMessageId(clientMessageId) + clearOutboundImageCaches(clientMessageId, optimisticId) + clearOutboundFileCaches(clientMessageId, optimisticId) + OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId) + } + return true + } + val failureKey = when { + error.message == UPLOAD_ERROR_FILE_TOO_LARGE -> UPLOAD_ERROR_FILE_TOO_LARGE + isLikelyUploadMemoryError(error) -> UPLOAD_ERROR_FILE_TOO_LARGE + else -> null + } + if (failureKey != null) { + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Failed( + jobId = clientMessageId, + error = failureKey, + ), + messageLabel = payload.content, + ) + } else { + // Transient (network / parse / 5xx): keep uploading UI and retry — do not flash failed. + AttachmentMediaLog.send( + "outbox_retryable", + "job" to clientMessageId.take(12), + "err" to (error.message ?: error::class.simpleName), + ) + } + false + } + } + + private suspend fun sendResumable( + instanceId: String, + row: Outbox, + payload: PublicAttachmentOutboxPayload, + serverUploadId: Array, + ): PublicUploadCompleteResponse { + val totalSize = payload.fileSizeBytes + if (totalSize <= 0L) { + throw OutboundFileUnavailableException("Attachment file is empty or unavailable") + } + var activePayload = payload + var bytesUploaded = row.bytesUploaded.coerceAtLeast(0L) + if (bytesUploaded > totalSize) { + bytesUploaded = 0L + } + var uploadId = activePayload.uploadId.trim().ifBlank { serverUploadId[0] } + try { + if (uploadId.isEmpty()) { + val init = ApiClient.initPublicUpload( + filename = payload.filename, + totalSize = totalSize, + chunkSize = DEFAULT_CHUNK_SIZE, + ) + uploadId = init.uploadId + serverUploadId[0] = uploadId + persistPayloadProgress(instanceId, row, activePayload.copy(uploadId = uploadId), bytesUploaded) + } else { + serverUploadId[0] = uploadId + } + + val serverStatus = ApiClient.getPublicUploadStatus(uploadId) + val serverOffset = serverStatus.offset.coerceAtLeast(0L) + if (serverStatus.totalSize > 0L && serverStatus.totalSize != totalSize) { + OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(uploadId) + uploadId = "" + serverUploadId[0] = "" + bytesUploaded = 0L + val init = ApiClient.initPublicUpload( + filename = activePayload.filename, + totalSize = totalSize, + chunkSize = DEFAULT_CHUNK_SIZE, + ) + uploadId = init.uploadId + serverUploadId[0] = uploadId + activePayload = activePayload.copy(uploadId = uploadId) + persistPayloadProgress(instanceId, row, activePayload, bytesUploaded) + } + var offset = maxOf(bytesUploaded, serverOffset) + val input = openOutboundFileInputStream(activePayload.fileUri) + ?: throw OutboundFileUnavailableException("Failed to open staged attachment") + try { + skipBytes(input, offset) + while (offset < totalSize) { + ensureStillQueued(instanceId, activePayload.clientMessageId) + val chunkLen = minOf(DEFAULT_CHUNK_SIZE.toLong(), totalSize - offset).toInt() + val chunk = ByteArray(chunkLen) + var filled = 0 + while (filled < chunkLen) { + val read = input.read(chunk, filled, chunkLen - filled) + if (read < 0) break + filled += read + } + if (filled <= 0) { + throw OutboundFileUnavailableException("Unexpected EOF while reading attachment") + } + val toSend = if (filled == chunkLen) chunk else chunk.copyOf(filled) + val chunkResp = ApiClient.uploadPublicChunk( + uploadId = uploadId, + offset = offset, + dataB64 = Base64.encode(toSend), + ) + offset = chunkResp.offsetReceived.coerceAtLeast(offset + toSend.size.toLong()) + val percent = ((offset.toDouble() / totalSize.toDouble()) * 100.0).toInt() + emitProgress(activePayload.clientMessageId, percent, activePayload.filename, activePayload.content) + persistPayloadProgress( + instanceId, + row, + activePayload.copy(uploadId = uploadId), + offset, + ) + } + } finally { + input.close() + } + + val completed = ApiClient.completePublicUpload(uploadId) + serverUploadId[0] = "" + return completed + } catch (e: kotlinx.coroutines.CancellationException) { + OutgoingMessageCoordinator.abortPublicServerUploadIfNeeded(uploadId) + serverUploadId[0] = "" + throw e + } + } + + private suspend fun skipBytes(input: ru.fromchat.api.local.cache.OutboundFileInputStream, count: Long) { + if (count <= 0L) return + val buffer = ByteArray(DEFAULT_CHUNK_SIZE) + var remaining = count + while (remaining > 0L) { + val toRead = minOf(buffer.size.toLong(), remaining).toInt() + val read = input.read(buffer, 0, toRead) + if (read < 0) { + throw OutboundFileUnavailableException("Unexpected EOF while seeking attachment") + } + remaining -= read.toLong() + } + } + + private suspend fun ensureStagedPayload( + instanceId: String, + row: Outbox, + payload: PublicAttachmentOutboxPayload, + ): PublicAttachmentOutboxPayload { + val expectedSize = payload.fileSizeBytes.takeIf { it > 0L } + ?: queryOutboundUriSizeBytes(payload.fileUri) + ?: 0L + val staged = stageOutboundFileForUpload( + instanceId = instanceId, + clientMessageId = payload.clientMessageId, + sourceUri = payload.fileUri, + expectedSizeBytes = expectedSize, + ) + if (staged.sizeBytes <= 0L) { + throw OutboundFileUnavailableException("Attachment file is empty or unavailable") + } + val updated = payload.copy(fileUri = staged.uri, fileSizeBytes = staged.sizeBytes) + if (updated.fileUri == payload.fileUri && updated.fileSizeBytes == payload.fileSizeBytes) { + return updated + } + persistPayloadProgress(instanceId, row, updated, row.bytesUploaded) + return updated + } + + private suspend fun persistPayloadProgress( + instanceId: String, + row: Outbox, + payload: PublicAttachmentOutboxPayload, + bytesUploaded: Long, + ) { + if (!outboxRowExists(instanceId, row.clientMessageId)) return + withContext(Dispatchers.Default) { + if (!outboxRowExists(instanceId, row.clientMessageId)) return@withContext + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = row.clientMessageId, + conversationId = row.conversationId, + kind = row.kind, + payloadJson = json.encodeToString(payload), + retryCount = row.retryCount, + nextAttemptAt = row.nextAttemptAt, + bytesUploaded = bytesUploaded, + ) + } + } + + private fun uploadPercent(bytesUploaded: Long, payload: PublicAttachmentOutboxPayload): Int { + val total = payload.fileSizeBytes + if (total <= 0L || bytesUploaded <= 0L) return 0 + return ((bytesUploaded.toDouble() / total.toDouble()) * 100.0).toInt().coerceIn(0, 99) + } + + private fun emitProgress( + jobId: String, + percent: Int, + filename: String? = null, + messageLabel: String? = null, + ) { + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.InProgress( + jobId = jobId, + percent = percent.coerceIn(0, 100), + filename = filename, + ), + messageLabel = messageLabel, + ) + } + + private fun mergeConfirmedPublicAttachment( + confirmed: ru.fromchat.api.schema.messages.Message, + payload: PublicAttachmentOutboxPayload, + ): ru.fromchat.api.schema.messages.Message { + val laidOut = confirmed.resolvePublicAttachmentLayout() + AttachmentMediaLog.aspect( + "outbox_http_raw", + "job" to payload.clientMessageId.take(12), + "realId" to laidOut.id, + "pairs" to confirmed.fileAspectRatioPairs?.firstOrNull(), + "resolvedPairs" to laidOut.fileAspectRatioPairs?.firstOrNull(), + "resolvedDims" to laidOut.fileDimensions?.firstOrNull(), + "resolvedRatio" to laidOut.fileAspectRatios?.firstOrNull(), + "payloadAspect" to payload.aspectRatio, + ) + val serverPair = laidOut.fileAspectRatioPairs?.firstOrNull() + ?: confirmed.fileAspectRatioPairs?.firstOrNull() + val serverDim = laidOut.fileDimensions?.firstOrNull() + val payloadAspect = payload.aspectRatio?.takeIf { it > 0f } + val serverHasReal = serverPair?.let { pair -> + pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1]) + } == true || serverDim?.let { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true + if (!serverHasReal) { + val aspect = payloadAspect + val stagedUri = payload.fileUri.trim().takeIf { it.isNotEmpty() } + AttachmentMediaLog.aspect( + "outbox_apply_payload_aspect", + "job" to payload.clientMessageId.take(12), + "aspect" to aspect, + "stagedUri" to stagedUri?.take(48), + ) + return laidOut.copy( + pendingFileUri = stagedUri?.takeIf { path -> + com.pr0gramm3r101.utils.files.PlatformFileSystem.exists( + path.removePrefix("file://"), + ) + }, + pendingFileAspectRatio = aspect, + fileAspectRatios = aspect?.let { listOf(it) }, + ) + } + AttachmentMediaLog.aspect("outbox_keep_server", "job" to payload.clientMessageId.take(12)) + return laidOut + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt index 8d3d2af..24f1539 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt @@ -10,6 +10,7 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.download.AttachmentDownloadNotifier import ru.fromchat.api.local.send.DmAttachmentOutboxPayload import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts @@ -53,9 +54,19 @@ object AttachmentTransferBootstrap { .selectPendingOutboxForInstance(instanceId) .executeAsList() for (row in rows) { - if (row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT) continue + if ( + row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT && + row.kind != OutgoingMessageCoordinator.KIND_SEND_PUBLIC_ATTACHMENT + ) { + continue + } val clientMessageId = runCatching { - json.decodeFromString(row.payloadJson).clientMessageId.trim() + when (row.kind) { + OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT -> + json.decodeFromString(row.payloadJson).clientMessageId.trim() + else -> + json.decodeFromString(row.payloadJson).clientMessageId.trim() + } }.getOrNull().orEmpty() if (clientMessageId.isEmpty()) continue repairInterruptedUploadArtifacts(instanceId, clientMessageId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt index a650199..d43faeb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentUploadNotifier.kt @@ -25,7 +25,7 @@ object AttachmentUploadNotifier { "msg" to msg, ) is AttachmentUploadProgress.InProgress -> { - if (progress.percent == 1 || progress.percent % 10 == 0 || progress.percent >= 95) { + if (progress.percent == 1 || progress.percent % 5 == 0 || progress.percent >= 95) { AttachmentMediaLog.upload( "progress", "job" to progress.jobId, @@ -33,20 +33,36 @@ object AttachmentUploadNotifier { "file" to progress.filename, "msg" to msg, ) + AttachmentMediaLog.send( + "upload_progress", + "job" to progress.jobId.take(12), + "pct" to progress.percent, + ) } } - is AttachmentUploadProgress.Success -> + is AttachmentUploadProgress.Success -> { AttachmentMediaLog.upload("success", "job" to progress.jobId, "msg" to msg) - is AttachmentUploadProgress.Failed -> + AttachmentMediaLog.send("upload_success", "job" to progress.jobId.take(12)) + } + is AttachmentUploadProgress.Failed -> { AttachmentMediaLog.upload( "failed", "job" to progress.jobId, "err" to progress.error, "msg" to msg, ) + AttachmentMediaLog.send( + "upload_failed", + "job" to progress.jobId.take(12), + "err" to progress.error, + ) + } } - mainScope.launch { - _progressFlow.emit(progress) + // Prefer immediate delivery so the determinate indicator tracks chunks. + if (!_progressFlow.tryEmit(progress)) { + mainScope.launch { + _progressFlow.emit(progress) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt index 8dfb9ad..b26b4fe 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt @@ -38,16 +38,21 @@ data class Message( @Transient val uploadError: String? = null, /** For DM file decryption; not serialized over network. */ @Transient val dmEnvelope: DmEnvelope? = null, - /** Blurhashes for image files (by index); from decrypted message JSON. */ - @Transient val fileThumbnails: List? = null, - /** Aspect ratios (width/height) for image files (by index); from decrypted message JSON. */ + /** Base64 JPEG thumbnails for image files (by index); public API or decrypted DM JSON. */ + @SerialName("fileThumbnails") val fileThumbnails: List? = null, + /** + * Pixel [width, height] pairs from public API / DM plaintext. + * Prefer [fileDimensions] / [fileAspectRatios] for layout after [resolvePublicAttachmentLayout]. + */ + @SerialName("fileAspectRatios") val fileAspectRatioPairs: List>? = null, + /** File sizes in bytes (by index). */ + @SerialName("fileSizes") val fileSizes: List? = null, + /** Aspect ratios (width/height) for image files (by index); derived client-side. */ @Transient val fileAspectRatios: List? = null, - /** File sizes in bytes (by index); from decrypted message JSON. */ - @Transient val fileSizes: List? = null, - /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ + /** Image dimensions (width, height) for image files (by index). */ @Transient val fileDimensions: List>? = null, /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ @Transient val isContentCorrupted: Boolean = false, /** Reply target id from local DB when nested [reply_to] is not hydrated yet. */ @Transient val replyToId: Int? = null, -) \ No newline at end of file +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/PublicAttachmentLayout.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/PublicAttachmentLayout.kt new file mode 100644 index 0000000..7cc9eb0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/PublicAttachmentLayout.kt @@ -0,0 +1,40 @@ +package ru.fromchat.api.schema.messages.publicchat + +import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions +import ru.fromchat.api.schema.messages.Message + +/** + * Maps public-API `[width, height]` pairs into layout fields used by attachment tiles. + */ +fun Message.resolvePublicAttachmentLayout(): Message { + val dims = fileAspectRatioPairs + ?.mapNotNull { pair -> + if (pair.size >= 2) { + val w = pair[0] + val h = pair[1] + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) w to h else null + } else { + null + } + } + ?.takeIf { it.isNotEmpty() } + ?: fileDimensions?.filterNot { (w, h) -> isPlaceholderAttachmentDimensions(w, h) } + ?.takeIf { it.isNotEmpty() } + val ratios = dims + ?.map { (w, h) -> aspectRatioFromDimensionPair(w, h) } + ?.takeIf { it.isNotEmpty() } + ?: fileAspectRatios + if (dims == fileDimensions && ratios == fileAspectRatios) return this + val resolved = copy(fileDimensions = dims, fileAspectRatios = ratios) + if (fileAspectRatioPairs != null) { + ru.fromchat.api.local.AttachmentMediaLog.aspect( + "resolve_public_layout", + "msgId" to id, + "pairsIn" to fileAspectRatioPairs.firstOrNull(), + "dimsOut" to resolved.fileDimensions?.firstOrNull(), + "ratioOut" to resolved.fileAspectRatios?.firstOrNull(), + ) + } + return resolved +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt index 0e46e6b..0d91a2e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/SendMessageRequest.kt @@ -7,4 +7,5 @@ data class SendMessageRequest( val content: String, val reply_to_id: Int? = null, val client_message_id: String? = null, -) \ No newline at end of file + val uploaded_file_ids: List? = null, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkRequest.kt new file mode 100644 index 0000000..23377d1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkRequest.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PublicUploadChunkRequest( + val offset: Long, + @SerialName("data_b64") val dataB64: String, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkResponse.kt new file mode 100644 index 0000000..aab4383 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadChunkResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Matches file_storage resumable chunk response: `{"offset_received": }`. */ +@Serializable +data class PublicUploadChunkResponse( + @SerialName("offset_received") val offsetReceived: Long = 0L, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadCompleteResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadCompleteResponse.kt new file mode 100644 index 0000000..590c012 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadCompleteResponse.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PublicUploadCompleteResponse( + @SerialName("file_id") val fileId: String, + @SerialName("upload_id") val uploadId: String, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitRequest.kt new file mode 100644 index 0000000..86f5bec --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitRequest.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PublicUploadInitRequest( + val filename: String, + @SerialName("total_size") val totalSize: Long, + @SerialName("chunk_size") val chunkSize: Int? = null, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitResponse.kt new file mode 100644 index 0000000..e0834fb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadInitResponse.kt @@ -0,0 +1,11 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PublicUploadInitResponse( + @SerialName("upload_id") val uploadId: String, + @SerialName("chunk_size") val chunkSize: Int, + val offset: Long = 0L, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadStatusResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadStatusResponse.kt new file mode 100644 index 0000000..e07f15c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/publicchat/upload/PublicUploadStatusResponse.kt @@ -0,0 +1,12 @@ +package ru.fromchat.api.schema.messages.publicchat.upload + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PublicUploadStatusResponse( + @SerialName("upload_id") val uploadId: String, + val offset: Long = 0L, + @SerialName("total_size") val totalSize: Long = 0L, + val complete: Boolean = false, +) 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 4376e7f..589b2f5 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 @@ -22,18 +22,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AttachFile import androidx.compose.material.icons.rounded.Download -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -69,13 +67,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import com.pr0gramm3r101.utils.conditional import com.pr0gramm3r101.utils.crypto.Base64 import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource @@ -102,17 +98,25 @@ import ru.fromchat.ui.chat.components.AttachmentLeadingTransitionMs import ru.fromchat.ui.chat.components.CancellableAttachmentProgressIndicator import ru.fromchat.ui.chat.components.ChatFileAttachmentTile import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot +import ru.fromchat.ui.chat.utils.ATTACHMENT_TILE_MAX_WIDTH +import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys import ru.fromchat.ui.chat.utils.attachmentImageCornerShape +import ru.fromchat.ui.chat.utils.attachmentTileLayout import ru.fromchat.ui.chat.utils.coalesceDecodeTarget import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully +import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress import ru.fromchat.ui.chat.MessageGroupInfo -private val IMAGE_SIZE = 160.dp -private val IMAGE_MAX_HEIGHT = 240.dp private const val BLUR_FADE_MS = 450 +private enum class AttachmentTileVisual { + Empty, + Thumb, + Full, +} + 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) @@ -163,7 +167,7 @@ fun AttachmentPreview( } val isPendingImage = pendingFileUri != null && isImage && file == null && dmEnvelope == null - val isConfirmedImage = file != null && isImage && dmEnvelope != null && !isPendingImage + val isConfirmedImage = file != null && isImage && !isPendingImage val showImageTile = isPendingImage || isConfirmedImage val isPendingFile = pendingFileUri != null && !isImage @@ -213,18 +217,9 @@ fun AttachmentPreview( Modifier } ) - .conditional( - fileAspectRatio != null && fileAspectRatio > 0f, - `if` = { - Modifier - .heightIn(max = IMAGE_MAX_HEIGHT) - .widthIn(max = IMAGE_SIZE) - .aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true) - }, - `else` = { - Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT) - } - ) + // Explicit px size from dp max + aspect — do not wrap to thumb intrinsics + // (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small). + .attachmentTileLayout(aspectRatio = fileAspectRatio) .clip(attachmentImageCornerShape(isAuthor, messageGroup)) .then( if (onImageBounds != null && showImageTile) { @@ -251,8 +246,11 @@ fun AttachmentPreview( .fillMaxSize() .graphicsLayer { alpha = if (isExpanded) 0f else 1f } ) { - val imageStableKey = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } - ?: "img:${messageId ?: 0}:${fileIndex ?: 0}" + val imageStableKey = when { + (messageId ?: 0) > 0 -> "img:$messageId:${fileIndex ?: 0}" + else -> clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + ?: "img:${messageId ?: 0}:${fileIndex ?: 0}" + } key(imageStableKey) { ChatImageTileContent( messageId = messageId ?: -1, @@ -314,45 +312,74 @@ private fun ChatImageTileContent( val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } - val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect) + val fallbackDecodeSize = rememberChatPreviewDecodeSize(ATTACHMENT_TILE_MAX_WIDTH, layoutAspect) val seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) } - val decryptCacheKey = remember(messageId, fileIndex, cacheClientId) { - DecryptedImageCache.storageKey(messageId, fileIndex, cacheClientId) + val decodeCacheKeys = remember(cacheClientId, messageId, fileIndex) { + attachmentDecodeCacheKeys(messageId, fileIndex, cacheClientId) } - var tileDecodeSize by remember(decryptCacheKey) { mutableStateOf(null) } + val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") } + val decryptCacheKey = decodeCacheKeys.first() + var tileDecodeSize by remember(bitmapStateKey) { mutableStateOf(null) } val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) { coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) } - val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) } - val hadInstantFull = initialFull != null - var cachedPath by remember(decryptCacheKey) { + var cachedPath by remember(bitmapStateKey) { mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) } val diskCacheUri = cachedPath val hasDiskCache = diskCacheUri != null val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri val hasLocalSource = displayLocalUri != null - val hasServerThumb = thumbnailBase64?.isNotBlank() == true - val useBlurredThumbPlaceholder = hasServerThumb && !hasDiskCache - var fullBitmap by remember(decryptCacheKey) { + val initialFull = remember(bitmapStateKey) { peekDecodedAttachmentBitmap(decodeCacheKeys) } + var fullBitmap by remember(bitmapStateKey) { mutableStateOf(initialFull) } - var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull || hasDiskCache) } - val thumbBlurAlpha = remember(decryptCacheKey, useBlurredThumbPlaceholder, hasLocalSource) { - Animatable( - when { - hadInstantFull || hasDiskCache -> 0f - useBlurredThumbPlaceholder || hasLocalSource -> 1f - else -> 0f - }, + val placeholderMemoryKey = remember(decryptCacheKey) { "${decryptCacheKey}#placeholder" } + val thumbnailBytes = remember(thumbnailBase64) { + thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } + } + val initialPlaceholder = remember(bitmapStateKey) { + DecryptedImageCache.peekPlaceholderThumb( + memoryKey = placeholderMemoryKey, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = cacheClientId, ) } - val fullRevealAlpha = remember(decryptCacheKey) { - Animatable(if (hadInstantFull) 1f else 0f) + val placeholderBitmap by produceState( + initialValue = initialPlaceholder, + placeholderMemoryKey, + decryptCacheKey, + thumbnailBytes, + messageId, + fileIndex, + cacheClientId, + diskCacheUri, + displayLocalUri, + ) { + value = DecryptedImageCache.decodePlaceholderThumb( + memoryKey = placeholderMemoryKey, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = cacheClientId, + serverThumbBytes = thumbnailBytes, + ) ?: initialPlaceholder } - val showOutboundBlurOverlay = isOutboundPending && hasLocalSource + val hasFullPreview = fullBitmap != null + val hasPlaceholder = placeholderBitmap != null + var decryptFinished by remember(bitmapStateKey) { + mutableStateOf(fullBitmap != null || placeholderBitmap != null) + } + val tileVisual = when { + fullBitmap != null -> AttachmentTileVisual.Full + placeholderBitmap != null -> AttachmentTileVisual.Thumb + else -> AttachmentTileVisual.Empty + } + val showOutboundBlurOverlay = (isOutboundPending || isUploading || awaitingServerAck) && + (hasLocalSource || hasFullPreview || hasPlaceholder) + val treatAsOutbound = isOutboundPending || isUploading || awaitingServerAck val outboundOverlayAlpha = remember(showOutboundBlurOverlay) { Animatable(if (showOutboundBlurOverlay) 1f else 0f) } @@ -372,50 +399,27 @@ private fun ChatImageTileContent( AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) || AttachmentDownloadNotifier.hasResumablePartial(messageId, fileIndex, cacheClientId) } - LaunchedEffect(messageId, fileIndex, cacheClientId) { + LaunchedEffect(messageId, fileIndex, cacheClientId, isOutboundPending, isUploading, awaitingServerAck) { + if (isOutboundPending || isUploading || awaitingServerAck) { + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + return@LaunchedEffect + } AttachmentDownloadNotifier.restorePausedForAttachment( messageId = messageId, fileIndex = fileIndex, clientMessageId = cacheClientId, ) } - var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) } - var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) } + var isAwaitingNetworkFull by remember(bitmapStateKey) { mutableStateOf(false) } + var loadAttempt by remember(bitmapStateKey) { mutableIntStateOf(0) } LaunchedEffect(showOutboundBlurOverlay) { if (showOutboundBlurOverlay) { outboundOverlayAlpha.snapTo(1f) } else { - if (fullBitmap != null) { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } outboundOverlayAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) } } - val thumbnailBytes = remember(thumbnailBase64, hasDiskCache) { - if (hasDiskCache) null else thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } - } - val thumbBitmap by produceState( - initialValue = if (hasDiskCache) null else LocalDecodedImageCache.peekThumb(decryptCacheKey), - decryptCacheKey, - thumbnailBytes, - decodeSize, - hasDiskCache, - ) { - if (hasDiskCache || thumbnailBytes == null) { - value = null - return@produceState - } - value = LocalDecodedImageCache.peekThumb(decryptCacheKey) - ?: withContext(Dispatchers.Default) { - LocalDecodedImageCache.loadThumb(decryptCacheKey, thumbnailBytes, decodeSize) - } - } - var decryptFinished by remember(decryptCacheKey) { - mutableStateOf(fullBitmap != null) - } val imageContentScale = ContentScale.Fit val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) @@ -445,6 +449,26 @@ private fun ChatImageTileContent( "diskCache" to (diskCacheUri?.take(48) ?: "null"), "target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", ) + // Already showing a decoded preview — never flash failed/loading by re-fetching. + val cachedBitmap = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys) + if (cachedBitmap != null && (isOutboundPending || isUploading || awaitingServerAck || + isAuthor || hasLocalSource) + ) { + if (fullBitmap == null) { + fullBitmap = cachedBitmap + } + decryptFinished = true + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + onFullyLoaded(true) + AttachmentMediaLog.send( + "tile_keep_local", + "key" to decryptCacheKey, + "msgId" to messageId, + "pending" to isOutboundPending, + "uploading" to isUploading, + ) + return@LaunchedEffect + } val diskUri = diskCacheUri ?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it } val localPaths = buildList { @@ -452,13 +476,14 @@ private fun ChatImageTileContent( diskUri?.let { cached -> if (none { it == cached }) add(cached) } } if (localPaths.isNotEmpty()) { + val quickDecode = previewSeedDecodeSize(aspectRatio) val loaded = withContext(Dispatchers.Default) { - LocalDecodedImageCache.peekFull(decryptCacheKey) + peekDecodedAttachmentBitmap(decodeCacheKeys) ?: localPaths.firstNotNullOfOrNull { path -> LocalDecodedImageCache.loadFull( decryptCacheKey, path.removePrefix("file://"), - decodeSize, + quickDecode, ) } } @@ -472,26 +497,67 @@ private fun ChatImageTileContent( fullBitmap = loaded cachedPath = diskUri ?: localPaths.firstOrNull() decryptFinished = true - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + onFullyLoaded(true) + // Own sends with a local preview never need a network round-trip for display. + if (isOutboundPending || isAuthor) return@LaunchedEffect + } else { + AttachmentMediaLog.tileLoad( + "load_local_miss", + "key" to decryptCacheKey, + "paths" to localPaths.size, + ) + } + } + // Own sends: never fall through to network while a local/disk preview exists (or is expected). + if (isOutboundPending || isUploading || awaitingServerAck || isAuthor) { + if (fullBitmap == null && localPaths.isEmpty()) { + DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> + cachedPath = uri + val loaded = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull( + decryptCacheKey, + uri.removePrefix("file://"), + decodeSize, + ) + } + if (loaded != null) { + fullBitmap = loaded + decryptFinished = true + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + onFullyLoaded(true) + AttachmentMediaLog.send( + "tile_cache_hit", + "key" to decryptCacheKey, + "msgId" to messageId, + ) + return@LaunchedEffect + } + } + } + if (fullBitmap != null || placeholderBitmap != null || localPaths.isNotEmpty() || + cachedPath != null + ) { + decryptFinished = true + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) onFullyLoaded(true) return@LaunchedEffect } - AttachmentMediaLog.tileLoad( - "load_local_miss", - "key" to decryptCacheKey, - "paths" to localPaths.size, - ) + if (isOutboundPending || isUploading || awaitingServerAck || localUri != null) { + decryptFinished = placeholderBitmap != null || thumbnailBytes != null + AttachmentMediaLog.send( + "tile_outbound_wait", + "key" to decryptCacheKey, + "msgId" to messageId, + "local" to (localUri?.take(32) ?: "null"), + "failed" to decryptFailed, + ) + return@LaunchedEffect + } } - if (isOutboundPending) { - decryptFinished = localPaths.isEmpty() && thumbBitmap == null && thumbnailBytes == null - if (fullBitmap != null || thumbBitmap != null) onFullyLoaded(true) - return@LaunchedEffect - } - if (serverFile == null || envelope == null) { + if (serverFile == null) { decryptFinished = true - AttachmentMediaLog.tileLoad("load_skip_no_envelope", "key" to decryptCacheKey) + AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey) return@LaunchedEffect } if (diskUri != null) { @@ -510,9 +576,6 @@ private fun ChatImageTileContent( ) fullBitmap = loaded decryptFinished = true - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) onFullyLoaded(true) return@LaunchedEffect } @@ -522,22 +585,33 @@ private fun ChatImageTileContent( "uri" to diskUri, ) } + val usePlainDownload = envelope == null AttachmentMediaLog.tileLoad( - "load_network_decrypt", + if (usePlainDownload) "load_network_plain" else "load_network_decrypt", "key" to decryptCacheKey, "file" to serverFile.path, ) isAwaitingNetworkFull = true val uri = try { - DecryptedImageCache.getOrDecrypt( - messageId = messageId, - fileIndex = fileIndex, - file = serverFile, - envelope = envelope, - currentUserId = currentUserId, - clientMessageId = cacheClientId, - messageLabel = messageLabel, - ) + if (usePlainDownload) { + DecryptedImageCache.getOrDownloadPlain( + messageId = messageId, + fileIndex = fileIndex, + file = serverFile, + clientMessageId = cacheClientId, + messageLabel = messageLabel, + ) + } else { + DecryptedImageCache.getOrDecrypt( + messageId = messageId, + fileIndex = fileIndex, + file = serverFile, + envelope = envelope, + currentUserId = currentUserId, + clientMessageId = cacheClientId, + messageLabel = messageLabel, + ) + } } catch (error: Throwable) { AttachmentMediaLog.tileLoad( "load_exception", @@ -566,9 +640,6 @@ private fun ChatImageTileContent( ) if (fullBitmap != null) { AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) onFullyLoaded(true) } } @@ -596,53 +667,39 @@ private fun ChatImageTileContent( } } - LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) { - when { - fullBitmap == null -> { - if (hasDiskCache) { - thumbBlurAlpha.snapTo(0f) - return@LaunchedEffect - } - didRevealFull = false - fullRevealAlpha.snapTo(0f) - if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f) - } - didRevealFull -> return@LaunchedEffect - hadInstantFull || hasDiskCache -> { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } - thumbBitmap != null -> { - didRevealFull = true - thumbBlurAlpha.snapTo(1f) - fullRevealAlpha.snapTo(0f) - coroutineScope { - launch { - thumbBlurAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) - } - launch { - fullRevealAlpha.animateTo(1f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) - } - } - } - else -> { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } + LaunchedEffect(placeholderBitmap) { + if (placeholderBitmap != null) decryptFinished = true + } + + LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) { + if (treatAsOutbound) { + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) } } - val isDownloadingFullImage = !isOutboundPending && fullBitmap == null && - (downloadProgress != null || isAwaitingNetworkFull) - val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled - val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending - val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled - val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource && + LaunchedEffect(hasLocalSource, hasDiskCache, fullBitmap, placeholderBitmap, isAuthor, messageId, fileIndex, cacheClientId) { + if (isAuthor && (hasLocalSource || hasDiskCache || fullBitmap != null || placeholderBitmap != null)) { + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + } + } + + val suppressNetworkChrome = treatAsOutbound || hasLocalSource || hasDiskCache || + fullBitmap != null || placeholderBitmap != null || (isAuthor && localUri != null) + val isDownloadingFullImage = !treatAsOutbound && fullBitmap == null && + (downloadProgress != null || isAwaitingNetworkFull) && !suppressNetworkChrome + val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && + !downloadCancelled && !hasLocalSource + val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !treatAsOutbound && + !suppressNetworkChrome + val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !treatAsOutbound && + !downloadCancelled && !suppressNetworkChrome + val showSpinnerOnly = fullBitmap == null && placeholderBitmap == null && !hasLocalSource && + !suppressNetworkChrome && !showLoadFailedOverlay && !showOutboundBlurOverlay && - (isDownloadingFullImage || (!decryptFinished && !isOutboundPending)) + !showDownloadProgressOverlay && + !showDownloadCancelledOverlay && + (!decryptFinished && !treatAsOutbound) Box( modifier = Modifier @@ -660,52 +717,52 @@ private fun ChatImageTileContent( ) { when { else -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(tilePlaceholderColor), - ) - if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) { - AsyncImage( - model = displayLocalUri, - contentDescription = null, - contentScale = imageContentScale, - modifier = Modifier.fillMaxSize(), - ) - } - thumbBitmap?.let { thumb -> - Box( - modifier = Modifier - .fillMaxSize() - .then( - if (thumbBlurAlpha.value > 0.01f) { - Modifier.hazeEffect(style = HazeMaterials.thin()) - } else { - Modifier - }, - ), - ) { - CachedAttachmentImage( - bitmap = thumb, - contentDescription = serverFile?.name, - contentScale = imageContentScale, - modifier = Modifier - .fillMaxSize() - .alpha(thumbBlurAlpha.value.coerceIn(0f, 1f)), - ) + AnimatedContent( + targetState = tileVisual, + modifier = Modifier.fillMaxSize(), + transitionSpec = { + fadeIn(tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) + .togetherWith(fadeOut(tween(BLUR_FADE_MS, easing = FastOutSlowInEasing))) + }, + label = "attachment_tile_image", + ) { visual -> + when (visual) { + AttachmentTileVisual.Empty -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(tilePlaceholderColor), + ) + } + AttachmentTileVisual.Thumb -> { + placeholderBitmap?.let { thumb -> + Box( + modifier = Modifier + .fillMaxSize() + .hazeEffect(style = HazeMaterials.thin()), + ) { + CachedAttachmentImage( + bitmap = thumb, + contentDescription = serverFile?.name, + contentScale = imageContentScale, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + AttachmentTileVisual.Full -> { + fullBitmap?.let { full -> + CachedAttachmentImage( + bitmap = full, + contentDescription = serverFile?.name, + contentScale = imageContentScale, + modifier = Modifier.fillMaxSize(), + ) + LaunchedEffect(full) { onFullyLoaded(true) } + } + } } } - fullBitmap?.let { full -> - CachedAttachmentImage( - bitmap = full, - contentDescription = serverFile?.name, - contentScale = imageContentScale, - modifier = Modifier - .fillMaxSize() - .alpha(fullRevealAlpha.value.coerceIn(0f, 1f)), - ) - LaunchedEffect(full) { onFullyLoaded(true) } - } AnimatedVisibility( visible = showDownloadProgressOverlay, enter = scaleIn( @@ -766,7 +823,7 @@ private fun ChatImageTileContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) + ExpressiveUploadIndicator(uploadProgress = null, modifier = Modifier.size(28.dp)) } } if (showLoadFailedOverlay) { @@ -785,8 +842,12 @@ private fun ChatImageTileContent( } if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { UploadingImageOverlay( - model = localUri ?: displayLocalUri!!, - uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, + model = localUri ?: displayLocalUri, + previewBitmap = fullBitmap, + uploadProgress = when { + isUploading || awaitingServerAck -> uploadProgress ?: 0 + else -> null + }, clipShape = clipShape, contentScale = imageContentScale, onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null, @@ -795,7 +856,7 @@ private fun ChatImageTileContent( .alpha(outboundOverlayAlpha.value), ) } - if (isOutboundPending && !uploadError.isNullOrBlank() && onRetryUpload != null) { + if (treatAsOutbound && !uploadError.isNullOrBlank() && onRetryUpload != null) { AttachmentUploadFailedOverlay( isAuthor = isAuthor, errorKey = uploadError, @@ -849,7 +910,8 @@ private fun DownloadCancelledImageOverlay( @OptIn(ExperimentalHazeMaterialsApi::class) @Composable private fun UploadingImageOverlay( - model: String, + model: String?, + previewBitmap: ImageBitmap?, uploadProgress: Int?, clipShape: RoundedCornerShape, contentScale: ContentScale = ContentScale.Fit, @@ -863,18 +925,35 @@ private fun UploadingImageOverlay( .clip(clipShape) .hazeEffect(style = HazeMaterials.thin()) ) { - AsyncImage( - model = model, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = contentScale, - ) + when { + previewBitmap != null -> { + CachedAttachmentImage( + bitmap = previewBitmap, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + ) + } + !model.isNullOrBlank() -> { + AsyncImage( + model = model, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + ) + } + else -> { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) + } + } } Box( - modifier = Modifier - .matchParentSize() - .padding(16.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { if (onCancelUpload != null) { CancellableAttachmentProgressIndicator( @@ -903,8 +982,10 @@ internal fun ExpressiveUploadIndicator( ) { // Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation). var latchedPercent by remember { mutableStateOf(null) } - if (uploadProgress != null) { - latchedPercent = uploadProgress.coerceIn(0, 100) + LaunchedEffect(uploadProgress) { + if (uploadProgress != null) { + latchedPercent = uploadProgress.coerceIn(0, 100) + } } val clampedProgress = latchedPercent val indeterminate = clampedProgress == null @@ -930,9 +1011,12 @@ internal fun ExpressiveUploadIndicator( val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude Box(modifier = modifier, contentAlignment = Alignment.Center) { + val indicatorModifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) if (indeterminate) { CircularWavyProgressIndicator( - modifier = Modifier.fillMaxSize(), + modifier = indicatorModifier, color = primary, trackColor = trackColor, amplitude = animatedWaveStrength @@ -940,7 +1024,7 @@ internal fun ExpressiveUploadIndicator( } else { CircularWavyProgressIndicator( progress = { animatedProgress }, - modifier = Modifier.fillMaxSize(), + modifier = indicatorModifier, color = primary, trackColor = trackColor, amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) } @@ -984,6 +1068,7 @@ private fun PendingImageContent( if (uploading) { UploadingImageOverlay( model = uri, + previewBitmap = null, uploadProgress = uploadProgress, clipShape = attachmentImageCornerShape(isAuthor = false), modifier = Modifier.matchParentSize(), @@ -999,14 +1084,10 @@ private fun PendingImageContent( contentAlignment = Alignment.Center ) { if (isUploading) { - if (uploadProgress != null) { - DeterminateCircularProgress( - progress = uploadProgress, - modifier = Modifier.size(40.dp) - ) - } else { - IndefiniteCircularProgress(modifier = Modifier.size(40.dp)) - } + ExpressiveUploadIndicator( + uploadProgress = uploadProgress, + modifier = Modifier.size(40.dp), + ) } else { Icon( imageVector = Icons.Rounded.AttachFile, @@ -1019,34 +1100,6 @@ private fun PendingImageContent( } } -@Composable -private fun IndefiniteCircularProgress(modifier: Modifier = Modifier) { - CircularProgressIndicator( - modifier = modifier, - strokeWidth = 3.dp - ) -} - -@Composable -private fun DeterminateCircularProgress( - progress: Int, - modifier: Modifier = Modifier -) { - val animatedProgress by animateFloatAsState( - targetValue = (progress.coerceIn(0, 100) / 100f), - animationSpec = tween( - durationMillis = 250, - easing = FastOutSlowInEasing - ), - label = "uploadProgress" - ) - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = modifier, - strokeWidth = 3.dp - ) -} - @Composable internal fun CachedAttachmentImage( bitmap: ImageBitmap, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 460b7b3..57b91cd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -239,7 +239,7 @@ fun ChatInput( onClearReply: () -> Unit, onClearEdit: () -> Unit, hazeState: HazeState, - recipientId: Int? = null, + supportsAttachments: Boolean = false, currentUserId: Int? = null, isReadOnly: Boolean = false, onReadOnlyMessageClick: () -> Unit = {}, @@ -477,7 +477,7 @@ fun ChatInput( }, ) - if (recipientId != null) { + if (supportsAttachments) { Box( modifier = Modifier .padding(vertical = ChatInputIconSlotVerticalInset) 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 2c1a355..a3fc810 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 @@ -9,15 +9,19 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.messages.generateClientMessageId import ru.fromchat.api.local.messages.nowMessageTimestampIso import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.Logger import ru.fromchat.api.local.send.clearOutboundFileCaches import ru.fromchat.api.local.send.clearOutboundImageCaches +import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio +import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId @@ -43,7 +47,9 @@ data class ChatPanelState( /** Public chat: registered user count; null until first successful load or WS update. */ val publicGroupMemberCount: Int? = null, /** Public chat: true until first count response (HTTP or WebSocket). */ - val publicGroupMetaLoading: Boolean = false + val publicGroupMetaLoading: Boolean = false, + /** Dissolve keys ([messageDissolveKey]) currently playing the Thanos delete/cancel animation. */ + val dissolvingMessageKeys: Set = emptySet(), ) @Serializable @@ -242,11 +248,7 @@ abstract class ChatPanel( open suspend fun cancelQueuedMessage(message: Message) { val cid = message.client_message_id?.trim().orEmpty() if (cid.isEmpty()) return - if (message.pendingFileUri != null) { - clearOutboundImageCaches(cid, message.id) - clearOutboundFileCaches(cid, message.id) - } - removeMessage(message.id) + if (!beginMessageDissolve(message)) return OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId()) } @@ -259,6 +261,59 @@ abstract class ChatPanel( cancelQueuedMessage(message) } + /** + * Starts fade+collapse exit animation. Returns false if already exiting. + * [finishDissolveAnimation] removes the row when the animation completes (or on timeout). + */ + fun beginMessageDissolve(message: Message): Boolean { + val key = messageDissolveKey(message) + if (key in _state.dissolvingMessageKeys) return false + if (_state.messages.none { messageDissolveKey(it) == key }) return false + updateState { state -> + state.copy(dissolvingMessageKeys = state.dissolvingMessageKeys + key) + } + // Off-screen / missed capture: still remove after the dissolve window. + scope.launch { + delay(messageExitDurationMs().toLong()) + if (key in _state.dissolvingMessageKeys) { + finishDissolveAnimation(key) + } + } + return true + } + + /** Called when the exit animation completes (or as a timeout fallback). */ + fun finishDissolveAnimation(dissolveKey: String) { + val key = dissolveKey.trim() + if (key.isEmpty()) return + val message = _state.messages.find { messageDissolveKey(it) == key } + updateState { state -> + state.copy( + messages = state.messages.filter { messageDissolveKey(it) != key }, + dissolvingMessageKeys = state.dissolvingMessageKeys - key, + ) + } + if (message == null) return + if (message.id > 0) clearReplyReferencesTo(message.id) + scope.launch(Dispatchers.Default) { + val cid = message.client_message_id?.trim().orEmpty() + if (message.pendingFileUri != null && cid.isNotEmpty()) { + clearOutboundImageCaches(cid, message.id) + clearOutboundFileCaches(cid, message.id) + } + if (message.id < 0 || !message.pendingFileUri.isNullOrBlank()) { + runCatching { removeOptimisticFromCache(message) } + } + } + } + + /** @deprecated Use [finishDissolveAnimation]. */ + fun finishExitAnimation(clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + finishDissolveAnimation("c:$cid") + } + /** * Remove message from list */ @@ -329,11 +384,20 @@ abstract class ChatPanel( } /** - * Handle message confirmation (replace temp message with confirmed) + * Handle message confirmation (replace temp message with confirmed). + * Preserves local attachment preview fields so the tile does not flash failed/loading. */ fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) { val pending = pendingMessages.remove(tempId) pending?.first?.cancel() + AttachmentMediaLog.send( + "confirm_start", + "job" to tempId.take(12), + "realId" to confirmedMessage.id, + "files" to (confirmedMessage.files?.size ?: 0), + "pendingUri" to (_state.messages.find { it.client_message_id == tempId } + ?.pendingFileUri?.take(48) ?: "null"), + ) updateState { currentState -> val optimistic = currentState.messages.find { it.client_message_id == tempId } @@ -343,12 +407,32 @@ abstract class ChatPanel( } else { confirmedMessage } - val resolvedConfirmed = if (withClientId.reply_to == null) { + val withReply = if (withClientId.reply_to == null) { val reply = optimistic?.reply_to if (reply != null) withClientId.copy(reply_to = reply) else withClientId } else { withClientId } + val resolvedConfirmed = mergeConfirmedAttachmentUi(optimistic, withReply) + AttachmentMediaLog.send( + "confirm_merged", + "job" to tempId.take(12), + "realId" to resolvedConfirmed.id, + "keepPreview" to (resolvedConfirmed.pendingFileUri?.take(48) ?: "null"), + "thumbs" to (resolvedConfirmed.fileThumbnails?.size ?: 0), + ) + AttachmentMediaLog.aspect( + "confirm_merged", + "job" to tempId.take(12), + "realId" to resolvedConfirmed.id, + "pairs" to resolvedConfirmed.fileAspectRatioPairs?.firstOrNull(), + "dims" to resolvedConfirmed.fileDimensions?.firstOrNull(), + "ratios" to resolvedConfirmed.fileAspectRatios?.firstOrNull(), + "pendingAspect" to resolvedConfirmed.pendingFileAspectRatio, + "optAspect" to optimistic?.pendingFileAspectRatio, + "optDims" to optimistic?.fileDimensions?.firstOrNull(), + "serverPairs" to withReply.fileAspectRatioPairs?.firstOrNull(), + ) val withoutDupReal = if (resolvedConfirmed.id > 0) { currentState.messages.filter { it.id != resolvedConfirmed.id } } else { @@ -369,10 +453,122 @@ abstract class ChatPanel( ) } scope.launch(Dispatchers.Default) { - val resolved = _state.messages.find { it.client_message_id == tempId } - ?: _state.messages.find { it.id == confirmedMessage.id } - val toPersist = resolved ?: confirmedMessage + val optimistic = pending?.second + ?: _state.messages.find { it.client_message_id == tempId } + val resolvedForSeed = _state.messages.find { it.client_message_id == tempId } + ?: confirmedMessage + seedConfirmedAttachmentCaches(tempId, optimistic, resolvedForSeed) + AttachmentMediaLog.send( + "confirm_seeded", + "job" to tempId.take(12), + "realId" to resolvedForSeed.id, + ) + val toPersist = resolvedForSeed runCatching { onOptimisticMessageConfirmed(tempId, toPersist) } + AttachmentMediaLog.send( + "confirm_done", + "job" to tempId.take(12), + "realId" to confirmedMessage.id, + ) + } + } + + /** + * Keep the outbound local preview on the confirmed row (DM + public) so confirm is a + * blur-fade, not a failed/loading flash. + */ + protected fun mergeConfirmedAttachmentUi(optimistic: Message?, confirmed: Message): Message { + if (optimistic == null) return confirmed + val hasFiles = !confirmed.files.isNullOrEmpty() + if (!hasFiles && optimistic.pendingFileUri == null) return confirmed + val localPreview = optimistic.pendingFileUri + val primaryName = confirmed.files?.firstOrNull()?.name + ?: optimistic.pendingFilename + ?: localPreview?.substringAfterLast('/')?.substringBefore('?') + val isImage = primaryName != null && isImageFilename(primaryName) + val serverDim = confirmed.fileDimensions?.firstOrNull() + ?: confirmed.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h } + val serverRatio = confirmed.fileAspectRatios?.firstOrNull() + val serverAspect = serverRatio + ?: serverDim?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null } + val serverPairList = confirmed.fileAspectRatioPairs?.firstOrNull() + val serverHasRealDims = serverPairList?.let { pair -> + pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1]) + } == true || serverDim?.let { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true + val keepLocalAspect = isImage && !serverHasRealDims && ( + optimistic.pendingFileAspectRatio != null || + optimistic.fileDimensions?.firstOrNull()?.let { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true + ) + val merged = confirmed.copy( + pendingFileUri = when { + isImage -> localPreview + else -> null + }, + pendingFilename = null, + pendingFileAspectRatio = when { + keepLocalAspect -> optimistic.pendingFileAspectRatio ?: serverAspect + isImage -> null + else -> null + }, + fileAspectRatios = when { + keepLocalAspect -> optimistic.pendingFileAspectRatio?.let { listOf(it) } + ?: optimistic.fileAspectRatios + ?: confirmed.fileAspectRatios + else -> confirmed.fileAspectRatios + ?: optimistic.pendingFileAspectRatio?.takeIf { !serverHasRealDims }?.let { listOf(it) } + ?: optimistic.fileAspectRatios + }, + fileDimensions = when { + keepLocalAspect -> optimistic.fileDimensions ?: confirmed.fileDimensions + else -> confirmed.fileDimensions ?: optimistic.fileDimensions + }, + fileSizes = confirmed.fileSizes ?: optimistic.fileSizes, + fileThumbnails = confirmed.fileThumbnails ?: optimistic.fileThumbnails, + uploadJobId = null, + uploadProgress = null, + uploadError = null, + ) + return if (hasFiles) merged.resolvePublicAttachmentLayout() else merged + } + + protected open suspend fun seedConfirmedAttachmentCaches( + clientMessageId: String, + optimistic: Message?, + confirmed: Message, + ) { + val cid = clientMessageId.trim() + if (cid.isEmpty() || confirmed.id <= 0) return + val localUri = optimistic?.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() } ?: return + val file = confirmed.files?.firstOrNull() ?: return + if (isImageFilename(file.name)) { + ru.fromchat.api.local.cache.DecryptedImageCache.seedFromLocalFile( + messageId = confirmed.id, + fileIndex = 0, + localFileUri = localUri, + clientMessageId = cid, + ) + ru.fromchat.api.local.cache.DecryptedImageCache.ensureDiskAliasForMessageId( + messageId = confirmed.id, + fileIndex = 0, + clientMessageId = cid, + ) + } else { + ru.fromchat.api.local.send.seedOutboundFileAsDownloaded( + messageId = confirmed.id, + fileIndex = 0, + localFileUri = localUri, + displayFilename = file.name, + clientMessageId = cid, + ) + ru.fromchat.api.local.cache.DecryptedFileCache.ensureDiskAliasForMessageId( + messageId = confirmed.id, + fileIndex = 0, + clientMessageId = cid, + ) } } @@ -560,6 +756,10 @@ abstract class ChatPanel( /** DM recipient ID for attachment uploads; null for non-DM panels. */ open fun getRecipientId(): Int? = null + /** Whether the composer may attach images/files (DM when recipient is known, or public chat). */ + open val supportsAttachments: Boolean + get() = getRecipientId() != null + open val showUsernamesInMessages: Boolean get() = true 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 f7a779d..2ad41b2 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 @@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -82,6 +83,7 @@ import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStatus @@ -212,15 +214,44 @@ fun ChatScreen( mutableStateOf(setOf()) } // Visit-scoped (not saveable): reopen must re-seed history, never replay enter. - var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(setOf()) } - var knownEnterIdentities by remember(panelId) { mutableStateOf(setOf()) } + val openingMessages = remember(panelId) { panel.getState().messages } + val mountSnapshotKeys = remember(panelId) { + openingMessages.mapTo(mutableSetOf()) { messageListKey(it) }.toSet() + } + val mountSnapshotIdentities = remember(panelId) { + openingMessages.mapTo(mutableSetOf()) { messageEnterIdentity(it) }.toSet() + } + var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(mountSnapshotKeys) } + var knownEnterIdentities by remember(panelId) { mutableStateOf(mountSnapshotIdentities) } + var enterAnimationsSeeded by remember(panelId) { + mutableStateOf(mountSnapshotKeys.isNotEmpty()) + } + // Blocks composition-only enter before the first LaunchedEffect seed on this visit. + var visitEnterSyncComplete by remember(panelId) { + mutableStateOf(mountSnapshotKeys.isNotEmpty()) + } val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) } val activeEnterAnimation by enterCoordinator.currentItem.collectAsState() val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState() val queuedEnter by enterCoordinator.queuedEnter.collectAsState() var previousNewestFingerprint by remember(panelId) { mutableStateOf("") } var previousEnterMessageCount by remember(panelId) { mutableIntStateOf(0) } - var enterAnimationsSeeded by remember(panelId) { mutableStateOf(false) } + + LaunchedEffect(panelId) { + val initial = panel.getState().messages + if (initial.isEmpty()) return@LaunchedEffect + val presentKeys = initial.mapTo(mutableSetOf()) { messageListKey(it) } + val presentIdentities = initial.mapTo(mutableSetOf()) { messageEnterIdentity(it) } + lastAnimatedMessageKeys = presentKeys + knownEnterIdentities = presentIdentities + enterAnimationsSeeded = true + visitEnterSyncComplete = true + previousEnterMessageCount = initial.size + val newest = initial.lastOrNull() + if (newest != null) { + previousNewestFingerprint = "${messageListKey(newest)}|${initial.size}" + } + } LaunchedEffect(panelState.messages) { val messages = panelState.messages @@ -255,6 +286,7 @@ fun ChatScreen( previousNewestFingerprint = fingerprint previousEnterMessageCount = messages.size enterAnimationsSeeded = true + visitEnterSyncComplete = true } // First non-empty load for this visit: never animate existing history. @@ -299,6 +331,10 @@ fun ChatScreen( seedWithoutAnimating("skip_new_not_newest") return@LaunchedEffect } + if (newestKey in mountSnapshotKeys || newestIdentity in mountSnapshotIdentities) { + seedWithoutAnimating("skip_opening_snapshot") + return@LaunchedEffect + } if (newestKey in lastAnimatedMessageKeys || newestIdentity in knownEnterIdentities) { seedWithoutAnimating("skip_already_known") return@LaunchedEffect @@ -658,26 +694,47 @@ fun ChatScreen( } LaunchedEffect(panel) { - if (panel.getRecipientId() != null) { + if (panel.supportsAttachments) { AttachmentUploadNotifier.progressFlow.collect { progress -> when (progress) { - is AttachmentUploadProgress.Pending -> + is AttachmentUploadProgress.Pending -> { + AttachmentMediaLog.send( + "ui_progress_pending", + "job" to progress.jobId.take(12), + ) panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = 0, uploadError = null) } + } - is AttachmentUploadProgress.InProgress -> + is AttachmentUploadProgress.InProgress -> { + AttachmentMediaLog.send( + "ui_progress_apply", + "job" to progress.jobId.take(12), + "pct" to progress.percent, + ) panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = progress.percent, uploadError = null) } + } - is AttachmentUploadProgress.Success -> + is AttachmentUploadProgress.Success -> { + AttachmentMediaLog.send( + "ui_progress_success", + "job" to progress.jobId.take(12), + ) panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = null) } + } is AttachmentUploadProgress.Failed -> { if (progress.error == "Cancelled") return@collect + AttachmentMediaLog.send( + "ui_progress_failed", + "job" to progress.jobId.take(12), + "err" to progress.error, + ) panel.updateMessageByClientMessageId(progress.jobId) { it.copy(uploadProgress = null, uploadError = progress.error) } @@ -756,12 +813,21 @@ fun ChatScreen( scope.launch { val replyToId = replyTo?.id?.takeIf { it > 0 } val recipientId = panel.getRecipientId() - if (attachments.isNotEmpty() && recipientId != null) { + if (attachments.isNotEmpty() && panel.supportsAttachments) { val plaintext = text.ifBlank { "" } attachments.forEach { att -> val jobId = generateClientMessageId() val tempId = optimisticMessageIdForClientMessageId(jobId) val isImage = att.isImage + val sendT0 = AttachmentMediaLog.nowMs() + AttachmentMediaLog.send( + "tap_send", + "job" to jobId.take(12), + "image" to isImage, + "file" to att.filename, + "uri" to att.uri.take(64), + "public" to (recipientId == null), + ) scope.launch(Dispatchers.Default) { val imageDimensions = if (isImage) { getImageDimensions(att.uri) @@ -776,6 +842,13 @@ fun ChatScreen( } else { null } + AttachmentMediaLog.send( + "staging_start", + "job" to jobId.take(12), + "dims" to imageDimensions, + "aspect" to aspectRatio, + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) val staged = if (isImage) { prepareOutboundImageForSend( clientMessageId = jobId, @@ -792,9 +865,24 @@ fun ChatScreen( ) } if (staged == null) { + AttachmentMediaLog.send( + "staging_failed", + "job" to jobId.take(12), + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) return@launch } - val fileUri = staged.stagedUri + AttachmentMediaLog.send( + "staging_ok", + "job" to jobId.take(12), + "bytes" to staged.sizeBytes, + "preview" to (staged.previewUri?.take(48) ?: "null"), + "staged" to staged.stagedUri.take(48), + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) + val uploadUri = staged.stagedUri + val previewUri = staged.previewUri?.takeIf { it.isNotBlank() } + ?: staged.stagedUri val optimisticMessage = Message( id = tempId, user_id = currentUserId ?: -1, @@ -809,7 +897,7 @@ fun ChatScreen( client_message_id = jobId, reactions = null, files = null, - pendingFileUri = fileUri, + pendingFileUri = previewUri, pendingFilename = att.filename, uploadJobId = jobId, uploadProgress = 0, @@ -819,6 +907,13 @@ fun ChatScreen( ) withContext(Dispatchers.Main) { panel.addMessage(optimisticMessage) + AttachmentMediaLog.send( + "optimistic_added", + "job" to jobId.take(12), + "tempId" to tempId, + "pendingUri" to previewUri.take(48), + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) } AttachmentUploadNotifier.emit( AttachmentUploadProgress.InProgress( @@ -828,16 +923,45 @@ fun ChatScreen( ), messageLabel = plaintext, ) - OutgoingMessageCoordinator.enqueueDmAttachment( - recipientId = recipientId, - plaintext = plaintext, - clientMessageId = jobId, - replyToId = replyToId, - fileUri = fileUri, - filename = att.filename, - optimisticMessage = optimisticMessage, - aspectRatio = staged.aspectRatio ?: aspectRatio, - fileSizeBytes = staged.sizeBytes, + if (recipientId != null) { + AttachmentMediaLog.send( + "enqueue_dm", + "job" to jobId.take(12), + "peer" to recipientId, + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) + OutgoingMessageCoordinator.enqueueDmAttachment( + recipientId = recipientId, + plaintext = plaintext, + clientMessageId = jobId, + replyToId = replyToId, + fileUri = uploadUri, + filename = att.filename, + optimisticMessage = optimisticMessage, + aspectRatio = staged.aspectRatio ?: aspectRatio, + fileSizeBytes = staged.sizeBytes, + ) + } else { + AttachmentMediaLog.send( + "enqueue_public", + "job" to jobId.take(12), + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), + ) + OutgoingMessageCoordinator.enqueuePublicAttachment( + content = plaintext, + clientMessageId = jobId, + replyToId = replyToId, + fileUri = uploadUri, + filename = att.filename, + optimisticMessage = optimisticMessage, + aspectRatio = staged.aspectRatio ?: aspectRatio, + fileSizeBytes = staged.sizeBytes, + ) + } + AttachmentMediaLog.send( + "enqueue_done", + "job" to jobId.take(12), + "elapsedMs" to (AttachmentMediaLog.nowMs() - sendT0), ) } } @@ -859,7 +983,7 @@ fun ChatScreen( inputText = "" }, hazeState = hazeState, - recipientId = panel.getRecipientId(), + supportsAttachments = panel.supportsAttachments, isReadOnly = isReadOnly, onReadOnlyMessageClick = { if (isReadOnly) { @@ -948,10 +1072,16 @@ fun ChatScreen( // Keep the newest bubble as NewMessage before enqueue runs. val newest = panelState.messages.lastOrNull() val newestListKey = newest?.let { messageListKey(it) } + val newestEnterIdentity = newest?.let { messageEnterIdentity(it) } val compositionPendingNewest = - enterAnimationsSeeded && + visitEnterSyncComplete && + enterAnimationsSeeded && newestListKey != null && + newestEnterIdentity != null && + newestListKey !in mountSnapshotKeys && + newestEnterIdentity !in mountSnapshotIdentities && newestListKey !in lastAnimatedMessageKeys && + newestEnterIdentity !in knownEnterIdentities && newestListKey !in pendingNewMessageKeys && activeEnterAnimation?.newMessageKey != newestListKey val pendingKeysForRole = @@ -1041,7 +1171,16 @@ fun ChatScreen( } } }, - enterAnimationRole = enterRole, + enterAnimationRole = if (!visitEnterSyncComplete) { + EnterAnimationRole.None + } else { + enterRole + }, + isExiting = messageDissolveKey(message) in + panelState.dissolvingMessageKeys, + onExitAnimationFinished = { + panel.finishDissolveAnimation(messageDissolveKey(message)) + }, modifier = Modifier.padding(top = item.spacingAbove), isContextMenuOpen = contextMenuState.isOpen, isContextMenuForThisMessage = 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 index 84e3484..7662a90 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -80,7 +80,10 @@ import ru.fromchat.api.local.messages.formatMessageDateTimeLocal import ru.fromchat.api.schema.messages.Message import ru.fromchat.back import ru.fromchat.more +import ru.fromchat.api.local.send.previewSeedDecodeSize +import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage +import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.Text import kotlin.math.abs @@ -124,29 +127,39 @@ fun ImageFullscreenPreview( val envelope = message.dmEnvelope val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } - val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) { - DecryptedImageCache.storageKey(message.id, fileIndex, cacheClientId) + val decodeCacheKeys = remember(message.id, fileIndex, cacheClientId) { + attachmentDecodeCacheKeys(message.id, fileIndex, cacheClientId) } - val fsCacheKey = remember(decryptCacheKey) { + val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") } + val decryptCacheKey = decodeCacheKeys.first() + val fsCacheKey = remember(bitmapStateKey) { LocalDecodedImageCache.fullscreenCacheKey(decryptCacheKey) } - var cachedPath by remember(decryptCacheKey) { + var cachedPath by remember(bitmapStateKey) { mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId)) } - var previewBitmap by remember(decryptCacheKey) { - mutableStateOf(LocalDecodedImageCache.peekFull(decryptCacheKey)) + var previewBitmap by remember(bitmapStateKey) { + mutableStateOf(peekDecodedAttachmentBitmap(decodeCacheKeys)) } - var fullscreenBitmap by remember(fsCacheKey) { - mutableStateOf(LocalDecodedImageCache.peekFullscreen(decryptCacheKey)) + var fullscreenBitmap by remember(bitmapStateKey) { + mutableStateOf( + decodeCacheKeys.firstNotNullOfOrNull { key -> + LocalDecodedImageCache.peekFullscreen(key) + }, + ) } val hasInstantBitmap = previewBitmap != null || fullscreenBitmap != null - val openAspectFromBitmap = previewBitmap?.let { it.width.toFloat() / it.height.toFloat() } + val openAspectFromBitmap = (fullscreenBitmap ?: previewBitmap)?.let { + it.width.toFloat() / it.height.toFloat() + } val layoutAspectHint = imageAspectRatioForMessage( fileAspectRatios = message.fileAspectRatios, fileDimensions = message.fileDimensions, pendingFileAspectRatio = message.pendingFileAspectRatio, + fileAspectRatioPairs = message.fileAspectRatioPairs, fileIndex = fileIndex, confirmed = message.id > 0, + hasLocalPreview = !message.pendingFileUri.isNullOrBlank() || cachedPath != null, ) var animationBounds by remember(decryptCacheKey) { mutableStateOf(null) } if (animationBounds == null && thumbnailBounds != null) { @@ -158,7 +171,7 @@ fun ImageFullscreenPreview( var menusVisible by remember { mutableStateOf(true) } var dismissRequested by remember { mutableStateOf(false) } val backgroundAlpha = remember { Animatable(1f) } - var hasPlayedOpenAnimation by remember(decryptCacheKey) { mutableStateOf(false) } + var hasPlayedOpenAnimation by remember(bitmapStateKey) { mutableStateOf(false) } var isOpenAnimationPlaying by remember { mutableStateOf(false) } var dismissProgress by remember { mutableStateOf(0f) } val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation @@ -205,18 +218,56 @@ fun ImageFullscreenPreview( ) } - LaunchedEffect(decryptCacheKey, fsCacheKey, fullscreenDecodeSize) { - val uri = cachedPath ?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) - ?: DecryptedImageCache.getOrDecrypt( - messageId = message.id, - fileIndex = fileIndex, - file = file, - envelope = envelope, - currentUserId = currentUserId, - clientMessageId = cacheClientId, - messageLabel = message.content, - ).also { cachedPath = it } + LaunchedEffect(bitmapStateKey, fsCacheKey, fullscreenDecodeSize, layoutAspectHint) { + if (previewBitmap == null) { + peekDecodedAttachmentBitmap(decodeCacheKeys)?.let { previewBitmap = it } + } + val existingUri = cachedPath + ?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) + if (previewBitmap == null && existingUri != null) { + cachedPath = existingUri + val quick = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull( + decryptCacheKey, + existingUri.removePrefix("file://"), + previewSeedDecodeSize(layoutAspectHint), + ) + } + if (quick != null) previewBitmap = quick + } + val uri = cachedPath ?: ( + if (envelope != null) { + DecryptedImageCache.getOrDecrypt( + messageId = message.id, + fileIndex = fileIndex, + file = file, + envelope = envelope, + currentUserId = currentUserId, + clientMessageId = cacheClientId, + messageLabel = message.content, + ) + } else { + DecryptedImageCache.getOrDownloadPlain( + messageId = message.id, + fileIndex = fileIndex, + file = file, + clientMessageId = cacheClientId, + messageLabel = message.content, + ) + } + )?.also { cachedPath = it } if (uri == null) return@LaunchedEffect + if (previewBitmap == null) { + val tilePreview = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull( + decryptCacheKey, + uri.removePrefix("file://"), + previewSeedDecodeSize(layoutAspectHint), + ) + } + if (tilePreview != null) previewBitmap = tilePreview + } + if (fullscreenBitmap != null) return@LaunchedEffect val hiRes = withContext(Dispatchers.Default) { LocalDecodedImageCache.loadFullscreen(decryptCacheKey, uri, fullscreenDecodeSize) } @@ -226,17 +277,12 @@ fun ImageFullscreenPreview( } val displayBitmap = fullscreenBitmap ?: previewBitmap - val displayAspect = displayBitmap?.let { bmp -> - bmp.width.toFloat() / bmp.height.toFloat() - } ?: message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } - val contentHeightAtScale1 = if (displayAspect != null) { - containerWidth / displayAspect - } else { - containerHeight - } + val hasLocalFile = cachedPath != null || + !message.pendingFileUri.isNullOrBlank() || + DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) != null when { - displayBitmap == null -> { + displayBitmap == null && !hasLocalFile -> { Box( modifier = Modifier .fillMaxSize() @@ -253,6 +299,9 @@ fun ImageFullscreenPreview( ) } else -> { + val displayAspect = displayBitmap?.let { bmp -> + bmp.width.toFloat() / bmp.height.toFloat() + } val layoutAspect = openAspectFromBitmap ?: thumbLayoutAspect ?: layoutAspectHint @@ -260,7 +309,7 @@ fun ImageFullscreenPreview( val layoutContentHeight = if (layoutAspect != null) { containerWidth / layoutAspect } else { - contentHeightAtScale1 + containerHeight } val initial = remember( animationBounds, containerWidth, containerHeight, layoutContentHeight, @@ -635,22 +684,26 @@ fun ImageFullscreenPreview( .then(sharedElementModifier) .offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) } ) { - FullscreenBitmapImage( - bitmap = displayBitmap, - contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = scaleAnim.value - scaleY = scaleAnim.value - translationX = offsetXAnim.value - offsetXAnim.value.roundToInt() - translationY = offsetYAnim.value - offsetYAnim.value.roundToInt() - val scale = maxOf(scaleAnim.value, 0.01f) - shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp) - clip = true - }, - contentScale = ContentScale.Fit - ) + displayBitmap?.let { bmp -> + FullscreenBitmapImage( + bitmap = bmp, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scaleAnim.value + scaleY = scaleAnim.value + translationX = offsetXAnim.value - offsetXAnim.value.roundToInt() + translationY = offsetYAnim.value - offsetYAnim.value.roundToInt() + val scale = maxOf(scaleAnim.value, 0.01f) + shape = androidx.compose.foundation.shape.RoundedCornerShape( + (cornerRadiusAnim.value / scale).dp, + ) + clip = true + }, + contentScale = ContentScale.Fit, + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDissolveEffect.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDissolveEffect.kt new file mode 100644 index 0000000..94554e3 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDissolveEffect.kt @@ -0,0 +1,17 @@ +package ru.fromchat.ui.chat + +import ru.fromchat.api.schema.messages.Message + +/** Fade-out duration when deleting/cancelling a message row. */ +internal const val MessageExitFadeMs = 220 + +/** Layout height collapse while fading (runs in parallel). */ +internal const val MessageExitCollapseMs = 280 + +internal fun messageExitDurationMs(): Int = + maxOf(MessageExitFadeMs, MessageExitCollapseMs) + 80 + +internal fun messageDissolveKey(message: Message): String { + val cid = message.client_message_id?.trim().orEmpty() + return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}" +} 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 33cfe1d..753b858 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 @@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -59,9 +60,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Logger +import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.Res import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.store.ProfileCache @@ -95,10 +98,13 @@ internal fun isFilenameOnlyMessageCaption(message: Message): Boolean { private fun isMessageCorrupted(message: Message): Boolean { val files = message.files ?: return false return files.withIndex().any { (index, file) -> - isImageFilename(file.name) && ( - message.dmEnvelope == null || + if (!isImageFilename(file.name)) return@any false + val isPlainPublic = message.dmEnvelope == null && + (file.path.contains("/normal/") || + (file.nonceB64.isNullOrBlank() && file.wrappedMekB64.isNullOrBlank())) + if (isPlainPublic) return@any false + message.dmEnvelope == null || message.fileThumbnails?.getOrNull(index)?.isBlank() != false - ) } } @@ -154,6 +160,9 @@ fun MessageItem( showTimestamp: Boolean = true, onBubbleTap: (() -> Unit)? = null, enterAnimationRole: EnterAnimationRole = EnterAnimationRole.None, + /** When true, fade out and collapse layout space before removal. */ + isExiting: Boolean = false, + onExitAnimationFinished: (() -> Unit)? = null, ) { val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { isMessageCorrupted(message) @@ -262,7 +271,8 @@ fun MessageItem( enterScale.snapTo(0f) } } - val runEnterAnimation = enterStarted && !enterFinished + val runEnterAnimation = enterStarted && !enterFinished && !isExiting + val shrinkLayoutForEnter = isNewEnterRole && runEnterAnimation // Single effect: start the spring as soon as this bubble is marked for enter. LaunchedEffect(enterIdentity, runEnterAnimation) { if (!runEnterAnimation) return@LaunchedEffect @@ -285,6 +295,17 @@ fun MessageItem( "spring_end identity=${enterIdentity.take(12)} role=${enterAnimationRole.name}", ) } + val exitAlpha = remember(enterIdentity) { Animatable(1f) } + val layoutCollapse = remember(enterIdentity) { Animatable(1f) } + LaunchedEffect(enterIdentity, isExiting) { + if (!isExiting) return@LaunchedEffect + enterFinished = true + coroutineScope { + launch { exitAlpha.animateTo(0f, tween(MessageExitFadeMs)) } + launch { layoutCollapse.animateTo(0f, tween(MessageExitCollapseMs)) } + } + onExitAnimationFinished?.invoke() + } LaunchedEffect(enterIdentity, enterAnimationRole, showSendingIndicator, sendFailed) { when (enterAnimationRole) { EnterAnimationRole.PreviousLast -> { @@ -295,7 +316,7 @@ fun MessageItem( } EnterAnimationRole.NewMessage -> Unit else -> { - if (!runEnterAnimation && enterScale.value != 1f) { + if (!isExiting && !runEnterAnimation && enterScale.value != 1f) { enterScale.snapTo(1f) } timestampForceAlpha.snapTo(1f) @@ -398,12 +419,15 @@ fun MessageItem( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { rowLayoutCoords = it } + .graphicsLayer { + alpha = if (isExiting) exitAlpha.value else 1f + } .then(rowLongPress) .padding(horizontal = 8.dp) .enterLayoutHeight( - scale = enterScale.value, + scale = if (isExiting) layoutCollapse.value else enterScale.value, minHeightPx = minEnterHeightPx, - active = runEnterAnimation, + active = shrinkLayoutForEnter || isExiting, ), horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom, @@ -701,6 +725,37 @@ fun MessageItem( (primaryFile != null && !primaryIsImage) if (showPrimaryImageSlot) { val imageKey = imageAttachmentKey(message, 0) + val layoutAspect = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = message.pendingFileAspectRatio, + fileAspectRatioPairs = message.fileAspectRatioPairs, + fileIndex = 0, + confirmed = message.id > 0, + hasLocalPreview = !message.pendingFileUri.isNullOrBlank(), + ) + LaunchedEffect( + message.id, + message.client_message_id, + layoutAspect, + message.fileAspectRatioPairs, + message.fileDimensions, + message.pendingFileAspectRatio, + message.pendingFileUri, + message.files?.firstOrNull()?.path, + ) { + AttachmentMediaLog.aspect( + "tile_state", + "msgId" to message.id, + "clientId" to message.client_message_id?.take(12), + "layoutAspect" to layoutAspect, + "pairs" to message.fileAspectRatioPairs?.firstOrNull(), + "dims" to message.fileDimensions?.firstOrNull(), + "pendingAspect" to message.pendingFileAspectRatio, + "pendingUri" to message.pendingFileUri?.take(48), + "file" to message.files?.firstOrNull()?.path?.takeLast(32), + ) + } val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() val isOutboundPendingImage = @@ -723,19 +778,7 @@ fun MessageItem( fileThumbnail = message.fileThumbnails ?.firstOrNull() ?.takeIf { it.isNotBlank() }, - fileAspectRatio = imageAspectRatioForMessage( - fileAspectRatios = message.fileAspectRatios, - fileDimensions = message.fileDimensions, - pendingFileAspectRatio = - message.pendingFileAspectRatio, - fileIndex = 0, - confirmed = message.id > 0, - hasLocalPreview = - DecryptedImageCache - .isDecryptedImageCacheUri( - message.pendingFileUri, - ), - ), + fileAspectRatio = layoutAspect, fileSizeBytes = message.fileSizes?.firstOrNull(), messageId = message.id, @@ -845,6 +888,7 @@ fun MessageItem( fileDimensions = message.fileDimensions, pendingFileAspectRatio = message.pendingFileAspectRatio, + fileAspectRatioPairs = message.fileAspectRatioPairs, fileIndex = index, confirmed = message.id > 0, hasLocalPreview = index == 0 && diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt index 0a46bde..c6bf504 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt @@ -120,6 +120,10 @@ fun ChatFileAttachmentTile( val displayUploadProgress = if (isUploading) uploadProgress ?: 0 else uploadProgress val openableLocalUri = resolvedCacheUri ?: pendingFileUri?.takeIf { isPendingLocal } + val canPlainDownload = file != null && dmEnvelope == null && + (file.path.contains("/normal/") || (file.nonceB64.isNullOrBlank() && file.wrappedMekB64.isNullOrBlank())) + val canEncryptedDownload = file != null && dmEnvelope != null + val canDownload = canPlainDownload || canEncryptedDownload val onRowClick: (() -> Unit)? = when { openableLocalUri != null -> { @@ -132,7 +136,7 @@ fun ChatFileAttachmentTile( } } } - downloadPaused && file != null && dmEnvelope != null -> { + downloadPaused && canDownload -> file?.let { downloadFile -> { AttachmentDownloadNotifier.beginDownload( messageId = messageId, @@ -141,11 +145,11 @@ fun ChatFileAttachmentTile( mirrorAsFileAttachment = true, ) scope.launch { - val ok = DmFileDownloader.downloadToCache( + val ok = downloadAttachmentToCache( messageId = messageId, fileIndex = fileIndex, - file = file, - envelope = dmEnvelope, + file = downloadFile, + dmEnvelope = dmEnvelope, currentUserId = currentUserId, clientMessageId = clientMessageId, messageLabel = messageLabel, @@ -162,7 +166,7 @@ fun ChatFileAttachmentTile( } } } - file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> { + canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile -> { AttachmentDownloadNotifier.beginDownload( messageId = messageId, @@ -171,11 +175,11 @@ fun ChatFileAttachmentTile( mirrorAsFileAttachment = true, ) scope.launch { - val ok = DmFileDownloader.downloadToCache( + val ok = downloadAttachmentToCache( messageId = messageId, fileIndex = fileIndex, - file = file, - envelope = dmEnvelope, + file = downloadFile, + dmEnvelope = dmEnvelope, currentUserId = currentUserId, clientMessageId = clientMessageId, messageLabel = messageLabel, @@ -273,3 +277,33 @@ fun ChatFileAttachmentTile( } } } + +private suspend fun downloadAttachmentToCache( + messageId: Int, + fileIndex: Int, + file: DmFile, + dmEnvelope: DmEnvelope?, + currentUserId: Int?, + clientMessageId: String?, + messageLabel: String?, +): Boolean = + if (dmEnvelope != null) { + DmFileDownloader.downloadToCache( + messageId = messageId, + fileIndex = fileIndex, + file = file, + envelope = dmEnvelope, + currentUserId = currentUserId, + clientMessageId = clientMessageId, + messageLabel = messageLabel, + ) + } else { + DecryptedFileCache.getOrDownloadPlain( + messageId = messageId, + fileIndex = fileIndex, + file = file, + clientMessageId = clientMessageId, + messageLabel = messageLabel, + ) != null + } + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index a275336..383391b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -588,8 +588,9 @@ class DmPanel( cancelQueuedMessage(queued) return } - val clientId = _state.messages.find { it.id == messageId }?.client_message_id - deleteMessageImmediately(messageId) + val message = _state.messages.find { it.id == messageId } ?: return + val clientId = message.client_message_id + beginMessageDissolve(message) DownloadedFileRegistry.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId) DecryptedFileCache.invalidateForMessage(messageId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index 3455c0d..bd0360d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -18,10 +18,13 @@ import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.sortMessagesForChatDisplay +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadProgress import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.MessageDeletedData import ru.fromchat.api.schema.websocket.types.ReactionUpdateData @@ -33,6 +36,7 @@ import ru.fromchat.ui.chat.utils.PublicChatTypingHandler import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.attachPublicReplyReferences import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState +import ru.fromchat.ui.chat.utils.mergeMessageUiFields import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting class PublicChatPanel( @@ -76,7 +80,7 @@ class PublicChatPanel( val fresh = byId[message.id] ?: return@map message ProfileCache.mergePreviewFromPublicMessage(fresh) ProfileCache.enrichPublicMessageForDisplay( - message.copy( + mergeMessageUiFields(fresh, message).copy( username = fresh.username, profile_picture = fresh.profile_picture, verified = fresh.verified, @@ -93,6 +97,9 @@ class PublicChatPanel( override val usesPublicGroupSubtitle: Boolean get() = true + override val supportsAttachments: Boolean + get() = true + init { val instanceId = CacheContext.activeInstanceId.value.trim() if (instanceId.isNotEmpty()) { @@ -120,6 +127,21 @@ class PublicChatPanel( } } } + scope.launch(Dispatchers.Default) { + AttachmentDownloadNotifier.progressFlow.collect { event -> + if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect + val uri = DecryptedImageCache.getUriForStorageKey(event.storageKey) ?: return@collect + withContext(Dispatchers.Main.immediate) { + updateMessage(event.messageId) { msg -> + msg.copy(pendingFileUri = uri) + } + } + MessageCacheStore.patchPublicMessageLocalPreview( + messageId = event.messageId, + localPreviewUri = uri, + ) + } + } scope.launch { typingHandler.typingUsers.collect { users -> Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") @@ -213,18 +235,19 @@ class PublicChatPanel( * Match optimistic rows via [Message.client_message_id] from the server ack (never by text). */ private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) { + val laidOut = newMsg.resolvePublicAttachmentLayout() val uid = currentUserId - if (uid != null && newMsg.user_id == uid) { - val cid = newMsg.client_message_id?.trim().orEmpty() + if (uid != null && laidOut.user_id == uid) { + val cid = laidOut.client_message_id?.trim().orEmpty() if (cid.isNotEmpty()) { - handleMessageConfirmed(cid, newMsg) + handleMessageConfirmed(cid, laidOut) return } - if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) { + if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) { return } } - ingestIncomingPublicMessage(newMsg) + ingestIncomingPublicMessage(laidOut) } private suspend fun ingestIncomingPublicMessage(newMsg: Message) { @@ -238,11 +261,20 @@ class PublicChatPanel( } private fun mergeNetworkHistoryWithShown(shown: List, fromNetwork: List): List { + val shownById = shown.associateBy { it.id } val networkIds = fromNetwork.map { it.id }.toSet() + val merged = fromNetwork.map { net -> + val local = shownById[net.id] + if (local == null) { + net.resolvePublicAttachmentLayout() + } else { + mergeMessageUiFields(net, local) + } + } val ahead = shown.filter { it.id > 0 && it.id !in networkIds } - if (ahead.isEmpty()) return fromNetwork + if (ahead.isEmpty()) return merged return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( - ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromNetwork + ahead), + ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead), ) } @@ -273,7 +305,11 @@ class PublicChatPanel( override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) { withContext(Dispatchers.Default) { - MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed) + MessageCacheStore.confirmPublicMessage( + clientMessageId, + confirmed.resolvePublicAttachmentLayout(), + ) + OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(clientMessageId) } } @@ -296,12 +332,13 @@ class PublicChatPanel( val response = responseResult.getOrNull() if (response != null && response.messages.isNotEmpty()) { - ProfileCache.mergePreviewFromPublicMessages(response.messages) + val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() } + ProfileCache.mergePreviewFromPublicMessages(networkMessages) withContext(Dispatchers.Main) { val shown = _state.messages - if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) { + if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) { Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") - val withSenders = mergePublicSenderFieldsFromNetwork(shown, response.messages) + val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages) if (withSenders != shown) { updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) } } @@ -311,7 +348,7 @@ class PublicChatPanel( batchStateUpdates { val merged = preserveReplyToFromExisting( shown, - mergeNetworkHistoryWithShown(shown, response.messages), + mergeNetworkHistoryWithShown(shown, networkMessages), ) clearMessages() addMessages( @@ -323,7 +360,7 @@ class PublicChatPanel( } } withContext(Dispatchers.Default) { - val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages) + val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages) MessageCacheStore.replacePublicMessages(mergedForCache) } } else if (responseResult.isFailure) { @@ -369,8 +406,9 @@ class PublicChatPanel( ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id) } if (response.messages.isNotEmpty()) { - ProfileCache.mergePreviewFromPublicMessages(response.messages) - val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed()) + val olderRaw = response.messages.map { it.resolvePublicAttachmentLayout() } + ProfileCache.mergePreviewFromPublicMessages(olderRaw) + val older = ProfileCache.enrichPublicMessagesForDisplay(olderRaw.reversed()) updateState { currentState -> currentState.copy( messages = older + currentState.messages @@ -499,9 +537,12 @@ class PublicChatPanel( } override suspend fun handleDeleteMessage(messageId: Int) { - deleteMessageImmediately(messageId) - clearReplyReferencesTo(messageId) - + val message = _state.messages.find { it.id == messageId } ?: return + if (messageId < 0) { + cancelQueuedMessage(message) + return + } + beginMessageDissolve(message) ApiClient.deleteMessage(messageId) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt index ba91f9c..80d5d4c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt @@ -1,18 +1,87 @@ package ru.fromchat.ui.chat.utils +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.db.isPlaceholderAttachmentAspectRatio +import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions import ru.fromchat.api.local.download.ChatPreviewDecodeSize +import ru.fromchat.api.local.download.LocalDecodedImageCache import ru.fromchat.ui.chat.MessageGroupInfo import ru.fromchat.ui.chat.bubbleTopRadii +/** Max attachment preview width in chat bubbles (160dp × 1.3). */ +internal val ATTACHMENT_TILE_MAX_WIDTH = 208.dp + +/** Max attachment preview height (240dp × 1.3). */ +internal val ATTACHMENT_TILE_MAX_HEIGHT = 312.dp + +/** Keeps wide panoramas tall enough for upload/download chrome (80dp × 1.3). */ +internal val ATTACHMENT_TILE_MIN_SHORT_EDGE = 104.dp + /** Padding between bubble edge and attachment image (must match MessageItem image padding). */ internal val ATTACHMENT_IMAGE_INSET = 2.dp /** Slight rounding on image preview bottom corners (less than bubble). */ private val IMAGE_BOTTOM_CORNER = 4.dp +/** + * Fixed dp tile size from max bounds + aspect ratio. + * + * Uses [requiredSize] so [androidx.compose.foundation.layout.IntrinsicSize.Max] bubbles do not + * adopt the child Image's intrinsic width (tiny ~80px disk/server thumbs → ~3× too small on + * xxhdpi vs [maxWidth] in dp). + */ +internal fun computeAttachmentTileSize( + aspectRatio: Float, + maxWidth: Dp = ATTACHMENT_TILE_MAX_WIDTH, + maxHeight: Dp = ATTACHMENT_TILE_MAX_HEIGHT, + minShortEdge: Dp = ATTACHMENT_TILE_MIN_SHORT_EDGE, +): Pair { + var width = maxWidth + var height = maxWidth / aspectRatio + if (height > maxHeight) { + height = maxHeight + width = maxHeight * aspectRatio + } + val shortEdge = minOf(width, height) + if (shortEdge < minShortEdge) { + val scale = minShortEdge / shortEdge + width *= scale + height *= scale + if (width > maxWidth) { + val shrink = maxWidth / width + width = maxWidth + height *= shrink + } + if (height > maxHeight) { + val shrink = maxHeight / height + height = maxHeight + width *= shrink + } + } + return width to height +} + +@Composable +internal fun Modifier.attachmentTileLayout( + aspectRatio: Float?, + maxWidth: Dp = ATTACHMENT_TILE_MAX_WIDTH, + maxHeight: Dp = ATTACHMENT_TILE_MAX_HEIGHT, +): Modifier { + val ratio = aspectRatio?.takeIf { it.isFinite() && it > 0f } ?: 1f + val (width, height) = remember(ratio, maxWidth, maxHeight) { + computeAttachmentTileSize(ratio, maxWidth, maxHeight) + } + return this.requiredSize(width, height) +} + /** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */ internal fun attachmentImageCornerShape( isAuthor: Boolean, @@ -31,22 +100,62 @@ internal fun attachmentImageCornerShape( ) } -/** Width / height for layout and decode. Pixel dimensions and server ratios beat stale pending. */ +/** Decode-cache keys for a message attachment (client-id seed + confirmed message id). */ +internal fun attachmentDecodeCacheKeys( + messageId: Int, + fileIndex: Int, + clientMessageId: String?, +): List { + val keys = ArrayList(2) + clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { cid -> + keys.add(DecryptedImageCache.storageKey(-1, fileIndex, cid)) + } + if (messageId > 0) { + val idKey = DecryptedImageCache.storageKey(messageId, fileIndex, null) + if (idKey !in keys) keys.add(idKey) + } + if (keys.isEmpty()) { + keys.add(DecryptedImageCache.storageKey(messageId, fileIndex, clientMessageId)) + } + return keys +} + +internal fun peekDecodedAttachmentBitmap(cacheKeys: List) = + cacheKeys.firstNotNullOfOrNull { LocalDecodedImageCache.peekFull(it) } + +/** + * Width / height for layout and decode. + * When a local preview exists, keep the outbound aspect if the server only sent the [1,1] placeholder. + */ internal fun imageAspectRatioForMessage( fileAspectRatios: List?, fileDimensions: List>?, pendingFileAspectRatio: Float?, + fileAspectRatioPairs: List>? = null, fileIndex: Int = 0, @Suppress("UNUSED_PARAMETER") confirmed: Boolean = true, @Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false, ): Float? { - fileDimensions?.getOrNull(fileIndex)?.let { (w, h) -> - if (w > 0 && h > 0) { + val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } + val pair = fileAspectRatioPairs?.getOrNull(fileIndex) + val pairRatio = pair?.takeIf { it.size >= 2 }?.let { (w, h) -> + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) { + aspectRatioFromDimensionPair(w, h) + } else { + null + } + } + val serverDim = fileDimensions?.getOrNull(fileIndex) + val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } + + pairRatio?.let { return it } + serverDim?.let { (w, h) -> + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) { return aspectRatioFromDimensionPair(w, h) } } - fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }?.let { return it } - return pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } + serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it } + return localRatio } internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt index 8bc04b4..49e41c9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt @@ -3,9 +3,11 @@ package ru.fromchat.ui.chat.utils import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.aspectRatioFromDimensionPair +import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.messages.publicchat.resolvePublicAttachmentLayout internal fun resolveDmReplyToId( envelope: DmEnvelope?, @@ -80,37 +82,82 @@ internal fun mergeDatabaseMessagesWithPanelState( } internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { - if (panel == null) return db + if (panel == null) { + return if (!db.files.isNullOrEmpty() && db.dmEnvelope == null) { + db.resolvePublicAttachmentLayout() + } else { + db + } + } val confirmed = db.id > 0 + val dbHasRealLayout = db.fileAspectRatioPairs?.firstOrNull()?.let { pair -> + pair.size >= 2 && !isPlaceholderAttachmentDimensions(pair[0], pair[1]) + } == true || db.fileDimensions?.firstOrNull()?.let { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true + // Keep any local preview across confirm so own image sends never flash network error UI. val localPreview = db.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) } ?: panel.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) } - return db.copy( + ?: panel.pendingFileUri?.takeIf { it.isNotBlank() } + ?: db.pendingFileUri?.takeIf { it.isNotBlank() } + val serverDim = db.fileDimensions?.firstOrNull() + ?: db.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h } + ?: panel.fileDimensions?.firstOrNull() + ?: panel.fileAspectRatioPairs?.firstOrNull()?.takeIf { it.size >= 2 }?.let { (w, h) -> w to h } + val serverRatio = db.fileAspectRatios?.firstOrNull() ?: panel.fileAspectRatios?.firstOrNull() + val localAspect = panel.pendingFileAspectRatio?.takeIf { it > 0f } + ?: db.pendingFileAspectRatio?.takeIf { it > 0f } + ?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } + ?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } + val merged = db.copy( pendingFileUri = when { - confirmed -> localPreview ?: db.pendingFileUri + confirmed -> localPreview else -> panel.pendingFileUri ?: db.pendingFileUri }, pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename, uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId, - pendingFileAspectRatio = if (confirmed) { - db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } - ?: db.fileAspectRatios?.firstOrNull() + pendingFileAspectRatio = when { + confirmed && dbHasRealLayout -> null + confirmed -> localAspect + ?: serverDim?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } + ?: serverRatio ?: db.pendingFileAspectRatio - } else { - panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio + else -> panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio }, uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress, uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError, files = db.files ?: panel.files, dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope, fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails, - fileAspectRatios = db.fileAspectRatios ?: panel.fileAspectRatios, + fileAspectRatioPairs = db.fileAspectRatioPairs ?: panel.fileAspectRatioPairs, + fileAspectRatios = when { + confirmed && dbHasRealLayout -> db.fileAspectRatios ?: panel.fileAspectRatios + confirmed && localAspect != null -> listOf(localAspect) + else -> db.fileAspectRatios ?: panel.fileAspectRatios + }, fileSizes = db.fileSizes ?: panel.fileSizes, - fileDimensions = db.fileDimensions ?: panel.fileDimensions, + fileDimensions = when { + confirmed && dbHasRealLayout -> db.fileDimensions ?: panel.fileDimensions + confirmed && panel.fileDimensions?.any { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true && !dbHasRealLayout -> panel.fileDimensions + confirmed && db.fileDimensions?.any { (w, h) -> + !isPlaceholderAttachmentDimensions(w, h) + } == true -> db.fileDimensions + else -> db.fileDimensions ?: panel.fileDimensions + }, content = db.content.ifBlank { panel.content }, isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, replyToId = db.replyToId ?: panel.replyToId, reply_to = db.reply_to ?: panel.reply_to, + client_message_id = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } + ?: db.client_message_id, ) + return if (!merged.files.isNullOrEmpty() && merged.dmEnvelope == null) { + merged.resolvePublicAttachmentLayout() + } else { + merged + } } /** Keeps hydrated [Message.reply_to] when a network/DB refresh omits nested reply payloads. */ diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.ios.kt new file mode 100644 index 0000000..d645973 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/AttachmentDiskThumbnail.ios.kt @@ -0,0 +1,42 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package ru.fromchat.api.local.cache + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSData +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.UIKit.UIGraphicsBeginImageContextWithOptions +import platform.UIKit.UIGraphicsEndImageContext +import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext +import platform.UIKit.UIImage +import platform.UIKit.UIImageJPEGRepresentation + +actual fun generateAttachmentDiskThumbnail( + sourceAbsolutePath: String, + destAbsolutePath: String, + maxEdgePx: Int, +): Boolean { + val data = NSData.create(contentsOfFile = sourceAbsolutePath) ?: return false + val image = UIImage.imageWithData(data) ?: return false + val width = image.size.useContents { width } + val height = image.size.useContents { height } + if (width <= 0.0 || height <= 0.0) return false + val longEdge = maxOf(width, height) + val scale = if (longEdge > maxEdgePx) maxEdgePx.toDouble() / longEdge else 1.0 + val dstW = (width * scale).coerceAtLeast(1.0) + val dstH = (height * scale).coerceAtLeast(1.0) + UIGraphicsBeginImageContextWithOptions( + platform.CoreGraphics.CGSizeMake(dstW, dstH), + false, + 1.0, + ) + image.drawInRect(CGRectMake(0.0, 0.0, dstW, dstH)) + val scaled = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + val jpeg = scaled?.let { UIImageJPEGRepresentation(it, 0.85) } ?: return false + return jpeg.writeToFile(destAbsolutePath, atomically = true) +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt new file mode 100644 index 0000000..9689b9c --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt @@ -0,0 +1,3 @@ +package ru.fromchat.api.local.download + +internal actual fun readLocalImageDimensions(absolutePath: String): Pair? = null