mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+7
@@ -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. */
|
||||
|
||||
+13
-2
@@ -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 }
|
||||
|
||||
+90
-10
@@ -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
|
||||
|
||||
+193
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user