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.
Manual testing feedback from the UI:
- The Post-as Note/Picture FilterChip pair was overflowing the
options row and rendering "Picture" rotated 90°. Converted it
to the same Text + TextButton + DropdownMenu pattern as the
sibling Upload-to and Quality controls so the row stays
compact and visually consistent.
- QualitySelectorChip was likewise a FilterChip; rewritten as
Text + TextButton + DropdownMenu to match. Dropdown rows now
carry a two-line layout: bold preset label (e.g.
"Medium (640 px)") with a sub-line summary explaining the
tradeoff ("640 px · balanced size and quality").
- Restored the HIGH preset (640 px @ q=0.85, "visually lossless
on phones") between Medium and Desktop High. The four-preset
set is now Low / Medium / High / Desktop High — closer to the
Android-parity progression the brainstorm originally specced.
- CompressionQuality enum gains a `summary` field (one-line
description) plus a `chipLabel` convenience ("Medium (640 px)").
Settings panel and dropdown both consume `summary` so users
see what each preset actually does without trial-and-error.
ImageReencoderTest gains a HIGH-preset test and the monotonicity
test now asserts LOW < MEDIUM < HIGH for the same-dim subset.
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.
Phase 6 of the desktop image compression plan.
ClipboardPasteHandler previously wrote clipboard_*.png to /tmp with
deleteOnExit, leaking across long-running JVM sessions (the
existing leak documented in docs/temp-file-cleanup-analysis.md
under "Desktop temp files (out of scope for this change)").
Now lands under AmethystTempDir as
amethyst_paste_YYYYMMDD-HHMMSS_<rand>.png
so:
- the boot-time orphan sweep recovers it if the JVM crashes
before the upload pipeline consumes it,
- the directory mode (0700 on POSIX) keeps it out of reach of
other local users on shared systems,
- the filename surfaces the paste timestamp for diagnostics.
The PNG roundtrip is unavoidable: Java's clipboard image flavor
only exposes BufferedImage, so PNG is the lossless container we
materialize before the orchestrator's ImageReencoder decides to
re-encode at the active quality preset.
Phases 4 + 5 of the desktop image compression plan, landed together
because they touched the same send-loop / options-row code.
- New QualitySelectorChip composable (FilterChip + anchored
DropdownMenu) shows "Quality: <displayName>" and visually
highlights when the user has overridden the global default.
Reset row appears only after the user has chosen an override.
- ComposeNoteDialog gains:
* defaultQuality / stripExifSetting read from
ImageCompressionStore via .collectAsState() so changing the
Media settings panel updates the compose dialog live,
* perPostQualityOverride: CompressionQuality? for one-off
overrides scoped to the current post,
* activeQuality = override ?: default,
* QualitySelectorChip wired into the existing options row
(when attachments include images), next to the
ServerSelector and PostTypeSelector,
* the upload loop now passes both stripExif and quality
through to UploadOrchestrator.upload(),
* inline batch progress via the existing tracker fileName
slot — "1/3: foo.jpg", "2/3: bar.jpg", "3/3: baz.jpg" —
no new tracker state class needed,
* perPostQualityOverride resets after a successful send so
the next post starts from the saved default again.
Phase 3 of the desktop image compression plan.
ImageCompressionSettings composable in
desktopApp/.../ui/settings/, modeled on the existing
MediaServerSettings shape:
- Section header "Image Compression"
- Default quality as SingleChoiceSegmentedButtonRow (Low / Medium
/ Desktop High), matching the codebase convention from
TorSettingsSection / FeedBuilderDialog (NOT a DropdownMenu).
- Per-preset hint text underneath the segmented row, refreshed
reactively as the user clicks.
- Strip-metadata Switch with hint "Removes camera, GPS, and
timestamp data from uploaded photos."
State flows from ImageCompressionStore via .collectAsState() — uses
the JVM-portable Compose API (collectAsStateWithLifecycle is
Android-only and is not used anywhere in desktopApp).
Integrated into Main.kt:1791 just below MediaServerSettings,
surrounded by the standard HorizontalDivider + Spacer rhythm.
Per-post override (compose dialog) will read from the same store
in Phase 4.
Phase 2 of the desktop image compression plan.
- DesktopPreferences gains two raw prefs:
KEY_IMAGE_QUALITY (default "DESKTOP_HIGH") and
KEY_IMAGE_STRIP_EXIF (default true). Marked internal — callers
should go through ImageCompressionStore, not the raw prefs.
- ImageCompressionStore mirrors SearchHistoryStore: object
singleton, init seeds StateFlow from prefs, setters write
through to both StateFlow and prefs in one shot. Exposes
quality: StateFlow<CompressionQuality> and stripExif:
StateFlow<Boolean> for Compose reactivity.
Per-post override state lives in ComposeNoteDialog (Phase 4), not
here — this store carries only the default that the override falls
back to.
Phase 1 part C — the integration that makes the new compression
pipeline actually run on every upload.
- UploadOrchestrator.upload gains an optional quality parameter
(default CompressionQuality.DESKTOP_HIGH). The orchestrator now:
1. Runs ImageReencoder.reencode — branches on Reencoded vs
PassThrough.
2. For PassThrough + stripExif=true + JPEG source, runs
MediaCompressor.stripExif so animated/SVG passes still get
EXIF stripped where applicable.
3. Computes metadata on the bytes that will actually leave
the machine.
4. Eager-cleans up every intermediate in a finally{} block
wrapped in NonCancellable so user-cancelled uploads don't
leak temps.
Throws CompressionException for the fail-loud dialog path.
- MediaCompressor.stripExif drops deleteOnExit (long-running
desktop process was leaking stripped_*.jpg per
docs/temp-file-cleanup-analysis.md). Temp files now land under
AmethystTempDir with the amethyst_stripped_ prefix; caller owns
cleanup.
- AmethystTempDir lazy-initializer now triggers sweepOrphans() on
first access, recovering any amethyst_* files > 24h old left
behind by JVM crashes that skipped shutdown hooks.
- BlossomClient: open class + open upload methods so
UploadOrchestratorTest can substitute a FakeBlossomClient that
captures the uploaded file's bytes.
- jvmTest now pulls secp256k1.kmp.jni.jvm so end-to-end signer-
based tests (Blossom auth event signing) can run.
5 new orchestrator tests cover: JPEG re-encode path (uploaded file
is the AmethystTempDir temp, smaller than source, hash matches
captured bytes), animated GIF pass-through (uploaded == original),
AVIF refused with UnsupportedFormat (client.upload never called),
temp cleanup on success, and temp cleanup on upload failure.
Test totals across commons jvmTest upload package: 44 / 44 green
(4 smoke + 23 sniffer + 12 reencoder + 5 orchestrator).
Phase 1 part B. The core re-encode + downscale pipeline:
- AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
boot-time sweep of amethyst_* files > 24h. Defends against
/tmp tmpfs OOM on Linux VMs and against multi-user temp races
on shared systems. Overridable via -Damethyst.tmp.dir=.
- ImageReencoder.reencode(File, CompressionQuality) returns a
sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
and throws CompressionException for fatal cases.
* Format sniffer first → pass-through for animated GIF /
animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
* Pre-decode pixel guard: stream header dims via
ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
any pixel buffer is allocated.
* Subsampled decode (floor stride) so a 4032×3024 source decodes
to ~2016×1512 in heap before Thumbnailator's final resize.
* Never upscale: Thumbnails.of(...).size() is only called when
the source actually exceeds the target box.
* CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
with ensureActive() between stages.
* Cleanup on cancellation/failure runs in NonCancellable.
12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.
ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
Per .claude/CLAUDE.md: per-module plans live in the owning module's
plans/ folder. The global docs/plans/ is frozen. The image compression
feature is desktop-driven (commons gets the backing pipeline), so the
owning module is desktopApp.
Phase 1 part A of desktop image compression. Pure data types — no
external dependencies yet beyond Thumbnailator already on classpath.
- CompressionQuality enum (Low / Medium / Desktop High) with
JPEG quality values tuned for 2026 displays (0.65 / 0.75 / 0.90)
rather than the obsolete 2014-era Android values.
- ImageFormat sealed class covering the 9 formats the orchestrator
must distinguish (JPEG/PNG/BMP/TIFF re-encoded, animated GIF and
animated WebP byte-identical pass-through, SVG pass-through,
AVIF and HEIC refused for lack of a pure-Java decoder).
- ImageFormatSniffer with magic-byte detection + RFC 9649 VP8X
animation-flag check + GIF NETSCAPE2.0 application-extension
scan + ISO BMFF ftyp brand match for AVIF / HEIC variants.
- CompressionException sealed hierarchy (UnsupportedFormat /
InputTooLarge / EncodeFailed) with initCause for chain
preservation through logging.
23 sniffer unit tests cover each format including JPEG-via-file,
empty input, missing file, and both WebP animation paths.
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Plan for adding JPEG-only image re-encode + downscale pipeline to
desktop (and the Amy CLI), with EXIF strip, per-post quality override,
clipboard-paste integration, and a fail-loud failure dialog.
WebP encoding and HEIC input deferred to a JNI follow-up: no usable
pure-Java implementations exist in 2026 (sejda webp-imageio is JNI +
abandoned, TwelveMonkeys has no HEIC plugin per issue #976).
The equation image is taller than a text line, and the paragraph FlowRow
top-aligned its items, so equations hung below the baseline. Center items
on the cross axis (itemVerticalAlignment) so the equation sits centered
on the line; no-op for the common all-text row where every item is the
same height.
Some sources (e.g. the math-academy posts) over-escape their LaTeX, so
the content carries `\\ldots` / `\\cdots` instead of `\ldots` / `\cdots`.
JLaTeXMath reads the `\\` as a forced TeX line break — splitting every
inline equation across two lines — and renders the trailing command name
as the literal letters "ldots"/"cdots", so the ellipsis symbol is lost.
Collapse doubled backslashes (`\\cmd` -> `\cmd`) before rendering inline
math; display math is left untouched since `\\` can be a genuine line
break there. No-op when the content isn't over-escaped.
The "where the live tail ends and paged history begins" boundary (one week)
was copy-pasted across the gift-wrap + NIP-04 live tails, the backward history
pager floor, and the prune's recent/old split — easy to drift out of lockstep
(overlap = double-load, gap = missed messages).
- Add DmHistoryTuning: one place for liveTailSeconds + recentKeepCount, read by
AccountGiftWrapsEoseManager, both NIP-04 SubAssemblers, BackwardRelayPager,
and Chatroom.pruneMessagesToTheLatestOnly. Drops the duplicated
LIVE_TAIL_SECONDS / DEFAULT_LIVE_TAIL_SECONDS constants.
- Log the prune-time window realignment under DMPagination, per scope
([giftwrap] / [rooms.nip04] / [convo.nip04]): relay count + newest pruned
timestamp, so the rewind is observable in logcat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pins that a MathSegment covers only its $-delimited span (not the
paragraph), that space-separated hashtags, URLs and images next to math
stay independently detected, that `$x$.` keeps its trailing period while
a following hashtag remains its own segment, and that currency `$5`
doesn't pair with a later equation. Also documents the glued (no-space)
edge cases.