Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
Manage request nests under offer.fields, payer_data is a string list, and the
failure response carries field (guards the 21003 shape fix).
All CLINK tests pass.
Two robustness fixes from the audit:
- OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to
self when an authored event lacked its p tag, deriving a NIP-44 key with
myself) with conversationPeer(), which returns null when I'm neither the
author nor the addressed recipient; decryptContent then fails cleanly with
UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer
exists'.
- OfferClient/DebitClient/ManageClient responseFilter now also requires
#p == my pubkey, so a service reply that e-tags my request but is addressed
to a different payer no longer matches my subscription.
Valid request/response round-trips are unchanged (CLINK tests pass).
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
data under offer{id,fields}, payer_data is a string list (not a map), and the
response uses details + field (was offer/offers). Documented the single-object
details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
results) so profile visits / kind-0 refreshes don't refetch nostr.json.
Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
Resolves two review findings:
- App Functions (Assistant) pay path gated on hasWalletConnectSetup() and so
ignored a debit-only user's chosen default. payViaNwcOrNull is generalized to
payViaDefaultSourceOrNull, routing through account.settings.defaultPaymentSource()
(NWC wallet or CLINK debit), matching the rest of the app; NwcOutcome -> PayOutcome.
- AccountViewModel.payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main
(was Dispatchers.IO via launchSigner), consistent with requestDebitBudget and safe
for UI callbacks (toasts/dialogs).
:amethyst (play) compiles.
From a correctness review of the CLINK branch:
- ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from
parseResponse and treat an undecryptable reply as no response (return null)
instead of throwing. An uncaught SerializationException/NIP-44 failure
escaped launchSigner (which only catches signer exceptions), hanging the UI:
the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast
never shown, and a split zap silently cancelling sibling payments.
- ClinkOfferPreview now renders the active (possibly moved) offer's price/type,
not the original pointer's, after an Expired-or-Moved redirect.
- WalletViewModel.setDefaultWallet only updates local state when the persist
actually succeeds, so the default star can't diverge from the stored value.
:amethyst compiles.
Exposes the CLINK Debits budget capability (requestBudget) the spec describes:
- ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and
awaits the reply; the publish/await machinery is factored out of payInvoice
into a shared sendAndAwait helper.
- DebitFrequency gains UNIT_DAY/WEEK/MONTH constants.
- WalletViewModel.requestDebitBudget resolves the debit pointer and runs it.
- A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount +
one-time/daily/weekly/monthly cadence); the result is surfaced as a toast.
:amethyst compiles. The 21002 budget round-trip is untested end-to-end.
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).
Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase
tag conventions, replacing raw inline tags:
- Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and
signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign().
- Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals.
Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes
and tag order is unchanged, so signed events are byte-identical. All CLINK tests
pass (ClinkEventTest, ClinkClientServerTest, pointer/interop).
Note: these are NIP-44-encrypted request/response events, so create*() stays a
suspend factory that encrypts then signs the template — matching NIP-47; a pure
pre-signing template isn't possible without the signer.
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
ad-hoc CLINK_OFFER_PROPERTY constant.
UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
Paying a CLINK offer is already a direct payment to the recipient; wrapping it
in a NIP-57 zap request conflated two different things. Removes the zappable-
offers and post-level-zap behavior:
- ClinkOfferPreview no longer builds a zap request or takes authorPubKey/zapEvent;
it just pays the fetched invoice via the default source.
- ClinkOfferPayer.requestInvoice drops the zap param.
- Unwinds the zapEvent threading through RichTextViewer / ExpandableRichTextViewer
/ TranslatableRichTextViewer (both flavors) and reverts Text.kt.
Keeps the rest of the offer card (variable amount, moved-offer follow, default
payment-source dispatch) and the profile receive side intact. Both flavors compile.
Threads the note's Event through the rich-text render chain
(TranslatableRichTextViewer [play+fdroid] -> ExpandableRichTextViewer ->
RichTextViewer -> word renderers -> ClinkOfferPreview) as a default-null
zapEvent. The offer card now prefers an e-tag zap on the post when the event
is present, falling back to the author (p-tag) zap, then a plain invoice.
Also fixes coverage: the main note body (Text.kt) didn't pass author context
at all, so offer zaps previously only fired in chat. It now passes both
authorPubKey and the note event, so paying a noffer in a feed post lands as a
zap on that post.
Both flavors compile. Zap round-trip/receipt untested end-to-end.
When the offer service replies EXPIRED_OR_MOVED with a replacement noffer in
'latest', the card now parses it, swaps to the new pointer, and retries the
request once (paying the relocated offer) instead of dead-ending on an error.
Request handling is refactored into a single helper so the amount field and
zap-request attachment apply to the retry too.
:amethyst compiles; offer round-trip remains untested end-to-end.
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
well-known clink_offer (keyed by local name, mirroring the names map; exact
shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
profile.
quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
Adds a 'CLINK Offer (noffer)' field to the profile editor so users can advertise
a payment offer in their kind-0 metadata:
- UserMetadataState.sendNewUserMetadata threads clinkOffer into the kind-0 build.
- NewUserMetadataViewModel loads/saves/clears the clinkOffer field.
- NewUserMetadataScreen renders the input (placeholder noffer1…).
:amethyst compiles. The read side (NIP-05 clink_offer discovery + preferring it)
is the next step.
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
kind-0 content via the new CLINK_OFFER_PROPERTY key.
Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
When a noffer is rendered in someone's note, paying it now attaches a NIP-57
zap request so the offer service issues a zappable invoice and publishes a zap
receipt — turning the payment into a real zap on the author instead of a silent
invoice:
- ClinkOfferPayer.requestInvoice forwards a serialized zap request via
OfferClient's existing zap field.
- ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the
account's default zap type (skipped for NONZAP), at the resolved amount.
- RichTextViewer threads authorPubKey into the offer-segment renderers; the
markdown/secret paths default to null (plain invoice, no target).
Falls back to a plain invoice when there's no author or the user opted out of
zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end.
The offer card no longer assumes a fixed price:
- FIXED offers show their preset price (unchanged).
- SPONTANEOUS offers (and the spec default when the pointer omits a price
type) now render an amount field; Pay is disabled until a positive amount
is entered, and that amount is sent as amount_sats.
- An INVALID_AMOUNT (code 5) response reveals/refines the amount field and
shows the service's allowed range, so variable offers recover gracefully
even when the price type was ambiguous.
:amethyst compiles; the offer round-trip remains untested end-to-end.
In-post payment cards now route their 'Pay' button through the selected
default payment source instead of always opening an external wallet:
- New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a
bolt11. External-wallet path fires the intent (the wallet app confirms);
NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card
pay (unlike a deliberate small zap tap) can be a larger/variable amount.
- ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice
state; InvoicePreview now takes accountViewModel.
NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app
pay paths remain untested end-to-end.
Surfaces debits in the existing wallet list and add flow:
- WalletViewModel reads clinkDebitWallets and emits them as spend-only rows
(canShowBalance=false) in the unified walletInfoList; the default radio
spans both types via setDefaultPaymentSource; remove/rename route by type.
- WalletScreen renders debit rows with a Pay only badge instead of a balance
and disables the NWC-only detail navigation for them.
- AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes
or scans an ndebit1 pointer (no secret) and saves it as a payment source.
- New Route.WalletAddClinkDebit + AppNavigation registration.
:amethyst compiles. UI rendering and the end-to-end debit payout remain
untested on device.
ZapPaymentHandler now dispatches on account.settings.defaultPaymentSource():
a CLINK debit default pays each zap invoice via the new payViaClinkDebit
(kind-21002 round-trip through ClinkDebitPayer, surfacing the service's GFY
error text); an NWC default keeps the existing payViaNWC path; no configured
source falls back to the external wallet intent. NWC-only users are unaffected.
The two secondary single-invoice sites (profile LN-address pay, DVM pay) still
use NWC/intent and are left as follow-ups.
:amethyst compiles. The debit payout path is untested end-to-end — it needs a
live debit service to verify a real payment.
Wires the payment-source model into AccountSettings + LocalPreferences:
- AccountSettings gains clinkDebitWallets and add/remove/rename methods
mirroring the NWC ones, plus defaultPaymentSource() resolving the unified
default via PaymentSourceResolver.
- Renames defaultNwcWalletId -> defaultPaymentSourceId: one default id spans
both NWC wallets and CLINK debits. First configured source of any kind
auto-defaults; adding more never silently changes it; removing the default
falls back to the first remaining source.
- LocalPreferences persists clinkDebitWallets and defaultPaymentSourceId,
migrating the legacy defaultNwcWalletId key on load.
- NwcSignerState/WalletViewModel read the unified default; NWC zap routing is
unchanged for NWC-only users (falls back to first NWC wallet).
:amethyst compiles. The Wallet-screen rows/confirm dialog and routing the zap
button through ClinkDebitPayer are the next (compile-only) step.
Adds the verifiable core for using a CLINK debit pointer as a spend rail
alongside NWC:
- ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only
counterpart of NwcWalletEntry (no secret, no balance/history)
- PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and
CLINK debits into one list with a single default id spanning both types;
no explicit default falls back to first (NWC before debits), preserving
today's behavior. canShowBalance marks NWC vs debit honestly.
- ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits
the preimage via a one-shot subscription, mirroring ClinkOfferPayer.
Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl.
cross-type default + stale-id fallback); amethyst compiles. Persisting the
new fields in AccountSettings and the Wallet-screen rows/confirm dialog are
the next (compile-only) step.
Wires the ClinkOfferSegment into RichTextViewer with a ClinkOfferPreview
card (modeled on InvoicePreview): shows the offer + price and a Pay
button. Pay runs ClinkOfferPayer, which publishes the kind-21001 request
to the offer's relays and awaits the encrypted reply via a one-shot
subscription, then hands the returned bolt11 to the existing
payViaIntent wallet flow. Consume-only; Amethyst never answers offers.
Compiles (:amethyst:compilePlayDebugKotlin); visual rendering and a live
offer round-trip still need on-device verification.
Teaches the commons RichTextParser to recognize an inline noffer1...
token and emit a ClinkOfferSegment carrying the decoded NOffer, so a
GUI front end can render a 'Pay' card in the note body (the feed-offer
feature). Bare tokens only for now; nostr:/lightning: prefixed forms
fall through. Covered by ClinkOfferSegmentTest on JVM.
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
request from a decoded pointer, expose the relays to publish on, the
response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
check, plus K1Tracker for single-use debit session enforcement
Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
carrying both request and response over one kind, NIP-44 encrypted,
with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory
Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.
Adds round-trip + dispatch + reject tests in commonTest.
Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on
Quartz (client + server) and Amethyst (consume-only), reusing NIP-44,
bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler.
Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19).
The in-stream loading marker spelled out "N relays" only for the fully-loaded
(done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the
relays plural for the count fallback on every state so a count chip always reads
as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).
Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.
Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:
- uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
- uploadPassesAuthHeaderToClient (.txt)
- uploadPassesSameFileWhenNoStripExif (.txt)
- uploadComputesMetadata (.txt)
The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).
Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:
1. UploadOrchestrator.upload's quality parameter is now nullable
(CompressionQuality? = null). Null means "do not reencode" —
matches the orchestrator's behavior before this feature, so the
Android, CLI, and any non-image upload path keeps working
unchanged. The desktop compose flow continues to pass a non-null
CompressionQuality so it still runs the reencoder.
2. ImageReencoder no longer throws UnsupportedFormat for
ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
AVIF and HEIC still throw (those are recognized formats we
explicitly refuse). The new PassReason.NotAnImage is rendered in
the preview dialog as "Not an image · uploaded as-is" / "Metadata
preserved (non-image — no re-encode applies)".
My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
In the lightbox/carousel:
- Hover over the image → Material3 PlainTooltip shows the full
Blossom URL above the image (TooltipAnchorPosition.Above, 8 dp
gap). Same TooltipBox pattern already used in
MediaServerSettings.
- Single-click on the image → copies the URL to the system
clipboard via AWT Toolkit, then surfaces a green snackbar
banner at the top: "Copied <url> to clipboard". The banner
slides in from above, sits below the download banner if both
fire simultaneously, and auto-dismisses after 2.5 s
(LaunchedEffect on the message state).
- Double-click still resets zoom — unchanged.
- The MoreOptionsMenu's "Copy URL" rows on both the image and
video paths now route through the same copyUrlToClipboard
helper so they also trigger the snackbar (previously they
copied silently with no user feedback).
ZoomableImage gains an `onTap: (() -> Unit)?` parameter; null
keeps the old "consume single tap" behavior, set means the caller
handles the click (lightbox uses it for the copy action).
"Cancel" implies the post is being abandoned. The actual behavior
is to return to the compose dialog with attachments still attached
so the user can adjust quality, swap files, or change copy before
re-triggering Preview. "Back" matches the semantic.
Reported via runtime crash dialog on the user's first PNG upload:
Exception in thread "AWT-EventQueue-0":
java.lang.IllegalStateException: Can't overwrite cause with
javax.imageio.IIOException: Bogus input colorspace
at java.lang.Throwable.initCause(Throwable.java:464)
at CompressionException.<init>(CompressionException.kt:39)
Two real bugs:
1. CompressionException constructor double-set the cause.
Exception(message, cause) super already wires the Throwable's
cause slot; the init block then called initCause(cause) AGAIN
which throws IllegalStateException by spec ("Can't overwrite
cause"). The init block was added per a code-review note that
was wrong about how Kotlin's primary constructor forwards
cause. Removed the init block; relying on super does the
right thing. Regression: encodeFailedWrapsCauseWithoutCrashing.
2. JPEG writer rejected non-RGB BufferedImages with "Bogus input
colorspace". TYPE_INT_ARGB (typical PNG decode), TYPE_BYTE_GRAY,
TYPE_CUSTOM (CMYK JPEGs, indexed PNGs) all blow up the stock
JPEGImageWriter. encodeJpeg now flattens via toRgbCanvas — draws
onto a fresh TYPE_INT_RGB canvas with white background for any
transparent pixels. White matches what every major image viewer
does for transparent PNGs over a light surface.
Regression: reencodesPngWithAlphaToJpeg.
Both regressions are covered by new tests so the patterns cannot
silently come back. Reencoder test count: 13 -> 15.
Reworked the per-row toggle in CompressionPreviewDialog to match the
user's actual intent. Previously the Switch meant "exclude this
attachment from the post entirely"; now it means "upload the original
bytes instead of the compressed version" — which is the only
meaningful per-row choice once you've already attached something.
Behavior:
- Toggle off the compression on a Reencoded row → orchestrator
uses bypassReencode=true (= upload original), the cached
compressed temp is deleted right before upload so it never
leaks.
- The Publish button no longer changes count or disables —
everything attached gets uploaded.
- Cancel still cleans up every cached compressed temp.
Layout fix the user called out:
- Only the compressed half of the row dims (thumbnail + arrow).
The original thumbnail stays full-color because that's what's
actually being uploaded when "use original" is on.
- The stats/savings line is replaced by "compression skipped —
original uploads as-is" when toggled.
- The metadata-strip sub-line now flips dynamically:
compressed → "All EXIF, GPS, camera tags stripped (re-encoded)"
original + strip ON + JPEG → "EXIF, GPS, camera tags stripped
from original before upload"
original + strip ON + non-JPEG → red warning: "Metadata
preserved — strip only runs
on JPEG; original is non-JPEG"
original + strip OFF → "Metadata preserved (EXIF strip off
in settings)"
Style fix: replaced the chunky Switch with a small TextButton —
"Use original" by default (muted color) → "Using original — undo"
when active (error color). Matches the rest of the dialog's
TextButton + DropdownMenu vocabulary; reads as a desktop action,
not a mobile preference.
The toggle is intentionally removed from PassThrough / Failed /
NonImage rows — those have no per-row choice (always-as-original
by design) and a control there would be deceptive.
API change: CompressionPreviewDialog.onPublish is now
(List<PreviewItem>, useOriginalPaths: Set<String>) -> Unit.
runPublish in ComposeNoteDialog routes Reencoded items in the
useOriginalPaths set through orchestrator.upload(bypassReencode =
true) and deletes the unused compressed temp inline.
Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.
Per-row skip toggle:
- Every preview row gains a Switch labeled "Include" / "Skipped"
(the verb is shown so the user can't misread a bare switch).
- Skipped rows dim the thumbnail (0.4 alpha) and tone down the
surface, hide the "Click to compare" hint, and disable the
click-to-zoom.
- Publish button label now reflects the included count —
"Publish (4)" when nothing skipped, "Publish (3 of 5)" with
skips, "Nothing to publish" + disabled state when all skipped.
- On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
included subset is handed off to UploadOrchestrator via the
preCompressed param as before.
- onPublish signature changed: (List<PreviewItem>) -> Unit, and
runPublish in ComposeNoteDialog now takes the filtered list
rather than reading pendingPreview directly.
Explicit metadata-strip status on every row:
- Reencoded rows: "All EXIF, GPS, camera tags stripped
(re-encoded to JPEG)" in the tertiary color. Re-encode wipes
metadata regardless of the strip-EXIF setting because we
don't preserve any metadata in the JPEG writer.
- PassThrough rows:
Animated → "Metadata preserved (animated — re-encode would
drop frames)"
Vector → "No raster metadata (SVG)"
Bypass → "Metadata preserved per your override"
- Failed rows (going to send original):
JPEG + strip on → "EXIF, GPS, camera tags stripped before
upload" in tertiary color
non-JPEG + strip on → "Metadata preserved — strip only runs
on JPEG; this is <Format>" in error
color (privacy warning)
strip off → "Metadata preserved (EXIF strip off in settings)"
- NonImage rows: "Metadata preserved — EXIF strip applies to
JPEG only"
The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
When the post has image attachments, the Publish button now reads
"Preview" instead. Clicking it runs ImageReencoder on every
attachment eagerly, then opens CompressionPreviewDialog with one
row per file:
- Reencoded rows: original thumbnail → compressed thumbnail +
dims/sizes/savings % + chip showing the active quality preset.
Click the row to open a side-by-side ZoomCompareDialog with
420 dp images and a "Saves N%" header.
- PassThrough rows: original thumbnail + "Animated / Vector ·
uploaded as-is" assist chip — covers animated GIF, animated
WebP, SVG, and the bypass-by-user path.
- Failed rows: original thumbnail + red-bordered surface +
"Could not compress: <reason>" + the privacy hint
("EXIF will be stripped" for JPEG, "metadata may still be
present" for non-JPEG). User can still publish — original
bytes ship.
- NonImage rows: filename + extension badge + "uploaded as-is"
for any non-image attachment caught up in the batch.
The dialog's Publish button calls the same runPublish lambda the
main button uses. The lambda walks the preview items and tells the
orchestrator either:
- preCompressed = <cached temp> for Reencoded,
- bypassReencode = true for Failed,
- default flags for PassThrough / NonImage.
UploadOrchestrator.upload gains a `preCompressed: File?` param
so the dialog can hand off ownership of the cached temp; the
orchestrator deletes it after the actual upload in the same
finally block.
Cancel cleans up every cached temp via cleanupPreviewTemps so a
dismissed preview doesn't leak.
The standalone CompressionFailureDialog from Phase 7 is now
unreachable (all failures surface inline in the preview), so it
gets deleted. The shared `runPublish` lambda was hoisted out of
the Card into the composable's top scope so both the main button
and the preview's onPublish callback can call it.
Triggered by the user's manual-testing feedback: "shouldn't I
preview the compressed images before publishing the note?" — the
plan's deferred compare dialog became the natural publish gate.
The options row (Upload to / Quality / Post as) was wrapping
"Note" to two lines at the 600 dp dialog width — see the
screenshot the user surfaced during manual testing.
- Bumped the compose dialog from 600 dp to 780 dp and added
DialogProperties(usePlatformDefaultWidth = false) so the
explicit width is honored.
- Added maxLines=1 + softWrap=false to all three selector
TextButton labels (ServerSelector, QualitySelectorChip,
PostTypeSelector) so they can never wrap regardless of
future attachment count or label growth.
Also threads through a new preCompressed: File? param on
UploadOrchestrator.upload — landed early because the preview-
gate work needs it. When the upcoming CompressionPreviewDialog
hands off a pre-computed temp, the orchestrator skips reencode
+ stripExif and just uploads + cleans up.