When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.
Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).
Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
Phase 3 of relay-latency-health: wire the Phase 1 tracker and Phase 2 store
into the desktop UI across three surfaces. After this commit the feature is
end-to-end usable in the running app.
Wiring (Main.kt):
- Construct a RelayLatencyTracker per account (same lifetime as
RelayHealthStore).
- Install a RelayLatencyListener alongside the existing RelayHealthListener
on relayManager.client; uninstall both on account switch / app exit.
- Pass the tracker to the store via the new latencyTracker constructor
param so sweep + snapshot happen on the existing 60 s reclassify tick.
- nip11Provider: read live from Nip11Fetcher's session cache (new
`allCached()` accessor). The classifier reads it every tick.
- authProvider: hardcoded `{ false }` for desktop — NIP-42 isn't wired in
desktop yet, so any auth-required or payment-required relay is treated
as "auth not complete" and excluded from the slow cohort. Avoids
perpetually flagging paid relays that CLOSED our anonymous queries.
RelayMetricsTab + RelayMetricCard (dashboard):
- Tab collects latencySnapshots + slowRelays ONCE; per-row passes the
per-relay value snapshots (not the whole map). Strong-skipping then
handles the rest — unchanged rows skip on 60 s ticks.
- Each row gains three compact columns: OK / EOSE / FR p50s (in ms).
Missing metrics omit their cell — common in the first ~60 s before the
tracker's first snapshot lands.
- A red "Slow: <metric> 2.4×" AssistChip appears next to the columns
when the classifier flags the relay.
RelayDetailPanel (the per-row NIP-11 popup):
- New "Latency (rolling last 50 samples)" section below the existing
NIP-11 fields, listing each metric's p50, sample count, and cohort
multiplier when the relay is currently flagged on that metric.
- First-result row carries a tooltip explaining filter-dependence so
users don't misread "slow first-result" as pure network slowness.
UnhealthyRelaysPopup:
- Now also collects store.slowRelays and renders a "Slow relays" section
below the existing "Unresponsive relays" list (when slowRelays is
non-empty). Each slow row: relay URL, metric + p50 vs cohort, slow
chip, Dashboard + Snooze actions. Snooze reuses the existing 7-day
snooze field on RelayHealthRecord.
UnhealthyRelayBannerHost:
- Banner now visible when either unhealthy OR slowRelays is non-empty.
- Count text reads "$dead relays unresponsive — Review" /
"$slow slow relays — Review" / "${dead+slow} relays need attention —
Review" depending on which buckets have entries.
Compose stability:
- All public StateFlow types from RelayHealthStore expose ImmutableMap,
and RelayLatencySnapshot is @Immutable with ImmutableMap fields, so
strong-skipping engages.
- Per-row composables (RelayMetricCard, SlowRelayPopupRow) only take
@Immutable value parameters — no maps passed in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of relay-latency-health: hook the Phase 1 tracker into the existing
RelayHealthStore lifecycle and persist its rings via the existing
PreferencesRelayHealthPersistence so samples survive restarts.
commonMain:
- RelayLatencyProvider: small interface so the store can drive a tracker
that lives in jvmAndroidMain (the impl needs ConcurrentHashMap).
- RelayHealthSnapshot: optional `latencySamples` field. Default empty;
older saved snapshots load cleanly without it.
- RelayHealthStore now takes optional `latencyTracker` / `nip11Provider`
/ `authProvider` constructor params:
* exposes `latencySnapshots: StateFlow<ImmutableMap<Url, RelayLatencySnapshot>>`
— MutableStateFlow updated inside the existing 60 s reclassify tick
(one timer, not two — the tracker is scope-less and gets
`sweep(now)` called from reclassify).
* exposes `slowRelays: StateFlow<ImmutableMap<Url, SlowReason>>` —
derived via `_latencySnapshots.map(classifySlowRelays).stateIn(
scope, SharingStarted.Eagerly, persistentMapOf())`. The classifier
reads `nip11Provider()` / `authProvider` live, so paid/auth-only
relays only join the cohort once their auth completes.
* `init {}` restores persisted samples into the tracker; the
existing `schedulePersist()` now bundles `tracker.samplesForPersistence()`
into the saved snapshot via a new private `snapshotForPersist()`
helper. The same helper feeds the final flush in `close()`.
No new dispatcher / scope / timer — everything piggybacks on the
existing infra (single SupervisorJob, 5 s persist debounce, 60 s tick).
jvmAndroidMain:
- RelayLatencyTracker now implements RelayLatencyProvider. Overrides drop
the inline `System.currentTimeMillis()` default; callers from commonMain
pass `TimeUtils.nowMillis()` explicitly.
desktopApp (jvmMain):
- PreferencesRelayHealthPersistence persists per-relay latency rings in
separate keys (`lat_<account-prefix>_<sha256(url)[..16]>`) so the 8 KB
Preferences ceiling on the main `health_<account>` key isn't blown by a
user with many relays. Each key holds one relay's four metric rings as
`wss://relay.url\tok:csv|eose:csv|fr:csv|ping:csv`. On save, keys for
relays no longer in the snapshot get removed so the prefs node doesn't
grow unboundedly across account churn.
Notes:
- Persistence still uses the existing 5 s debounce path. The deepened plan
called for 30 s for `lat_*` keys; deferring that micro-optimization
until we observe write thrash in practice. The cap on writes is
one-rewrite-per-5s-of-activity which matches what the existing snooze
persistence already does, so latency adds zero new flush events.
- Tracker is wired only when a `RelayLatencyProvider` is passed to the
store. Existing tests / Android continue to compile and run with
latency unconfigured — `latencySnapshots` stays empty and `slowRelays`
derives to empty. Desktop wiring lands in Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `LaunchTestOverrides` bundle (default null in production) so
`App()` can be driven from `createComposeRule()` against the in-process
fixture relay instead of the OkHttp + kmp-tor + DesktopHttpClient stack
it normally constructs via `remember { … }`. `DesktopRelayConnectionManager`
gains a secondary constructor taking a `WebsocketBuilder` so the
`LocalRelayManager` composition local (typed as
`DesktopRelayConnectionManager?` and consumed by ~20 screens) does not
have to be relaxed.
`AppStateMachineTest` exercises four scenarios:
1. `appShowsLoginScreenWhenNoSavedAccountExists` — App() with no
`accounts.json.enc` reaches LoggedOut and renders LoginScreen.
2. `appWithViewOnlyAccountReachesLoggedInWithoutCrashing` — App() with
a pre-seeded ViewOnly account reaches LoggedIn end-to-end through
`MainContent`, the deck columns, NWC wiring, etc.
3. `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` —
wires a `NeverConnectsWebsocketBuilder` so no connection ever opens,
yet App() still reaches LoggedIn within 5s instead of the previous
30s gate timeout. Direct regression test for the Phase 5.2
bootstrap-gate removal.
4. `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` — wraps the
fixture builder with a `RecordingWebsocketBuilder` and asserts the
bootstrap REQ does not loop or double-fire.
The `LaunchScenario` benchmark drops its private
`BenchmarkRelayConnectionManager` subclass in favor of the new
secondary `DesktopRelayConnectionManager(WebsocketBuilder)` constructor.
278/278 desktopApp tests pass.
ProGuard in the release DMG (compose-rules.pro) was keeping
pt.davidafsilva.apple.** — a library no longer in the dependency graph.
The actual macOS-keychain dependency is com.github.javakeyring:java-keyring,
which reflection-loads its OS-specific backend (OSXKeychainBackend /
SecretServiceBackend / WinCredentialStoreBackend) at Keyring.create()
time. The shrinker stripped the backend classes, Keyring.create() threw
BackendNotSupportedException on every cold boot, SecureKeyStorage's
fallback silently returned null (no password prompt in a GUI cold-boot),
and every account whose key lived in the OS keychain (nsec, NIP-46
bunker ephemeral, NWC secret) was forced back to the login screen on
each launch of the release DMG. Dev/Gradle runs skip ProGuard, which is
why this never surfaced in development.
Primary fix:
- Replace dead pt.davidafsilva.apple.** keep rules with
com.github.javakeyring.** and keep native methods + constructors on
internal.** backends.
Defense in depth (so a future regression is visible, not silent):
- AccountManager._keychainUnavailable: StateFlow<Boolean> mirrors the
existing _storageCorruption / _forceLogoutReason channels.
- loadInternalAccount / loadBunkerAccount raise the signal when
accounts.json.enc points at a key the keychain cannot return.
- LoginScreen shows a one-line error banner when the signal is set;
cleared on any successful login.
Tests:
- AccountManagerLoadAccountTest gains four cases: Internal-no-privkey
signals, Bunker-no-ephemeral signals, clearKeychainUnavailable
resets, happy path does NOT signal.
See docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md for
brainstorm + plan + deferred follow-ups (Linux/Windows DMG verification,
signed-DMG smoke test, ProGuard mapping regression guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App() only consumes torManager.status, which is on ITorManager.
Loosening the parameter type lets future tests substitute a fake without
having to construct the concrete DesktopTorManager (which eagerly builds
a kmp-tor TorRuntime on first status access). No production behavior
change: DesktopTorManager already implements ITorManager and the existing
call site at Main.kt:633 upcasts naturally.
This is a small intermediate step on the road to the still-pending Phase
1.4 App() Compose smoke test, which is the last item blocked on broader
App() dependency injection (relayManager / localCache / localRelayStore
are still remember'd internally).
Phases 2.1/2.2/2.3, 3.1/3.2, 4, 5.2, and 6 of the launch-optimization plan
land together because they share a single set of seams and a single
benchmark report.
* InProcessWebsocketBuilder + LaunchFixtureRelay wrap quartz's existing
InProcessWebSocket + NostrServer (with EmptyPolicy) so any test can
drive a NostrClient against an in-memory relay seeded with arbitrary
events. Roundtrip verified by LaunchFixtureRelayTest.
* LaunchFixture builds a deterministic 50-note synthetic home-feed
snapshot from a fixed RNG seed (kind:1 + author kind:0 + kind:3 +
kind:10002). A real-world JSONL artifact is a drop-in replacement.
* NoteCard gets a stable testTag + a CompositionLocal-backed
onPlaced hook. Production overhead is one composition-local read
plus one null check per placement (default
LocalNoteCardInstrumentation = null).
* LaunchMarkers records named markers against TimeSource.Monotonic.
LaunchScenario.coldBoot drives the AccountManager (ViewOnly path)
+ DesktopLocalCache + RelayConnectionManager + LocalRelayStore
stack against the fixture relay and reports t_account_logged_in,
t_first_event, t_n_events.
* LaunchBenchmark runs 2 warmup + 5 measured iterations, computes
min/q1/median/q3/max, atomically writes the report file, and is
skipped by default — opt in via AMETHYST_BENCH=true. Baseline +
post-fix snapshots committed under desktopApp/benchmarks/.
* SubscribeBeforeConnectTest proves NostrClient / RelayPool queue REQs
issued before connect() and flush them when the connection comes up.
The bootstrap-config subscription in Main.kt drops its
`connectedRelays.first { isNotEmpty() }` + 30s withTimeoutOrNull gate
on the strength of that invariant — the subscription now fires
eagerly and recovers when no relay ever connects instead of silently
giving up after 30s.
All 274 desktopApp tests pass. No flaky tests introduced.
Phase 5.1 of the launch-optimization plan: the cold-boot critical path
loaded /icon.png up to four separate times (taskbar setup, Window icon,
Tor splash, account-loading splash). Two of those sites also paid an
ImageIO.read to obtain a BufferedImage, and the Window-icon site
additionally round-tripped the image back through ImageIO.write so Skia
could re-decode it.
IconResources holds one lazy each for the bytes, the decoded
BufferedImage, the platform-adapted BufferedImage (squircle on macOS),
and the two BitmapPainters (raw + adapted). All four call sites in
Main.kt now consume the cached values directly — no remember, no
re-decode.
IconResourcesTest pins the memoization invariants (same instance on
repeated access). All 271 desktopApp tests pass.
End-to-end delta vs the baseline will be measured once Phase 3
benchmarks land; the worst-case savings on cold boot are two
ImageIO.read calls plus three resource reads plus one ImageIO.write,
all on the main thread.
Phase 1 of the desktop launch-optimization plan: pin the behavior of
the cold-boot critical path before any launch refactor lands.
* Plan document committed to desktopApp/plans/.
* AccountManager: ViewOnly load + decode-failure state transitions are
pinned by AccountManagerLoadStateTransitionsTest (2 tests).
* LocalRelayStore: gains a homeDir constructor parameter so tests can
point the SQLite event store at a temp directory; production callers
unchanged via default argument.
* LocalRelayStoreHydrationTest pins hydrate's contract:
- empty DB is a no-op,
- kind:3 contact list is consumed before kind:0 metadata,
- kind:1 within the 7-day window is hydrated,
- kind:1 older than 7 days is excluded.
All 266 desktopApp tests pass. No production behavior change.
The macOS icon.icns shipped inconsistent artwork across its embedded
sizes — a transparent full-bleed glyph at 256/512 (shown on the DMG mount
window and Spotlight) but a white-carded glyph at 128 (shown in the Dock).
It was also missing every @2x Retina tier and its 16/32/48 entries decoded
to corrupt noise, a signature of a generic PNG->ICNS converter rather than
iconutil. Regenerate from a single transparent glyph master into a proper
iconset (all standard sizes + @2x) compositing one consistent rounded-card
look at every size, then assemble with iconutil.
The Windows icon.ico held a single 32x32 BMP, so Windows upscaled a blurry
32px everywhere it needed a larger icon. Rebuild as a multi-size .ico
(16/32/48/64/128/256, PNG-encoded) from the same glyph, full-bleed and
transparent per Windows convention (matches the Linux icon.png).
Linux icon.png is unchanged — a single transparent PNG never had the
inconsistency and Linux desktops expect transparent icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
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.