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
The commons commonTest source set is shared across all KMP targets,
including the iosArm64/iosSimulatorArm64 spike, but several tests still
reached for JVM-only APIs that don't resolve on Kotlin/Native:
- JUnit (`org.junit.*`, `junit.framework.TestCase`) → kotlin.test, with
message arguments moved from first (JUnit) to last (kotlin.test).
- `assertArrayEquals` → `assertContentEquals`.
- `@JvmStatic` on the `android.util.Log` test stub → removed (it only
affects JVM bytecode; companion calls work without it).
- `seg.javaClass.simpleName` → `seg::class.simpleName!!`.
- A test function name containing `()` (illegal on Native) → renamed.
- `String(CharArray, offset, count)` → `CharArray.concatToString`.
CliffDetectorTest exercises `computeStalledSpeakers`/`defaultCliffBackoffMs`,
which live in the jvmAndroid-only NestViewModel and are invisible to iOS,
so it moves to jvmTest alongside NestViewModelTest.
The in-stream loading marker spelled out "N relays" only for the fully-loaded
(done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the
relays plural for the count fallback on every state so a count chip always reads
as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).
Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.
Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:
- uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
- uploadPassesAuthHeaderToClient (.txt)
- uploadPassesSameFileWhenNoStripExif (.txt)
- uploadComputesMetadata (.txt)
The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).
Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:
1. UploadOrchestrator.upload's quality parameter is now nullable
(CompressionQuality? = null). Null means "do not reencode" —
matches the orchestrator's behavior before this feature, so the
Android, CLI, and any non-image upload path keeps working
unchanged. The desktop compose flow continues to pass a non-null
CompressionQuality so it still runs the reencoder.
2. ImageReencoder no longer throws UnsupportedFormat for
ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
AVIF and HEIC still throw (those are recognized formats we
explicitly refuse). The new PassReason.NotAnImage is rendered in
the preview dialog as "Not an image · uploaded as-is" / "Metadata
preserved (non-image — no re-encode applies)".
My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
Reported via runtime crash dialog on the user's first PNG upload:
Exception in thread "AWT-EventQueue-0":
java.lang.IllegalStateException: Can't overwrite cause with
javax.imageio.IIOException: Bogus input colorspace
at java.lang.Throwable.initCause(Throwable.java:464)
at CompressionException.<init>(CompressionException.kt:39)
Two real bugs:
1. CompressionException constructor double-set the cause.
Exception(message, cause) super already wires the Throwable's
cause slot; the init block then called initCause(cause) AGAIN
which throws IllegalStateException by spec ("Can't overwrite
cause"). The init block was added per a code-review note that
was wrong about how Kotlin's primary constructor forwards
cause. Removed the init block; relying on super does the
right thing. Regression: encodeFailedWrapsCauseWithoutCrashing.
2. JPEG writer rejected non-RGB BufferedImages with "Bogus input
colorspace". TYPE_INT_ARGB (typical PNG decode), TYPE_BYTE_GRAY,
TYPE_CUSTOM (CMYK JPEGs, indexed PNGs) all blow up the stock
JPEGImageWriter. encodeJpeg now flattens via toRgbCanvas — draws
onto a fresh TYPE_INT_RGB canvas with white background for any
transparent pixels. White matches what every major image viewer
does for transparent PNGs over a light surface.
Regression: reencodesPngWithAlphaToJpeg.
Both regressions are covered by new tests so the patterns cannot
silently come back. Reencoder test count: 13 -> 15.
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 1 part C — the integration that makes the new compression
pipeline actually run on every upload.
- UploadOrchestrator.upload gains an optional quality parameter
(default CompressionQuality.DESKTOP_HIGH). The orchestrator now:
1. Runs ImageReencoder.reencode — branches on Reencoded vs
PassThrough.
2. For PassThrough + stripExif=true + JPEG source, runs
MediaCompressor.stripExif so animated/SVG passes still get
EXIF stripped where applicable.
3. Computes metadata on the bytes that will actually leave
the machine.
4. Eager-cleans up every intermediate in a finally{} block
wrapped in NonCancellable so user-cancelled uploads don't
leak temps.
Throws CompressionException for the fail-loud dialog path.
- MediaCompressor.stripExif drops deleteOnExit (long-running
desktop process was leaking stripped_*.jpg per
docs/temp-file-cleanup-analysis.md). Temp files now land under
AmethystTempDir with the amethyst_stripped_ prefix; caller owns
cleanup.
- AmethystTempDir lazy-initializer now triggers sweepOrphans() on
first access, recovering any amethyst_* files > 24h old left
behind by JVM crashes that skipped shutdown hooks.
- BlossomClient: open class + open upload methods so
UploadOrchestratorTest can substitute a FakeBlossomClient that
captures the uploaded file's bytes.
- jvmTest now pulls secp256k1.kmp.jni.jvm so end-to-end signer-
based tests (Blossom auth event signing) can run.
5 new orchestrator tests cover: JPEG re-encode path (uploaded file
is the AmethystTempDir temp, smaller than source, hash matches
captured bytes), animated GIF pass-through (uploaded == original),
AVIF refused with UnsupportedFormat (client.upload never called),
temp cleanup on success, and temp cleanup on upload failure.
Test totals across commons jvmTest upload package: 44 / 44 green
(4 smoke + 23 sniffer + 12 reencoder + 5 orchestrator).
Phase 1 part B. The core re-encode + downscale pipeline:
- AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
boot-time sweep of amethyst_* files > 24h. Defends against
/tmp tmpfs OOM on Linux VMs and against multi-user temp races
on shared systems. Overridable via -Damethyst.tmp.dir=.
- ImageReencoder.reencode(File, CompressionQuality) returns a
sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
and throws CompressionException for fatal cases.
* Format sniffer first → pass-through for animated GIF /
animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
* Pre-decode pixel guard: stream header dims via
ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
any pixel buffer is allocated.
* Subsampled decode (floor stride) so a 4032×3024 source decodes
to ~2016×1512 in heap before Thumbnailator's final resize.
* Never upscale: Thumbnails.of(...).size() is only called when
the source actually exceeds the target box.
* CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
with ensureActive() between stages.
* Cleanup on cancellation/failure runs in NonCancellable.
12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.
ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
Phase 1 part A of desktop image compression. Pure data types — no
external dependencies yet beyond Thumbnailator already on classpath.
- CompressionQuality enum (Low / Medium / Desktop High) with
JPEG quality values tuned for 2026 displays (0.65 / 0.75 / 0.90)
rather than the obsolete 2014-era Android values.
- ImageFormat sealed class covering the 9 formats the orchestrator
must distinguish (JPEG/PNG/BMP/TIFF re-encoded, animated GIF and
animated WebP byte-identical pass-through, SVG pass-through,
AVIF and HEIC refused for lack of a pure-Java decoder).
- ImageFormatSniffer with magic-byte detection + RFC 9649 VP8X
animation-flag check + GIF NETSCAPE2.0 application-extension
scan + ISO BMFF ftyp brand match for AVIF / HEIC variants.
- CompressionException sealed hierarchy (UnsupportedFormat /
InputTooLarge / EncodeFailed) with initCause for chain
preservation through logging.
23 sniffer unit tests cover each format including JPEG-via-file,
empty input, missing file, and both WebP animation paths.
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Some sources (e.g. the math-academy posts) over-escape their LaTeX, so
the content carries `\\ldots` / `\\cdots` instead of `\ldots` / `\cdots`.
JLaTeXMath reads the `\\` as a forced TeX line break — splitting every
inline equation across two lines — and renders the trailing command name
as the literal letters "ldots"/"cdots", so the ellipsis symbol is lost.
Collapse doubled backslashes (`\\cmd` -> `\cmd`) before rendering inline
math; display math is left untouched since `\\` can be a genuine line
break there. No-op when the content isn't over-escaped.
The "where the live tail ends and paged history begins" boundary (one week)
was copy-pasted across the gift-wrap + NIP-04 live tails, the backward history
pager floor, and the prune's recent/old split — easy to drift out of lockstep
(overlap = double-load, gap = missed messages).
- Add DmHistoryTuning: one place for liveTailSeconds + recentKeepCount, read by
AccountGiftWrapsEoseManager, both NIP-04 SubAssemblers, BackwardRelayPager,
and Chatroom.pruneMessagesToTheLatestOnly. Drops the duplicated
LIVE_TAIL_SECONDS / DEFAULT_LIVE_TAIL_SECONDS constants.
- Log the prune-time window realignment under DMPagination, per scope
([giftwrap] / [rooms.nip04] / [convo.nip04]): relay count + newest pruned
timestamp, so the rewind is observable in logcat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pins that a MathSegment covers only its $-delimited span (not the
paragraph), that space-separated hashtags, URLs and images next to math
stay independently detected, that `$x$.` keeps its trailing period while
a following hashtag remains its own segment, and that currency `$5`
doesn't pair with a later equation. Also documents the glued (no-space)
edge cases.
Memory pruning drops DM messages out of the cache but left the per-relay
paging cursors untouched, so a relay still claimed to have delivered the
dropped band (reachedUntil deep, or done) and the demand-driven loader
never re-requested it — a silent hole until app restart.
- Prune NIP-17 too: pruneMessagesToTheLatestOnly now reaps both NIP-04
(PrivateDmEvent) and NIP-17 (WrappedEvent rumors) on one merged top-N
cut, so a conversation is cut at a single time point (no NIP-04-without
-NIP-17 holes). NIP-17 is the actual memory-pressure driver.
- HostStub carries the host's createdAt, so a decrypted rumor self-
describes its outer gift-wrap time (the time the cursor pages by; the
rumor's own time is the message time, not the wrap time).
- RelayLoadingCursors.rewindTo() pulls a relay's reached cursor up past
the pruned band, clears done, and un-arms it (demand-driven re-fetch);
advance() now resumes from the rewound reached point instead of the
floor.
- LocalCache.pruneOldMessages accumulates the newest pruned created_at
per relay (outer-wrap time for gift wraps, event time for NIP-04),
filtered below each cursor's floor, then rewinds giftWrapHistory +
rooms-list nip04History (account-wide) and the per-conversation
nip04History.
The gift-wrap window is account-global, so pruning one room rewinds the
shared sweep; the interference is bounded (already-held wraps short-
circuit in consumeRegularEvent, re-fetch is demand-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- RadialRenderer: guard minDim <= 0 so a transient 0-size canvas never builds Brush.radialGradient(radius = 0f), which throws.
- AuroraRenderer: clamp y with coerceIn(half, maxOf(half, h - half))
- PcmTapRegistry: never evict a flow that a composable is still collecting (subscriptionCount > 0)
- Aurora/Waves: build the palette-only gradient brushes once via remember(palette)
- audioVisualizerHeight: clamp the fallback strip to min(fallback, maxHeight)
- normalizeToPeakInPlace gains a fromIndex param
- Remove unused silentSpectrum()
feat(audio): wire audio-visualizer settings into navigation and menu
feat(audio): add audio-visualizer settings screen with live previews
feat(audio): add audio-visualizer settings strings
fix(audio): move visualizer setting to Account section; render at feed size
feat(audio): add Classic (default) and Static visualiser styles
feat(audio): show selected live visualiser for audio notes
feat(audio): add change/read accessors for audio-visualizer preference
feat(audio): expose synced audio-visualizer preference flow
feat(audio): add media prefs to synced-settings internal model
fix(audio): thread-safe tap registry, reset spectrum on track reuse
feat(audio): tap decoded PCM via TeeAudioProcessor in pooled players
test(audio): unit-test PCM sink with synthetic sine waves
feat(audio): add PCM-tap registry and FFT audio-buffer sink
fix(audio): continuous viz clock, safe peak-normalize, OFF layout, palette guards
feat(audio): add AudioVisualizer dispatcher composable
feat(audio): add renderer interface, canvas scaffold, registry, and all five styles
feat(audio): add deterministic synthetic spectrum for previews
feat(audio): add VisualizerStyle enum and palette
refactor(audio): drop Visualizer FFT helper, add peak normalization
feat(audio): add Hann windowing + PCM-to-float conversion
feat(audio): add pure-Kotlin radix-2 FFT for the visualiser
The first cut special-cased the whole per-line loop with an
`if (mightContainMath)` branch that duplicated the word-split +
wordIdentifier logic and filtered empty words inconsistently with the
non-math path (which preserves them to keep double-spaces).
Reframe MathParser.split as a drop-in replacement for `line.split(' ')`
that returns typed Word|Math tokens, keeping math spans whole instead of
tearing them at internal spaces. For a math-free line it yields exactly
the same words (empties included), so RichTextParser collapses to a
single uniform map with an exhaustive when — no branch, no duplication.
Also fixes end-of-sentence math: a span glued to trailing punctuation
(`$x$.`) now carries that punctuation as a `trailing` field rendered
adjacent to the equation, mirroring HashTagSegment's extras, instead of
being dropped to a plain word.
https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
Posts that use the common dollar-delimiter convention (e.g. the
math-academy "Linear Independence" note) now render their formulas
as real equations instead of raw LaTeX text.
- commons: new MathParser tokenizes a line into atomic math spans
(kept whole, since they contain spaces) interleaved with plain text,
following the pandoc/remark-math dollar rules so currency like
"$5 and $10" and escaped "\$" don't false-fire. New MathSegment
carries the inner LaTeX + display flag through the rich-text pipeline.
- RichTextParser splits math out before the whitespace word-splitter
when a line might contain math; non-math lines keep the existing path.
- amethyst: LatexEquation renders a MathSegment via JLaTeXMath, tinted
to the current text color and sized to the font, with a raw-text
fallback when the formula fails to parse. Wired into both the
preview and no-preview render paths of RichTextViewer.
Scope: dollar delimiters only, regular (non-markdown) render path.
https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
The single-active BackwardRelayPager applies forwarded relay callbacks to
whichever scope is currently bound. Its doc already states this is "safe as long
as the caller only advances the bound scope", but a subscription for a
*just-backgrounded* scope (conversation navigation overlap, account switch, a
second pane) can still deliver a late onEvent/onEose/onClosed — which would move
the newly-bound scope's cursors instead. Now that those cursors persist on the
Chatroom/ChatroomList model, that corruption would stick.
Add BackwardRelayPager.isBoundTo(cursors) (cursor identity == scope identity) and
gate each manager's forwarded callbacks on it, so a stray callback from a
non-bound scope is dropped, not mis-applied. The framework's own newEose
bookkeeping still runs. No-op on the happy single-scope path.
PerRelayLoadTracker silenced (→ stalled) any in-flight relay after 15 s of total
cohort dead air. Over Tor, REQs queue on a not-yet-connected socket and circuits
routinely take 20–80 s to come up, so relays — including the user's primary —
were being flagged "stalled" before they ever connected (visible in the Messages
trace: vitor's history REQ went out at +0 s, was silenced at +15 s, and only
actually hit the wire at +77 s). Bump the window to 60 s. lastActivityMs is
global, so any relay delivering keeps it fresh for the whole cohort — this only
fires on total dead air, and a genuinely dead relay still settles via CLOSED /
cannot-connect, not this watchdog.
Two reported chatroom-screen issues.
Bug 1 — the NIP-04 card's `⋯` paused state showed a bare protocol tag with no
relay count, even though tapping it listed 5 relays. historySubtitle only
counted relays that were *in-flight* (relayCount) or *stalled*; a relay that
returned a page and parked (the paused state) is neither, so it fell through to
the bare tag. The card now derives a "reaching" count from relayProgress (not
done && not stalled) — covering both fetching and parked relays — so the
subtitle reads "N relays · back to <date>", matching the popup.
Bug 2 — the in-stream "Relay sync: ✓ 5" divider was a non-interactive dead end
and didn't say which protocol it meant (it mixes NIP-17 + NIP-04). Give each
RelayReachCursor a protocol tag, make RelayReachMarkers tap-through (optional
onShowDetail callback), and add RelayReachDetailDialog listing the relays at
that point in the stream with protocol · state glyph · reach-back date. The
conversation view hoists the dialog state and wires the tap; the marker stays a
passive divider wherever onShowDetail isn't supplied (rooms list unchanged).
Compiles: commons (JVM) + amethyst. iOS not buildable in this sandbox (toolchain
download blocked) but avoids the destructuring-in-composable pattern the file
guards against.
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>
Per commons/ARCHITECTURE.md, quartz is protocol/NIPs/crypto/relay framing while
commons owns the relay-subscription client and StateFlow state holders. The
paging *orchestrators* are exactly that — StateFlow-backed, subscription-loading
state — so they belong in commons, not quartz:
- BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker (+ trackingListener)
-> commons/relayClient/paging (jvmAndroid source set, same as before).
- BackwardRelayPagerTest -> commons jvmTest.
The pure protocol-paging primitives stay in quartz commonMain:
- RelayLoadingCursors (the until+limit cursor mechanics) and RelayPagingProgress.
They had no upward deps, so the move is downhill (commons -> quartz): the
orchestrators now import RelayLoadingCursors / RelayPagingProgress from quartz.
Consumers (the six DM managers/assemblers + WindowLoadTrackerIdleTest) repoint
their imports to the commons package. The quartz geode wire test keeps testing
the relay contract; its lone BackwardRelayPager KDoc link is demoted to a
backtick (no longer reachable from quartz).
No behaviour change. DM suite green (26/26); quartz + commons compile on iOS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the pager was split into the orchestrator (BackwardRelayPager) and the
pure per-relay cursor state that lives on the model, "UntilLimitPager" no longer
described the latter — it pages nothing, it just records how far each relay has
loaded. Rename it (and its test) to RelayLoadingCursors.
The geode wire-contract test keeps its name (UntilLimitPagingRelayTest): it
pins the relay-side `until`+`limit` paging behaviour, not the class.
Pure rename — no behaviour change. Design doc updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DM history widgets extracted into commons were never compiled for the
commons iOS target, which hid two Kotlin/Native-only breaks:
- RelayReachMarker: `toSortedMap(compareBy { it.ordinal })` + a destructured
`(state, list)` Map.Entry inside an inline @Composable lambda don't type-infer
on Native. Rewrite as `.entries.sortedBy { it.key.ordinal }` with explicit
`entry.key` / `entry.value`.
- DmHistoryLoadingCard referenced RelayPagingProgress, which sat in quartz's
jvmAndroid source set — visible to commonMain only when building JVM/Android,
not iOS. It's a pure data class, so move it to quartz commonMain.
commons:compileKotlinIosArm64 now succeeds; JVM/Android unaffected and the DM
test suite is still green (26/26).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The history pagers were keyed by account / (account, conversation) inside the
quartz engine, with all per-key state in inner hashmaps + an activeKey +
activate() machinery. But the loaders are per-account-VM and the on-screen
scope is single-active, so the key was redundant indirection.
Move the per-relay cursor *state* onto the domain object whose lifetime it
should share:
- UntilLimitPager is now keyless (per-relay cursors + a pinned floor only) and
lives in commonMain (LargeCache<NormalizedRelayUrl, RelayCursor> — keyed only
by relay url, which is Comparable + equals-consistent, so the sorted cache is
safe; kotlin.concurrent.Volatile for the fields). It is stored on:
* Chatroom.nip04History (per conversation)
* ChatroomList.giftWrapHistory (account NIP-17)
* ChatroomList.nip04History (account rooms-list NIP-04)
The LocalCache object graph is now the partition; cursors are dropped exactly
when the cached messages they describe are pruned, and survive an account
switch (no re-page on switch-back).
- BackwardRelayPager is now a keyless single-active orchestrator: it owns only
the transient bits (in-flight tracker, stalled set, display flows) and binds
to the active scope's cursors via bind(cursors, scope, relaysFor). Removed
activeKey / activate() / the per-key exhausted+floor+stalled maps. Safe as
single-active because history relays only arm while their markers are
on-screen, so a backgrounded scope emits no callbacks.
The three assemblers resolve the scope's cursors from the account's
chatroomList and bind on newSub; the redundant `user` arg dropped from the
account-level advance/advanceAll (callers updated).
Behaviour change: switching between two conversations no longer keeps both
rooms' cursors live in one engine — each room's cursors persist on its own
Chatroom instead, so reopening a room restores its progress (strictly better).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two naming families described the same concept — a relay's position in its
backward history walk — and the UI name overloaded the heavily-used "window"
and REQ "limit" terms. Collapse onto one vocabulary ("Reach"):
- RelayWindowLimit -> RelayReachCursor
- RelayWindowLimitMarkers -> RelayReachMarkers
- RelayWindowLimitSentinels -> RelayReachSentinels
And rename the NIP-04 per-relay routing map so it reads as a map, not a list:
- Nip04DmRelays (class) / nip04DMRelays (factory) -> Nip04DmRelayRouting / nip04DmRelayRouting
Pure rename — no behavior change. Design doc updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackwardRelayPager.floorFor was public but only ever called inside the
pager (and its same-module test) — narrow to internal.
- RelayReachMarker composable was public but only rendered by
RelayWindowLimitMarkers in the same file; the public entry points are
RelayWindowLimitMarkers/Sentinels — make it private.
Co-Authored-By: Claude Opus 4.8 (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.
Audit follow-up.
- Remove the 12 history-card strings (chats_history_older / all_caught_up /
reached_start / relay_sync / subtitle{,_no_date} / waiting / relays_title /
relay_back / incomplete{,_sub} + the chats_history_relays plural) from
amethyst's default strings.xml: they moved to commons composeResources with
the card and have zero remaining amethyst references. They were branch-new and
not yet translated, so removing the default key is a clean, orphan-free delete.
Kept chats_history_proto_* (still the card's protocolName) and chats_reply_*.
- Extract the "does this relay's reached cursor fall in this gap" check, which
was duplicated (with off-by-one-prone >/<= boundaries) between marker placement
(RelayWindowLimitMarkers) and the paging driver (RelayWindowLimitSentinels),
into a single pure reachedFallsInGap(); both now call it so they can't disagree
about which gap a cursor lives in. Add RelayReachMarkerTest pinning every
boundary (newer strictly >, older inclusive <=, null ends).
- Fix a garbled comment in BackwardRelayPager.onSilenced.
Phase 2 (UI), stage 2. Move DmHistoryLoadingCard + its tap-through per-relay
dialog + the historySubtitle/incompleteSubtitle helpers out of amethyst into
commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds), so the same
"older history / all caught up / some relays didn't respond" boundary card can
back any per-relay BackwardRelayPager feed on Android and Desktop.
The card's localized date formatting (SimpleDateFormat/Locale) can't live in
commons commonMain (it targets iOS/linux/macOS, no java.*), so it's injected as
a formatReachDate: (epochSeconds) -> String lambda — the platform that renders
the card supplies its native formatter, no i18n regression. Android passes
formatHistoryReachDate (new HistoryDateFormat.kt). The dialog's per-relay reach
now uses that same month-precision formatter (was "MMM d, yyyy", now "MMM yyyy")
— a negligible cosmetic change.
Its ~11 strings move to commons composeResources, including the module's first
<plurals> (chats_history_relays). The three Android call sites (ChatroomView,
ChatroomListFeedView, LoadingReplyNote) now import the shared card/helpers and
pass the formatter; no behaviour change. The old amethyst string copies are left
in place (harmless, separate resource namespace) for a later cleanup pass.
Phase 2 (UI), stage 1. Move the per-relay reach markers — RelayReachState,
RelayReach, RelayWindowLimit, RelayWindowLimitSentinels (the hoisted,
visibility-driven paging driver), RelayWindowLimitMarkers, and RelayReachMarker
— out of amethyst into commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds)
so the Desktop chats UI (and any feed) can render the same in-stream paging
progress, not just Android.
De-Android-ified: the one Android string (chats_history_relay_sync) becomes a
CMP composeResources string in commons; the two theme constants (DividerThickness,
HalfPadding) are inlined (0.25.dp / padding(5.dp)) so the shared component carries
no app-theme dependency. Logic is otherwise byte-identical. The Android
conversation + rooms-list views now import the shared version; no behaviour change.
Three names didn't describe their behavior:
- removeFromCache → unlinkAndRemove: the method's main job is unlinking the
note from every referrer (parents, channels, the report/card/status/poll
indexes), not just evicting it from the map; the old name only captured
the last step.
- removeAllChildNotes → clearChildLinks: it clears only THIS note's forward
child collections and returns them — it does not touch the children's
replyTo and does not remove anything from the cache. The old name sounded
more aggressive than detachFromChildren(), which is actually the
both-directions op.
- Note.removeOnchainZap(source) → removeOnchainZapBySource(source): too easy
to confuse with removeOnchainZapForSource(txid, pubkey), which is the
verification-verdict removal with anti-spoof guards. The new name matches
its inner helper (innerRemoveOnchainZapBySource) and disambiguates the two.
Pure rename: no behavior change. Test names/comments updated to match.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s