Per Vitor's review of #3221: wake-detection is platform-specific UX, not
NostrClient's job. Quartz already exposes `reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)` which does the full disconnect + connect — the
app layer just needs to call it when it detects a wake.
- Revert the keep-alive heuristic in NostrClient.kt; the loop is back to the
conservative `reconnectIfNeedsTo` path it had before.
- Add `runSleepResumeMonitor` (desktopApp/network/SleepResumeMonitor.kt): a
60s tick that watches for wall-clock overshoot and calls the supplied
`onWake` lambda. No native deps.
- Wire it in `Main.kt` next to the metrics LaunchedEffect: on >5x overshoot
call `relayManager.client.reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)`.
Real OS sleep events (NSWorkspace on macOS, D-Bus PrepareForSleep on Linux,
WM_POWERBROADCAST on Windows) can be layered in later as platform improvements
without touching Quartz again.
Follow-up to #3186, addressing the unresolved review feedback:
- RelayHealthStore.schedulePersist() wrapped the blocking save() in withContext(ioDispatcher)
so prefs.flush() no longer sits on the Compose composition thread on Desktop.
- close() now fires the final save on a detached IO-bound scope instead of blocking
the composition thread for ~50ms during account switch / app exit.
- @Volatile on persistJob/tickJob and a closed-flag guard so the relay-network thread
and composition thread no longer race on plain vars (and post-close work is dropped).
- desktopApp/Main.kt passes Dispatchers.IO to RelayHealthStore so persistence flushes
land on the IO dispatcher instead of Dispatchers.Default.
Plus a separate-but-related fix to the offline-banner-stuck-after-Mac-sleep issue:
NostrClient.keepAliveJob now tracks wall-clock overshoot of its scheduled tick.
If the OS suspended us (laptop lid closed, system sleep), delay() returns far
past its deadline and the OkHttp websockets we held are dead even though
BasicRelayClient.isConnected() still reads true until the next ping fails.
On a >5x interval overshoot, force relayPool.disconnect() + connect() instead
of trusting needsToReconnect(), so feeds resume without an app restart.
Adds the Namecoin diagnostics card that Android renders below the
per-server test results to the desktop settings panel, so support
requests carry the same information on both platforms.
- last test timestamp + pass/fail tally
- host OS (name/version/arch) — desktop equivalent of Android's
Build.MANUFACTURER/MODEL row
- JVM name + version — desktop equivalent of Android's API level row
- distinct TLS versions observed during the test run
Also adds a 'Testing next server...' inline progress row matching the
Android section's behaviour when the test loop is mid-run.
Pure UI addition; no model, preference, or callback changes.
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".
Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
(epoch seconds, 0 = never observed). RelayStats listener pushes them
on onConnected / onIncomingMessage. Durable per-relay history lives
outside quartz in the commons RelayHealthStore.
Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
* first-run grace (don't flag for 7d after firstScanAt)
* offline grace (don't flag if no relay anywhere has responded)
* Tor-mode skip (relay timing is intentionally lossy through Tor)
* per-relay snooze (snoozedUntil > now)
* 10006 (blocked) excluded from detection but still part of the
multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.
Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
listener + per-account scan trigger wired in Main.kt's MainContent.
Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
Redesign the three payment cards rendered in the middle of a post
(Lightning invoice, CLINK Offer, Cashu token) around a shared PaymentCard
scaffold that follows the wallet screens' Material3 idiom: tonal card,
icon + label header with a copy action, centered headline amount, and a
full-width themed Pay/Redeem button (no more hardcoded white text or
7sp mint lines).
Descriptions were not being rendered at all:
- BOLT-11: LnInvoiceUtil only decoded the amount from the HRP. Add
tagged-field parsing (description 'd', expiry 'x', timestamp) with
BOLT-11 spec-vector tests; the invoice card now shows the memo and
flags expired invoices (Pay disabled). Desktop card shows it too.
- Cashu: V3 'memo'/'unit' and V4 'd'/'u' were parsed then dropped.
CashuToken now carries them; the card shows the memo and no longer
mislabels non-sat units (usd/eur cents formatted as decimals).
- CLINK Offers: the card now shows who gets paid (avatar + name from
the pointer's pubkey, tappable to the profile).
https://claude.ai/code/session_019VuZ4y3ij6Ly4VVExE1W1W
A memory audit found the global strong-reference RumorHosts index could
never be kept in sync with LocalCache.notes, which holds WeakReferences:
seven prune paths (pruneExpiredEvents — rumors inherit the seal's
expiration tag — hidden/old-message/replaceable/reaction/hidden-event
prunes, and cleanMemory) plus silent GC eviction dropped rumor notes
without clearing their entries, clear() had no callers (logout, account
removal, memory trim), and orphaned stubs accumulated unbounded.
The stub now lives on the Note (Note.rumorHost): whatever removes or
garbage-collects the note frees the stub, closing every leak path by
construction. Cost is one nullable reference per Note (~200-400 KB at a
50k-note steady state) versus the index's per-entry map overhead plus
unbounded orphan growth. All consumers already held the Note: toNEvent,
Account.broadcast, deleteEnvelopes, removeIfWrap, chat pruning, and the
ingestion pipeline. RumorHosts is deleted.
Also fixes the desktop regression the audit surfaced: the desktop
gift-wrap handler now records the wrap on the rumor note, so desktop
nevent citations of chat messages point at the wrap id again instead of
exposing the private rumor id.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
which only exits the lambda; the collector kept running and each
subsequent playVideo() call accumulated a live collector that would
re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
volume=100/isMuted=false on a new URL, but the kdroidFilter player
retains its `volume` across openUri(); muting one track and starting a
new one left the engine silent while the UI showed unmuted. Reset
`player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
synchronously from the Compose getter; if native init throws (missing
GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
window would crash. Wrapped in runCatching and changed
activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
and GlobalFullscreenOverlay handle the null path by rendering the
thumbnail / blank backdrop respectively; playVideo()/playAudio()
surface "Video playback unavailable" through the existing
errorReason -> PlaybackErrorMessage path.
Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
the same VideoPlayerState while the feed card was already mounting
one, doubling the draw rate against the shared frame bitmap and
widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
path. Mini-preview now renders the cached thumbnail (or the music
icon fallback). 0.10.1 contains an upstream fix
("recover video playback after composition removal") but is not yet
on Maven Central — single-surface mounting is the only mitigation
we can ship today.
VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
Range: header and returned HTTP 200 with the full body, we capped
the copy at MAX_THUMB_BYTES and persisted the truncated file
forever. Subsequent thumbnail attempts hit the broken cache file
and re-failed JCodec/ffmpeg every time. Tag download results with
whether the server actually returned 206; on 200, extract from the
temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
DesktopHttpClient.currentClient() so thumbnail fetches respect the
user's Tor preference (fail-closed when Tor is expected but
bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
pipe: redirectError(DISCARD) so we never wait on stderr; a finally
block destroys the process if anything leaked through the timeout.
CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
build.yml and smoke-test-desktop.yml. They were targeting an
ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
~minutes of CI time per leg and tied the build to videolan.org
reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
the linuxdeploy-vs-appimagetool comment.
.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
reached the commit. Refined the ignore rules to keep stale vlc/ workspace
trees out of git (still ignored) while explicitly tracking the
ffmpeg/README.md drop-in slot under each OS. The READMEs document the
recommended LGPL build source per OS for the bundled-FFmpeg packaging
path.
Verified on macOS arm64:
./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
./gradlew :desktopApp:test BUILD SUCCESSFUL
./gradlew :desktopApp:spotlessApply clean
Refs PR #3175 review by @davotoula.
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
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
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.
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.
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.
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.
Two follow-ups to the reply-context PR.
1) Parent-author metadata wasn't reaching the embed / "Replying to @X"
label, so they rendered the truncated hex indefinitely.
- FeedScreen.missingNoteIds: also fetch the immediate parent EVENT
for visible replies (was only repost originals + bech32 quotes).
- FeedScreen.missingAuthorPubkeys: also include the parent AUTHOR
hex, extracted DIRECTLY from each reply's tags
(CommentEvent.replyAuthor() for NIP-22; taggedUsers().lastOrNull()
for NIP-10) so the kind 0 request fires even before the parent
event itself arrives in cache.
- NoteCard.QuotedNoteEmbed + FeedScreen.rememberReplyContext:
produceState observation of the parent author's
metadata().flow so the embed and label recompose to display name
+ avatar once kind 0 lands.
2) Embedded parent appeared clickable but did nothing — the outer
NoteCard's OutlinedCard onClick was catching the click and
re-navigating to the reply's own thread (the current view). Make
the wrapping Box itself clickable, route it to the parent thread,
and drop the inner OutlinedCard's onClick so there's a single
explicit click surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RichTextParser splits each paragraph on ' ' so every segment is one
space-delimited token; the source space lives BETWEEN segments, not
within them. When a paragraph contains only RegularTextSegments the
parser collapses them back to one segment rejoined with " ". When the
paragraph also contains a mention/hashtag/link the segments stay split
and DesktopRichTextViewer rendered them in a FlowRow with no horizontal
gap — every word glued together.
Set the FlowRow's horizontalArrangement to Arrangement.spacedBy(4.dp)
(the same constant the file already uses for ImageGalleryParagraph),
preserving the RTL alignment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Replies tab predicate used `!note.isNewThread()`, which returns true
whenever Note.replyTo is non-empty. The cache populates replyTo from
event.tagsWithoutCitations(), and that includes unmarked positional
NIP-10 e-tags — which modern clients use for QUOTES and MENTIONS, not
replies. Posts that merely quoted another note were therefore appearing
in the Replies tab.
Tighten the signal: a reply is now either a NIP-22 CommentEvent, or a
NIP-10 TextNoteEvent carrying an explicit `reply`/`root` marker tag
(`markedReply()` / `markedRoot()`). Unmarked e-tags no longer qualify.
Adds 6 regression tests including the unmarked-e-tag false-positive
case the user reported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.
- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
param. Default keeps Notes-tab behavior unchanged; when true, the
predicate becomes `event is TextNoteEvent && !note.isNewThread()`
(excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
new tab at index 1, body branch mirroring the Notes Loading/Empty/
Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.
NIP-22 kind 1111 deferred — most replies today are kind 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an
embedded parent card plus a "Replying to @displayName" label above the
reply body, matching Android's home-feed behavior. Extracts the shared
ReplyToLabel composable + ReplyContext data class to commons so Android
switches over to the shared version.
- commons/.../ui/note/ReplyContext.kt: data class + from(event, cache)
detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism.
- commons/.../ui/note/ReplyToLabel.kt: shared composable.
- commons/strings.xml: new "Notes & Replies" section + replying_to key.
- desktopApp NoteCard: replyContext param + render branch (bordered
QuotedNoteEmbed + ReplyToLabel). Recursion impossible because
QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext.
- desktopApp FeedScreen: rememberReplyContext() observes parent
metadata flow so embed/label pop in once the parent arrives via
relay subscription. Wired into both regular and reposted-inner paths.
- amethyst ReplyInformation.kt: removed local ReplyToLabel definition.
- amethyst Text.kt: calls shared commons ReplyToLabel; resolves author
display name at the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move isRenderableRepost() (and its test) from amethyst ui/dal into
commons/ui/feeds so both platforms use one implementation, then point
desktop's isFeedNote() at it.
Two related polish fixes on the collapsed sidebar:
1. The hover/active highlight on each nav item used to span the full
sidebar width (minus 8dp outer padding), producing ~12dp of empty
highlight either side of the 24dp icon. Now the highlight clips to
a 40dp square centered on the icon (24dp icon + 8dp padding on each
side), so the ripple sits tight against the glyph.
2. When the sidebar is collapsed, the label was already supplied as
`contentDescription` for screen readers but had no visual
affordance. Added a `TooltipArea` that surfaces the label on hover
(Surface + inverseSurface tonal style, matching the existing
TorStatusIndicator tooltip pattern), so mouse users can also see
what each icon means without expanding the sidebar.
Applied to both `SidebarNavItem` and `SidebarFeedItem` since both
suffer the same issue. Expanded behaviour is unchanged.
Two bugs that together caused HomeFeed to always open on Following:
1. FeedScreen was reading feedRepo.pinnedFeeds.value as the source of
truth for the first pinned feed. That's a stateIn-derived flow with
initial value persistentListOf(); the underlying _feeds StateFlow
IS loaded synchronously by FeedDefinitionRepository on construction,
but the derived pinnedFeeds doesn't reflect it until the first flow
emission propagates — which is too late for `remember` to see.
Fixed by reading feedRepo.feeds.value directly and filtering /
sorting by pinOrder ourselves.
2. DeckColumnContainer was passing initialFeedMode = FeedMode.FOLLOWING
when rendering DeckColumnType.HomeFeed, which overrode FeedScreen's
first-pinned logic entirely. Removed the hardcode so the deck's
home column inherits FeedScreen's default.
With both fixed, a user who has only Global pinned now opens to Global
on launch instead of Following.
If the user has pinned only Global (or only a custom feed), the app
should open to that on launch instead of showing Following just
because DesktopPreferences.feedMode happened to be saved as
Following. The "pinned feeds" list is the user's stated ordering;
the first item should drive the initial tab.
Resolution order (most specific wins):
1. explicit customFeedSource/customFeedId from the caller
2. explicit initialFeedMode from the caller
3. first pinned feed in feedRepo.pinnedFeeds (NEW)
4. DesktopPreferences.feedMode (last-saved, previous default)
For a pinned Filter feed, this also seeds activeFeedId and
activeFeedSource so the feed mounts in CUSTOM mode with the right
source.
Real root cause of the "stale feed on launch" perception bug: when
fresh events prepend to the desktop home feed, Compose's stable-key
diff (`items(loadedState.list, key = { it.idHex })`) preserves the
visual anchor on whatever item was already visible. The user's
previously-visible top item — once at index 0 — silently shifts to
index N as N new items are inserted above the viewport. From the
user's perspective the feed looks frozen on stale items even though
the underlying state HAS updated; switching screens unmounts
FeedScreen, recreates lazyListState at index 0, and on remount paints
from the now-current top.
Android already handles this with StickToTopOnPrepend
(amethyst/.../WatchScrollToTop.kt:133-152), but the helper lived in
the Android module and Desktop had no equivalent.
Changes:
- New commons/.../ui/feeds/StickToTopOnPrepend.kt with the same
observer + snapshotFlow trick, ported to use plain `collectAsState`
(replacing the Android-only `collectAsStateWithLifecycle` — the
effect's lifecycle is already bound to composition via
LaunchedEffect). Provides the same overloads:
* StickToTopOnPrepend(LazyListState, firstItemKey)
* StickToTopOnPrepend(LazyGridState, firstItemKey)
* StickToTopOnPrepend(FeedContentState, LazyListState)
* StickToTopOnPrepend(FeedContentState, LazyGridState)
- FeedScreen wires StickToTopOnPrepend(viewModel.feedState,
homeFeedLazyListState) at the same scope as the hoisted lazy list
state and the NewPostsChip.
Mutually exclusive with the NewPostsChip: the chip's visibility
predicate fires when isAtTop is false, the auto-snap fires when
isAtTop is true. Together they cover both cases:
* user at top → events arrive → auto-snap shows them
* user scrolled down → events arrive → chip announces them
The Android version in amethyst/.../WatchScrollToTop.kt is left in
place to avoid a wider refactor; it can be reduced to a thin delegate
in a follow-up.
Both loading splashes (the Tor-connect gate and the account-loading
screen between Tor active and LoginScreen) now show the Amethyst
icon tinted to the theme primary, anchored below the status text.
Layout pattern (status-forward, both splashes):
spinner → status text → Amethyst logo (96.dp, primary tint)
Brief research summary backing the choice:
- Apple HIG argues against splash branding, but its model assumes
near-instant launch — not applicable here where the Tor gate
can block for seconds.
- Material Design 2's branded-launch-screen pattern endorses
logo + brand color while a placeholder UI loads.
- The status-forward order keeps the dynamic info (what we're
waiting on) leading and the brand as the anchor below — the
right call when the wait is non-trivial.
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
5 issues from davotoula's review on PR #3124:
- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
`TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
`notify(...)`. Replies to deep-thread notes now thread correctly in
Damus/Primal/Coracle. Covered by `ReplyActionsTest`.
- #4 (architecture): reaction/follow/reply each inlined
`localCache.consume + relayManager.broadcastToAll` in 5 sites with
inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
canonical local-first order — and route all 5 sites through it.
- #1 (UX): related-content section scanned the cache once via
`DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
when an arriving bundle contains a candidate (matching hashtag or
author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
iterator) so the scan stays safe on the composition coroutine.
- #2 (UX): `DeckColumnContainer` re-requested focus on every
`currentOverlay` change, stealing focus from sibling columns whenever
any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
effect survives column reordering.
- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
`RelatedContentRow`, `CommentItem`, and `NoteActions` to
`commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
`formatZapAmount` and `formatSats` desktop-local helpers.
`WalletColumnScreen.formatSats` intentionally kept — locale-aware full
precision for wallet balance is by design.
Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
manual transaction — UNIQUE constraint violations skip that row instead
of failing the whole batch
2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
fallback all-zeros hex key instead of passing empty string to assembler
3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
gift wraps from local relay cache aren't addressed to current user
(subscription filter is correct, but hydration doesn't filter by p-tag)
4. Relay URL %20: decode percent-encoded spaces before rejection check in
RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
to wss://relay.example.com/ instead of being rejected
5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
from relay content is expected in the wild, catch+log is correct
6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
on startup instead of logging hundreds of stale-cache errors)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix like: read replyNote.event inside lambda (not captured val)
to avoid stale null reference. Consume reaction into local cache.
- Wire zap on comments: uses zapNote (now internal) with 21 sats default
via NWC connection, same flow as main action row
- Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire onLike on CommentItem: ReactionAction.reactTo + broadcast
- Related content clicks use overlay navigation (ThreadScreen) since
related notes may not be in the feed LazyColumn
- Add onNavigateToThreadOverlay param to ExpandedNoteContent
- Zap from comments deferred (requires full NWC flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Observe note.flow().replies so replyNotes recomputes when replies arrive
- Use loadMetadataBatched with explicit author pubkeys from reply events
- DisposableEffect for proper flow cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>