diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 5b580fe7f8..4926e61602 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -64,9 +64,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.service.upload.CompressionException import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality -import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -78,11 +76,13 @@ import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler -import com.vitorpamplona.amethyst.desktop.ui.media.CompressionFailureDialog +import com.vitorpamplona.amethyst.desktop.ui.media.CompressionPreviewDialog import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker -import com.vitorpamplona.amethyst.desktop.ui.media.FailedAttachment import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow +import com.vitorpamplona.amethyst.desktop.ui.media.PreviewItem import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip +import com.vitorpamplona.amethyst.desktop.ui.media.buildPreview +import com.vitorpamplona.amethyst.desktop.ui.media.cleanupPreviewTemps import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -98,7 +98,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -187,9 +186,16 @@ fun ComposeNoteDialog( var perPostQualityOverride by remember { mutableStateOf(null) } val activeQuality = perPostQualityOverride ?: defaultQuality - // Fail-loud failure dialog state. CompletableDeferred suspends the - // send loop until the user confirms or cancels. - var pendingFailureDialog by remember { mutableStateOf(null) } + // Preview-then-publish state. When images are attached, the + // publish button reads "Preview" and a click here triggers the + // CompressionPreviewDialog: every attachment is run through the + // ImageReencoder eagerly, the user reviews the result, and the + // actual upload uses the cached temp files (preCompressed). + var pendingPreview by remember { mutableStateOf?>(null) } + val hasImages = + attachedFiles.any { + it.extension.lowercase() in IMAGE_EXTENSIONS + } // Relay picker state val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -231,6 +237,109 @@ fun ComposeNoteDialog( } } + // Shared upload + publish flow. Called by either the main button + // (when there are no images, hence no preview gate) or by the + // CompressionPreviewDialog's Publish button (when the user has + // confirmed the preview). + val runPublish: () -> Unit = { + scope.launch { + isPosting = true + errorMessage = null + try { + val preview = pendingPreview + pendingPreview = null + val uploadResults = mutableListOf() + + val total = preview?.size ?: attachedFiles.size + val sources: List> = + preview?.map { it.source to it } + ?: attachedFiles.map { it to null } + + for ((idx, pair) in sources.withIndex()) { + val (file, item) = pair + val n = idx + 1 + val prefix = if (total > 1) "$n/$total: " else "" + uploadTracker.startUpload("$prefix${file.name}") + val result = + when (item) { + is PreviewItem.Reencoded -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + preCompressed = item.compressedFile, + ) + is PreviewItem.Failed -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + bypassReencode = true, + ) + else -> + orchestrator.upload( + file = file, + alt = null, + serverBaseUrl = selectedServer, + signer = account.signer, + stripExif = stripExifSetting, + quality = activeQuality, + ) + } + uploadTracker.onSuccess(result) + uploadResults.add(result) + } + + perPostQualityOverride = null + + val finalContent = + buildString { + append(content) + for (result in uploadResults) { + result.blossom.url?.let { url -> + if (isNotBlank()) append("\n") + append(url) + } + } + } + + if (postAsPicture) { + val pictureMetas = buildPictureMetas(uploadResults) + publishPicture( + description = content, + images = pictureMetas, + account = account, + relayManager = relayManager, + relays = selectedRelays, + ) + } else { + val imetaTags = buildIMetaTags(uploadResults) + publishNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + quoteOf = quoteOf, + imetaTags = imetaTags, + relays = selectedRelays, + ) + } + onDismiss() + } catch (e: Exception) { + errorMessage = "Failed: ${e.message}" + uploadTracker.onError(e.message ?: "Unknown error") + } finally { + isPosting = false + } + } + } + Dialog( onDismissRequest = { if (!isPosting) onDismiss() }, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -349,10 +458,6 @@ fun ComposeNoteDialog( // Server selector + per-post quality + post type — shown when files are attached if (attachedFiles.isNotEmpty()) { Spacer(Modifier.height(4.dp)) - val hasImages = - attachedFiles.any { - it.extension.lowercase() in IMAGE_EXTENSIONS - } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -451,160 +556,58 @@ fun ComposeNoteDialog( errorMessage = "Note cannot be empty" return@Button } - - scope.launch { - isPosting = true - errorMessage = null - - try { - // Upload attached files and collect results. - // Inline "Processing N/M" progress via the tracker's - // existing fileName slot — no new state class needed. - // CompressionException failures are collected and - // surfaced in a fail-loud dialog before the post - // publishes — never silently downgrade. - val uploadResults = mutableListOf() - val failures = mutableListOf() - for ((idx, file) in attachedFiles.withIndex()) { - val n = idx + 1 - val total = attachedFiles.size - val prefix = if (total > 1) "$n/$total: " else "" - uploadTracker.startUpload("$prefix${file.name}") - try { - val result = - orchestrator.upload( - file = file, - alt = null, - serverBaseUrl = selectedServer, - signer = account.signer, - stripExif = stripExifSetting, - quality = activeQuality, - ) - uploadTracker.onSuccess(result) - uploadResults.add(result) - } catch (e: CompressionException) { - failures.add( - FailedAttachment( - file = file, - exception = e, - originalBytes = file.length(), - sourceFormat = ImageFormatSniffer.sniff(file), - ), + // When there are images and we don't have a + // preview yet, the publish button acts as a + // "Preview" gate — build the preview, show it, + // and let the user confirm before any upload. + if (hasImages && pendingPreview == null) { + scope.launch { + isPosting = true + errorMessage = null + try { + pendingPreview = + buildPreview( + attachments = attachedFiles.toList(), + imageExtensions = IMAGE_EXTENSIONS, + quality = activeQuality, ) - } + } finally { + isPosting = false } - - // If anything failed compression, ask the user - // whether to ship raw bytes or abort. Block - // until the dialog returns a UserChoice. - if (failures.isNotEmpty()) { - val choice = CompletableDeferred() - pendingFailureDialog = FailureDialogState(failures.toList(), choice) - val outcome = choice.await() - pendingFailureDialog = null - when (outcome) { - FailureUserChoice.Cancel -> { - isPosting = false - return@launch - } - FailureUserChoice.SendOriginal -> { - for (failure in failures) { - uploadTracker.startUpload(failure.file.name) - val result = - orchestrator.upload( - file = failure.file, - alt = null, - serverBaseUrl = selectedServer, - signer = account.signer, - stripExif = stripExifSetting, - quality = activeQuality, - bypassReencode = true, - ) - uploadTracker.onSuccess(result) - uploadResults.add(result) - } - } - } - } - - // Reset per-post override so the next post starts - // from the saved default again. - perPostQualityOverride = null - - // Append uploaded URLs to content - val finalContent = - buildString { - append(content) - for (result in uploadResults) { - result.blossom.url?.let { url -> - if (isNotBlank()) append("\n") - append(url) - } - } - } - - if (postAsPicture) { - val pictureMetas = buildPictureMetas(uploadResults) - publishPicture( - description = content, - images = pictureMetas, - account = account, - relayManager = relayManager, - relays = selectedRelays, - ) - } else { - val imetaTags = buildIMetaTags(uploadResults) - publishNote( - content = finalContent, - account = account, - relayManager = relayManager, - replyTo = replyTo, - quoteOf = quoteOf, - imetaTags = imetaTags, - relays = selectedRelays, - ) - } - onDismiss() - } catch (e: Exception) { - errorMessage = "Failed: ${e.message}" - uploadTracker.onError(e.message ?: "Unknown error") - } finally { - isPosting = false } + return@Button } + runPublish() }, enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()), ) { - Text(if (isPosting) "Publishing..." else "Publish") + Text( + when { + isPosting -> if (pendingPreview == null && hasImages) "Compressing…" else "Publishing…" + hasImages && pendingPreview == null -> "Preview" + else -> "Publish" + }, + ) } } } } } - pendingFailureDialog?.let { state -> - CompressionFailureDialog( - failures = state.failures, - stripExifOnByPass = stripExifSetting, - onSendOriginal = { state.choice.complete(FailureUserChoice.SendOriginal) }, - onCancel = { state.choice.complete(FailureUserChoice.Cancel) }, + pendingPreview?.let { items -> + CompressionPreviewDialog( + items = items, + stripExifSetting = stripExifSetting, + onPublish = runPublish, + onCancel = { + cleanupPreviewTemps(items) + pendingPreview = null + isPosting = false + }, ) } } -/** - * State for the in-flight failure dialog. Held in - * [ComposeNoteDialog]'s `mutableStateOf` so the dialog renders on - * top of the compose dialog while the send coroutine awaits the - * user's choice via [choice]. - */ -private data class FailureDialogState( - val failures: List, - val choice: CompletableDeferred, -) - -private enum class FailureUserChoice { SendOriginal, Cancel } - private fun buildIMetaTags(results: List): List = results.mapNotNull { result -> val url = result.blossom.url ?: return@mapNotNull null diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionFailureDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionFailureDialog.kt deleted file mode 100644 index a87b29649e..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionFailureDialog.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.ui.media - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.commons.service.upload.CompressionException -import com.vitorpamplona.amethyst.commons.service.upload.ImageFormat -import java.io.File - -/** - * Captured failure for an attachment that the reencoder refused or - * could not process. The dialog renders one row per failure. - */ -data class FailedAttachment( - val file: File, - val exception: CompressionException, - val originalBytes: Long, - val sourceFormat: ImageFormat, -) - -/** - * Fail-loud dialog: never silently downgrade. When one or more - * attachments could not be compressed, the dialog shows the file - * names + reasons + original byte counts and lets the user choose - * between "Send Original" (upload raw bytes — still EXIF-stripped - * for JPEG when the setting is on) and "Cancel post." - * - * Built as `Dialog { Surface }` rather than `AlertDialog` because: - * - The body is a LazyColumn that grows with N failures; AlertDialog's - * `text` slot is a single column with fixed width constraints. - * - `LaunchedEffect` and `produceState` don't fire inside - * AlertDialog's `text` slot (see `custom-feeds-alertdialog.md`), - * leaving a path for future per-row actions (e.g., per-file - * retry) that AlertDialog would block. - */ -@Composable -fun CompressionFailureDialog( - failures: List, - stripExifOnByPass: Boolean, - onSendOriginal: () -> Unit, - onCancel: () -> Unit, -) { - Dialog( - onDismissRequest = onCancel, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = Modifier.width(560.dp), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp, - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - if (failures.size == 1) { - "1 image could not be compressed" - } else { - "${failures.size} images could not be compressed" - }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(8.dp)) - Text( - "You can send the original file(s) without compression, " + - "or cancel and try again.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - - LazyColumn( - modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp), - ) { - items(failures, key = { it.file.canonicalPath }) { failure -> - FailureRow(failure = failure, stripExifOnByPass = stripExifOnByPass) - } - } - - Spacer(Modifier.height(20.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton(onClick = onCancel) { - Text("Cancel post") - } - Spacer(Modifier.width(8.dp)) - Button(onClick = onSendOriginal) { - Text( - if (failures.size == 1) { - "Send original" - } else { - "Send originals (${failures.size})" - }, - ) - } - } - } - } - } -} - -@Composable -private fun FailureRow( - failure: FailedAttachment, - stripExifOnByPass: Boolean, -) { - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { - Text( - failure.file.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - "Could not compress: ${friendlyReason(failure.exception)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - Text( - buildString { - append("Original: ").append(formatBytes(failure.originalBytes)) - append(" • ").append(privacyHint(failure, stripExifOnByPass)) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -private fun friendlyReason(e: CompressionException): String = - when (e) { - is CompressionException.UnsupportedFormat -> "format not supported (${e.format})" - is CompressionException.InputTooLarge -> "image is larger than ${e.limit / 1_000_000} megapixels" - is CompressionException.EncodeFailed -> "encoder error (${e.cause?.message ?: e.message})" - } - -private fun privacyHint( - failure: FailedAttachment, - stripExifOnByPass: Boolean, -): String { - val isJpeg = failure.sourceFormat is ImageFormat.Jpeg - return when { - stripExifOnByPass && isJpeg -> "EXIF will be stripped before upload" - stripExifOnByPass && !isJpeg -> "metadata may still be present (non-JPEG)" - else -> "metadata preserved per your settings" - } -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 1024) return "$bytes B" - if (bytes < 1024 * 1024) return "%.1f KB".format(bytes / 1024.0) - return "%.1f MB".format(bytes / (1024.0 * 1024.0)) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt new file mode 100644 index 0000000000..66698f4b92 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import com.vitorpamplona.amethyst.commons.service.upload.CompressionException +import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality +import com.vitorpamplona.amethyst.commons.service.upload.ImageFormat +import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import java.io.File +import javax.imageio.ImageIO + +/** + * What the preview dialog renders for a single attachment. + * + * Built once per `Preview` click in the compose dialog; if the user + * confirms via `Publish`, the items flow into the upload loop and the + * orchestrator takes ownership of the cached temp files. + */ +sealed class PreviewItem { + abstract val source: File + abstract val originalSize: Long + abstract val originalDims: Pair? + + /** A reencoded image — the dialog shows side-by-side stats + a click-to-zoom. */ + data class Reencoded( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val compressedFile: File, + val compressedSize: Long, + val compressedDims: Pair, + val quality: CompressionQuality, + ) : PreviewItem() { + val savings: Double get() = 1.0 - (compressedSize.toDouble() / originalSize.toDouble()) + } + + /** ImageReencoder said pass-through (animated GIF/WebP, SVG, BypassByUser). */ + data class PassThrough( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val reason: PassReason, + ) : PreviewItem() + + /** Reencode threw — user can still publish; original ships raw. */ + data class Failed( + override val source: File, + override val originalSize: Long, + override val originalDims: Pair?, + val exception: CompressionException, + val sourceFormat: ImageFormat, + ) : PreviewItem() + + /** Non-image attachment — shown for context, uploaded straight through. */ + data class NonImage( + override val source: File, + override val originalSize: Long, + ) : PreviewItem() { + override val originalDims: Pair? = null + } +} + +/** + * Reads source dimensions from a file via ImageIO header (no decode). + * Returns null on any failure — preview keeps rendering, just without + * the dim string. + */ +internal fun readDimensions(file: File): Pair? = + try { + ImageIO.createImageInputStream(file).use { iis -> + iis ?: return null + val readers = ImageIO.getImageReaders(iis) + if (!readers.hasNext()) return null + val reader = readers.next() + reader.input = iis + try { + reader.getWidth(0) to reader.getHeight(0) + } finally { + reader.dispose() + } + } + } catch (_: Exception) { + null + } + +/** + * Build the preview list for a batch of attachments. Runs ImageReencoder + * eagerly so the dialog renders complete state once. Non-image files + * are not sent through the reencoder. + * + * IMPORTANT: callers own the lifecycle of any [PreviewItem.Reencoded.compressedFile] + * temps returned here. On Cancel: delete each. On Publish: hand off + * to [com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator] + * via its `preCompressed` parameter — the orchestrator cleans up. + */ +suspend fun buildPreview( + attachments: List, + imageExtensions: Set, + quality: CompressionQuality, +): List = + attachments.map { file -> + val originalSize = file.length() + val isImage = file.extension.lowercase() in imageExtensions + if (!isImage) { + return@map PreviewItem.NonImage(file, originalSize) + } + val originalDims = readDimensions(file) + try { + when (val result = ImageReencoder.reencode(file, quality)) { + is ReencodeResult.Reencoded -> { + val compressedDims = readDimensions(result.file) ?: (0 to 0) + PreviewItem.Reencoded( + source = file, + originalSize = originalSize, + originalDims = originalDims, + compressedFile = result.file, + compressedSize = result.file.length(), + compressedDims = compressedDims, + quality = quality, + ) + } + is ReencodeResult.PassThrough -> + PreviewItem.PassThrough( + source = file, + originalSize = originalSize, + originalDims = originalDims, + reason = result.reason, + ) + } + } catch (e: CompressionException) { + PreviewItem.Failed( + source = file, + originalSize = originalSize, + originalDims = originalDims, + exception = e, + sourceFormat = ImageFormatSniffer.sniff(file), + ) + } + } + +/** Delete any temp files held by Reencoded items. Called on Cancel. */ +fun cleanupPreviewTemps(items: List) { + items.forEach { item -> + if (item is PreviewItem.Reencoded) { + item.compressedFile.delete() + } + } +} + +/** Compact byte formatter for the preview rows. */ +internal fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + if (bytes < 1024 * 1024) return "%.1f KB".format(bytes / 1024.0) + return "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} + +internal fun formatDims(dims: Pair?): String = dims?.let { "${it.first}×${it.second}" } ?: "unknown" + +internal fun friendlyFailReason(e: CompressionException): String = + when (e) { + is CompressionException.UnsupportedFormat -> "format not supported (${e.format})" + is CompressionException.InputTooLarge -> "image is larger than ${e.limit / 1_000_000} megapixels" + is CompressionException.EncodeFailed -> "encoder error (${e.cause?.message ?: e.message})" + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt new file mode 100644 index 0000000000..6dbb027a6f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreviewDialog.kt @@ -0,0 +1,446 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason + +/** + * Preview-then-publish dialog: every attachment gets a row showing + * what will actually be uploaded. Re-encoded rows show original-vs- + * compressed thumbnails with dim/size/savings stats; click a row + * to open a side-by-side zoom. Pass-through and fail rows render + * with a badge so the user knows what's being shipped untouched. + * + * Built with `Dialog { Surface }` (not AlertDialog) — see the + * compose-expert review notes in the plan: AlertDialog cramps wide + * content and the state-isolation trap prevents LaunchedEffect / + * subscription patterns inside its `text` slot. + */ +@Composable +fun CompressionPreviewDialog( + items: List, + stripExifSetting: Boolean, + onPublish: () -> Unit, + onCancel: () -> Unit, +) { + var zoomed by remember { mutableStateOf(null) } + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.width(820.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + "Preview & publish", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + summaryLine(items), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items, key = { it.source.canonicalPath }) { item -> + when (item) { + is PreviewItem.Reencoded -> + ReencodedRow(item = item, onZoom = { zoomed = item }) + is PreviewItem.PassThrough -> PassThroughRow(item = item) + is PreviewItem.Failed -> FailedRow(item = item, stripExifSetting = stripExifSetting) + is PreviewItem.NonImage -> NonImageRow(item = item) + } + } + } + + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onCancel) { + Text("Cancel") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = onPublish) { + Text("Publish (${items.size})") + } + } + } + } + } + + zoomed?.let { item -> + ZoomCompareDialog(item = item, onDismiss = { zoomed = null }) + } +} + +// ---------- row composables ---------- + +@Composable +private fun ReencodedRow( + item: PreviewItem.Reencoded, + onZoom: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable { onZoom() }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(8.dp)) + Text("→", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(8.dp)) + Thumbnail(item.compressedFile) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(2.dp)) + Text( + "${formatDims(item.originalDims)} → ${formatDims(item.compressedDims)} · ${item.quality.chipLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)} · saves ${"%.0f".format(item.savings * 100)}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Click to compare", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun PassThroughRow(item: PreviewItem.PassThrough) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(2.dp)) + Text( + "${formatDims(item.originalDims)} · ${formatBytes(item.originalSize)} · uploaded as-is", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = {}, + label = { Text(passReasonBadge(item.reason), style = MaterialTheme.typography.labelSmall) }, + colors = AssistChipDefaults.assistChipColors(), + ) + } + } +} + +@Composable +private fun FailedRow( + item: PreviewItem.Failed, + stripExifSetting: Boolean, +) { + val isJpeg = item.sourceFormat is com.vitorpamplona.amethyst.commons.service.upload.ImageFormat.Jpeg + val privacy = + when { + stripExifSetting && isJpeg -> "EXIF will be stripped before upload" + stripExifSetting && !isJpeg -> "metadata may still be present (non-JPEG)" + else -> "metadata preserved per your settings" + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.20f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(item.source) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Text( + "Could not compress: ${friendlyFailReason(item.exception)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + "Will send original (${formatBytes(item.originalSize)}) · $privacy", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun NonImageRow(item: PreviewItem.NonImage) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Text( + item.source.extension + .uppercase() + .take(4), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + item.source.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + ) + Text( + "${formatBytes(item.originalSize)} · uploaded as-is (non-image)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Thumbnail(file: java.io.File) { + AsyncImage( + model = file, + contentDescription = file.name, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) +} + +// ---------- zoom sub-dialog ---------- + +@Composable +private fun ZoomCompareDialog( + item: PreviewItem.Reencoded, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.width(960.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + item.source.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Saves ${"%.0f".format(item.savings * 100)}% (${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)}) at ${item.quality.chipLabel}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ZoomSide(label = "Original", dims = item.originalDims, sizeBytes = item.originalSize, file = item.source) + ZoomSide(label = "Compressed", dims = item.compressedDims, sizeBytes = item.compressedSize, file = item.compressedFile) + } + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onDismiss) { Text("Close") } + } + } + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.ZoomSide( + label: String, + dims: Pair?, + sizeBytes: Long, + file: java.io.File, +) { + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "${formatDims(dims)} · ${formatBytes(sizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + AsyncImage( + model = file, + contentDescription = "$label: ${file.name}", + modifier = + Modifier + .fillMaxWidth() + .height(420.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentScale = ContentScale.Fit, + ) + } +} + +// ---------- helpers ---------- + +private fun summaryLine(items: List): String { + val reencoded = items.count { it is PreviewItem.Reencoded } + val passthrough = items.count { it is PreviewItem.PassThrough } + val failed = items.count { it is PreviewItem.Failed } + val nonImage = items.count { it is PreviewItem.NonImage } + val totalSaving = + items + .filterIsInstance() + .sumOf { it.originalSize - it.compressedSize } + val parts = mutableListOf() + if (reencoded > 0) parts.add("$reencoded reencoded (saves ${formatBytes(totalSaving)})") + if (passthrough > 0) parts.add("$passthrough pass-through") + if (failed > 0) parts.add("$failed couldn't be compressed") + if (nonImage > 0) parts.add("$nonImage non-image") + return parts.joinToString(" · ") +} + +private fun passReasonBadge(reason: PassReason): String = + when (reason) { + PassReason.Animated -> "Animated · uploaded as-is" + PassReason.Vector -> "Vector · uploaded as-is" + PassReason.BypassByUser -> "Original · uploaded as-is" + }