feat(desktop): fail-loud confirm dialog on compression failure

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: <reason>" 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<FailureUserChoice>.
    On SendOriginal: re-uploads each failure with
    bypassReencode=true. On Cancel: aborts the post.
This commit is contained in:
nrobi144
2026-06-09 11:42:00 +03:00
parent 4c1464bfb3
commit 9aad12804a
4 changed files with 303 additions and 12 deletions
@@ -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. */
@@ -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 }
@@ -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<CompressionQuality?>(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<FailureDialogState?>(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<UploadResult>()
val failures = mutableListOf<FailedAttachment>()
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<FailureUserChoice>()
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<FailedAttachment>,
val choice: CompletableDeferred<FailureUserChoice>,
)
private enum class FailureUserChoice { SendOriginal, Cancel }
private fun buildIMetaTags(results: List<UploadResult>): List<IMetaTag> =
results.mapNotNull { result ->
val url = result.blossom.url ?: return@mapNotNull null
@@ -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<FailedAttachment>,
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))
}