Commit Graph
14800 Commits
Author SHA1 Message Date
davotoula d90574c4e9 refactor: rename ReplaceableVideoEvent to AddressableVideoEvent 2026-06-10 20:32:57 +02:00
davotoula 638486ea1f Compute legacy NIP-71 video addresses with their d tag:
Extend BaseAddressableEvent instead so dTag() reads the real `d` tag.
2026-06-10 17:20:14 +02:00
Vitor PamplonaandGitHub e41169efbf Merge pull request #3164 from vitorpamplona/claude/focused-faraday-z9zs87
Work around Compose ui-uikit prebuilt cache linking failure
2026-06-09 19:29:00 -04:00
Claude 84497bbd47 perf(commons): index URLs by first char in fixMissingSpaces
fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.

Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
2026-06-09 22:52:00 +00:00
Claude f42cc836aa fix(commons): make fixMissingSpaces work on Kotlin/Native (iOS)
RichTextParser.fixMissingSpaces used a Regex of the form
`([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to
neighbouring text. Kotlin/Native's regex engine fails to backtrack the
optional `([^ \n])?` capture groups to zero width, so on iOS every URL was
corrupted (e.g. "https://x" became "h https://x"). That broke the
downstream segmenter, which is why :commons:iosSimulatorArm64Test reported
19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary
finally linked.

Replace the regex with a direct left-to-right scan that inserts a single
space wherever a detected URL touches a non-space/non-newline neighbour. The
scan is engine-independent, so it behaves identically on JVM and Native.
Verified equivalent to the old behaviour across the full commons richtext
JVM corpus, and the new FixMissingSpacesTest pins the cases on every target
(including iosSimulatorArm64).
2026-06-09 22:42:50 +00:00
Claude 9671fc4d04 fix(commons): disable native cache for iOS test binary to fix ui-uikit link
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).

Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
2026-06-09 21:44:11 +00:00
Vitor PamplonaandGitHub a7e892dc7c Merge pull request #3160 from davotoula/chore/remove-tarsosdsp-gpl
Replace GPLv3 TarsosDSP with an in-house pitch shifter
2026-06-09 17:09:11 -04:00
Vitor PamplonaandGitHub d8a37dfb0d Merge pull request #3163 from vitorpamplona/claude/nifty-cray-h4ctiu
Add NIP-14 group subjects and improve DM group creation UX
2026-06-09 17:08:53 -04:00
Claude 85f9998463 revert: drop ChatroomList.changes flow and the screen's reactive use
Backs out the reactive list-refresh plumbing added in the prior commit:
removes the `changes` SharedFlow from the shared `ChatroomList` (restoring
it to its original form) and reverts Desktop `ChatroomListState` to its
original 2s poll. Room assembly is expected to move to a LocalCache.observe
approach on both platforms later, which would supersede this.

Keeps the independent Desktop list improvements (per-room unread tracking
and the mute/acceptable filter), which don't depend on the flow.

https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
2026-06-09 20:57:45 +00:00
Vitor PamplonaandGitHub ff71bc1401 Merge pull request #3162 from vitorpamplona/claude/trusting-archimedes-hduulo
Replace String.format with toString(16).padStart in test helpers
2026-06-09 16:56:30 -04:00
Claude a6e9439c6d fix: use multiplatform hex padding in room state tests
String.format is JVM-only and broke the iOS Klib compile of commonTest.
Replace "%064x".format(...) with toString(16).padStart(64, '0').
2026-06-09 20:45:17 +00:00
Vitor PamplonaandGitHub 39d36441d4 Merge pull request #3159 from vitorpamplona/claude/brave-lovelace-d1rap2
fix(commons): make commonTest compile for iOS targets
2026-06-09 16:24:54 -04:00
Vitor PamplonaandGitHub 30dcd3aead Merge pull request #3161 from vitorpamplona/claude/friendly-mayer-g3lhj1
Count NIP-18 quote-reposts as boosts of quoted notes
2026-06-09 16:20:52 -04:00
Claude ed37020fbf ci: run shared commonTest on iOS for :commons
The test-quartz-ios job only compiled :commons production code for iOS
(compileKotlinIos*), never the commonTest source set, so a test using a
JVM-only API compiled fine on JVM/Android and shipped green while silently
breaking compileTestKotlinIosSimulatorArm64.

Mirror the quartz step: run :commons:iosSimulatorArm64Test (compiles +
runs the shared commonTest suite on the simulator) and
:commons:compileTestKotlinIosArm64 (device-only compile drift). This keeps
commonTest KMP-clean going forward.
2026-06-09 20:20:29 +00:00
Claude 1fbefb2efe feat(desktop): bring group DM parity with Android
Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.

- commons ChatNewMessageState: actually attach the composed NIP-14 subject
  to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
  UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
  a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
  header and add a rename dialog that broadcasts a subject change to all
  members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
  room (covers kind 14/15 and future variants) and store self-authored
  NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
  (with a slower safety poll), track real per-room unread via a last-seen
  mark, and hide rooms whose latest message isn't acceptable (mute/filter).

https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
2026-06-09 20:16:55 +00:00
Claude 10f369c43c feat(model): count NIP-18 quote-reposts in the repost counter
A kind:1 note carrying a `q` tag is a quote-repost of the quoted note,
but Amethyst's reaction-row repost counter reads `Note.boosts`, which
only collected kind:6/kind:16 reposts. Quote-reposts were treated purely
as inline citations (stripped by `tagsWithoutCitations()`), so they never
appeared in the quoted note's repost count.

LocalCache now adds a `q`-tagged note as a boost of each quoted note when
consuming text notes/comments, and detaches it on deletion. The quoted
note is deliberately kept out of `replyTo` so the quote still renders as a
root post in the home feed (`Note.isNewThread`). The same wiring is added
to DesktopLocalCache for parity, with tests pinning the behavior using the
exact event reported.
2026-06-09 20:15:19 +00:00
davotoula 26fc0ea7bf docs(claude): require a license check when adding any dependency 2026-06-09 22:10:55 +02:00
David KasparandGitHub 445702a34a Merge pull request #3158 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-09 22:02:02 +02:00
davotoula 66c345717c Code review fixes:
- honor cancellation in voice anonymizer
- reuse shared Hann window and drop vestigial progress callback
2026-06-09 22:00:29 +02:00
davotoula 190316eb28 Replace GPLv3 TarsosDSP with in-house pitch shifter 2026-06-09 21:59:50 +02:00
Claude fc4f7a03fc fix(commons): make commonTest compile for iOS targets
The commons commonTest source set is shared across all KMP targets,
including the iosArm64/iosSimulatorArm64 spike, but several tests still
reached for JVM-only APIs that don't resolve on Kotlin/Native:

- JUnit (`org.junit.*`, `junit.framework.TestCase`) → kotlin.test, with
  message arguments moved from first (JUnit) to last (kotlin.test).
- `assertArrayEquals` → `assertContentEquals`.
- `@JvmStatic` on the `android.util.Log` test stub → removed (it only
  affects JVM bytecode; companion calls work without it).
- `seg.javaClass.simpleName` → `seg::class.simpleName!!`.
- A test function name containing `()` (illegal on Native) → renamed.
- `String(CharArray, offset, count)` → `CharArray.concatToString`.

CliffDetectorTest exercises `computeStalledSpeakers`/`defaultCliffBackoffMs`,
which live in the jvmAndroid-only NestViewModel and are invisible to iOS,
so it moves to jvmTest alongside NestViewModelTest.
2026-06-09 19:26:04 +00:00
Crowdin Bot 673a12452d New Crowdin translations by GitHub Action 2026-06-09 19:08:10 +00:00
Vitor PamplonaandGitHub 3dfe418150 Merge pull request #3151 from vitorpamplona/claude/relay-message-pagination-LMqSQ
DM history: per-relay backward paging, live-tail split + prune-aware window realignment
2026-06-09 15:05:58 -04:00
Vitor PamplonaandGitHub 696be6181e Merge pull request #3157 from davotoula/fix/latex-rendering
Inline LaTeX — render wrapped equations, upgrade renderer, baseline alignment
2026-06-09 15:05:31 -04:00
Vitor PamplonaandClaude Opus 4.8 39eb25bc17 fix(commons): show "N relays" on every history marker count chip
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>
2026-06-09 14:17:07 -04:00
David KasparandGitHub 022aec6474 Merge pull request #3156 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-09 20:13:51 +02:00
Vitor PamplonaandClaude Opus 4.8 75315095ee refactor(commons): collapse pager status flows into one PagingStatus snapshot
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>
2026-06-09 14:13:26 -04:00
davotoula c8de54d1c2 fix(math): align inline equations to the text baseline 2026-06-09 19:51:31 +02:00
davotoula 8097e6c52d fix(math): upgrade LaTeX renderer to maintained rikkahub jlatexmath fork 2026-06-09 19:51:31 +02:00
davotoula 711ca547b5 fix(math): render inline equations wrapped in opening punctuation 2026-06-09 19:51:31 +02:00
Vitor Pamplona b21aecdfca Improves markers 2026-06-09 13:47:46 -04:00
Claude d6603baf1a Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-09 12:54:51 +00:00
Crowdin Bot e9e5ce4152 New Crowdin translations by GitHub Action 2026-06-09 11:41:09 +00:00
Vitor PamplonaandGitHub fb31d5440a Merge pull request #3155 from nrobi144/feat/desktop-image-compression
feat(desktop): image compression + preview gate, plus lightbox URL hover/copy
2026-06-09 07:39:21 -04:00
nrobi144 c21317912e fix(commons): make UploadOrchestrator backward-compatible for non-image uploads
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.
2026-06-09 12:25:41 +03:00
nrobi144 194a109a73 feat(desktop): copy Blossom URL on image click + hover tooltip + snackbar
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).
2026-06-09 11:42:01 +03:00
nrobi144 19e5b3f8de fix(desktop): rename preview dialog Cancel -> Back
"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.
2026-06-09 11:42:01 +03:00
nrobi144 40d9fe6d97 fix(commons): two crash bugs in ImageReencoder/CompressionException
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.
2026-06-09 11:42:01 +03:00
nrobi144 150117241b fix(desktop): preview "skip" now means "upload original", not "drop"
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.
2026-06-09 11:42:01 +03:00
nrobi144 b73f7fe4e2 feat(desktop): per-row skip toggle + explicit metadata-strip status in preview
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.
2026-06-09 11:42:01 +03:00
nrobi144 0f5eae4906 feat(desktop): preview-then-publish gate for image uploads
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.
2026-06-09 11:42:01 +03:00
nrobi144 62260e9aed fix(desktop): widen compose dialog + lock selector labels to one line
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.
2026-06-09 11:42:01 +03:00
nrobi144 6d6270a8e8 fix(desktop): unify options-row controls + restore HIGH quality preset
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.
2026-06-09 11:42:00 +03:00
nrobi144 9aad12804a 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.
2026-06-09 11:42:00 +03:00
nrobi144 4c1464bfb3 fix(desktop): route clipboard-paste temp files through AmethystTempDir
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.
2026-06-09 11:42:00 +03:00
nrobi144 0a01790726 feat(desktop): per-post compression quality + batch progress in compose
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.
2026-06-09 11:42:00 +03:00
nrobi144 38eacee53c feat(desktop): add Image Compression settings panel
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.
2026-06-09 11:42:00 +03:00
nrobi144 b6f2a0e48d feat(desktop): add ImageCompressionStore for persisted compression settings
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.
2026-06-09 11:42:00 +03:00
nrobi144 21577d9576 feat(commons): wire ImageReencoder into UploadOrchestrator
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).
2026-06-09 11:42:00 +03:00
nrobi144 e6711845a0 feat(commons): add ImageReencoder + AmethystTempDir for image uploads
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.
2026-06-09 11:41:59 +03:00