From 9aad12804aff78e6f1932040d74e59df8f338fef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 9 Jun 2026 10:32:56 +0300 Subject: [PATCH] feat(desktop): fail-loud confirm dialog on compression failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 of the desktop image compression plan. Never silently downgrade — when ImageReencoder throws CompressionException for one or more attachments (UnsupportedFormat, InputTooLarge, EncodeFailed), the compose dialog now surfaces a modal listing each failure and lets the user choose between Send Original (uploads raw bytes, EXIF-stripped for JPEGs) or Cancel post. - UploadOrchestrator gains bypassReencode: Boolean = false. When true, the orchestrator skips ImageReencoder and treats the source as a PassThrough(BypassByUser), preserving the EXIF strip + temp-cleanup semantics from the normal pass-through path. - ImageReencoder.PassReason gains BypassByUser. - CompressionFailureDialog is built as Dialog { Surface } (not AlertDialog) because: * the body is a LazyColumn that grows with N failures — AlertDialog's `text` slot has fixed-width constraints, * LaunchedEffect / produceState don't fire inside AlertDialog.text (see custom-feeds-alertdialog.md memory), leaving room for future per-row actions (e.g. per-file retry). Each row shows: filename, "Could not compress: " in error color, original byte count, and a privacy hint that differs by source format ("EXIF will be stripped" for JPEG bypass, "metadata may still be present" for PNG/HEIC bypass). - ComposeNoteDialog send loop now wraps each upload in try/catch (CompressionException), collects failures, and after the loop awaits the user's choice via CompletableDeferred. On SendOriginal: re-uploads each failure with bypassReencode=true. On Cancel: aborts the post. --- .../commons/service/upload/ImageReencoder.kt | 7 + .../service/upload/UploadOrchestrator.kt | 15 +- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 100 ++++++++- .../ui/media/CompressionFailureDialog.kt | 193 ++++++++++++++++++ 4 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionFailureDialog.kt diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt index de79906071..a3cd78fe70 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/ImageReencoder.kt @@ -227,6 +227,13 @@ object ImageReencoder { /** Vector format (SVG) — no raster re-encode is meaningful. */ Vector, + + /** + * Re-encode was attempted earlier and failed; the user + * confirmed via the fail-loud dialog that the original + * should be uploaded anyway. + */ + BypassByUser, } /** Outcome of a [reencode] call. */ diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index ae62f0e6bb..2d7d2f7ede 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -73,12 +73,23 @@ class UploadOrchestrator( signer: NostrSigner, stripExif: Boolean = true, quality: CompressionQuality = CompressionQuality.DEFAULT, + bypassReencode: Boolean = false, ): UploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null try { - // 1. Re-encode (or pass-through). - val reencode = ImageReencoder.reencode(file, quality) + // 1. Re-encode (or pass-through). The bypass branch is + // used by the fail-loud confirm dialog: when the user + // clicks "Send Original" after a reencode failure, we + // re-invoke upload with bypassReencode=true to skip + // the reencoder and ship the source bytes (still + // EXIF-stripped if JPEG + stripExif=true). + val reencode = + if (bypassReencode) { + ReencodeResult.PassThrough(ImageReencoder.PassReason.BypassByUser) + } else { + ImageReencoder.reencode(file, quality) + } val afterReencode = when (reencode) { is ReencodeResult.Reencoded -> reencode.file.also { reencodedTemp = it } 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 28f93a4950..b58dd5479e 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 @@ -63,7 +63,9 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog 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 @@ -75,7 +77,9 @@ 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.DesktopFilePicker +import com.vitorpamplona.amethyst.desktop.ui.media.FailedAttachment import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip import com.vitorpamplona.quartz.nip01Core.core.Event @@ -93,6 +97,7 @@ 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 @@ -181,6 +186,10 @@ 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) } + // Relay picker state val connectedRelays by relayManager.connectedRelays.collectAsState() val allRelays by relayManager.availableRelays.collectAsState() @@ -447,24 +456,73 @@ fun ComposeNoteDialog( // 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}") - val result = - orchestrator.upload( - file = file, - alt = null, - serverBaseUrl = selectedServer, - signer = account.signer, - stripExif = stripExifSetting, - quality = activeQuality, + 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), + ), ) - uploadTracker.onSuccess(result) - uploadResults.add(result) + } } + + // 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 @@ -519,8 +577,30 @@ fun ComposeNoteDialog( } } } + + pendingFailureDialog?.let { state -> + CompressionFailureDialog( + failures = state.failures, + stripExifOnByPass = stripExifSetting, + onSendOriginal = { state.choice.complete(FailureUserChoice.SendOriginal) }, + onCancel = { state.choice.complete(FailureUserChoice.Cancel) }, + ) + } } +/** + * 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 new file mode 100644 index 0000000000..a87b29649e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionFailureDialog.kt @@ -0,0 +1,193 @@ +/* + * 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)) +}