Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets,
so a wallet or payment-service relay fell through to newRelaysViaTor and
got forced over Tor regardless of the "Money operations via Tor" toggle
(which previously governed only HTTP clients). On services that block Tor
exits this silently broke NIP-47 and CLINK payments.
Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay
bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after
the onion reachability check). TorRelayState gains a persistent money-op
relay set — fed across all accounts from NIP-47 wallet relays and saved
CLINK debit relays via AccountsTorStateConnector — plus a reference-counted
ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The
websocket builder resolves the per-relay decision from live source values
so ad-hoc registration takes effect on the next connect with no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the MLS/Marmot message composer to parity with NIP-17 DMs:
typing @ shows the shared user-suggestion dropdown (local cache +
NIP-05 resolution), selecting a user inserts @npub…, and on send
NewMessageTagger rewrites mentions into nostr: URIs and collects
the referenced users as p-tags on the inner kind:9 rumor. Mentions
stay inside the MLS ciphertext; the outer kind:445 is unchanged.
Also applies MentionPreservingInputTransformation and
UrlUserTagOutputTransformation to the field so mentions render
highlighted while composing, matching the DM editor.
https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
data under offer{id,fields}, payer_data is a string list (not a map), and the
response uses details + field (was offer/offers). Documented the single-object
details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
results) so profile visits / kind-0 refreshes don't refetch nostr.json.
Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.
Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
RichTextParser.fixMissingSpaces used a Regex of the form
`([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to
neighbouring text. Kotlin/Native's regex engine fails to backtrack the
optional `([^ \n])?` capture groups to zero width, so on iOS every URL was
corrupted (e.g. "https://x" became "h https://x"). That broke the
downstream segmenter, which is why :commons:iosSimulatorArm64Test reported
19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary
finally linked.
Replace the regex with a direct left-to-right scan that inserts a single
space wherever a detected URL touches a non-space/non-newline neighbour. The
scan is engine-independent, so it behaves identically on JVM and Native.
Verified equivalent to the old behaviour across the full commons richtext
JVM corpus, and the new FixMissingSpacesTest pins the cases on every target
(including iosSimulatorArm64).
Adds the verifiable core for using a CLINK debit pointer as a spend rail
alongside NWC:
- ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only
counterpart of NwcWalletEntry (no secret, no balance/history)
- PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and
CLINK debits into one list with a single default id spanning both types;
no explicit default falls back to first (NWC before debits), preserving
today's behavior. canShowBalance marks NWC vs debit honestly.
- ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits
the preimage via a one-shot subscription, mirroring ClinkOfferPayer.
Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl.
cross-type default + stale-id fallback); amethyst compiles. Persisting the
new fields in AccountSettings and the Wallet-screen rows/confirm dialog are
the next (compile-only) step.
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).
Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
Teaches the commons RichTextParser to recognize an inline noffer1...
token and emit a ClinkOfferSegment carrying the decoded NOffer, so a
GUI front end can render a 'Pay' card in the note body (the feed-offer
feature). Bare tokens only for now; nostr:/lightning: prefixed forms
fall through. Covered by ClinkOfferSegmentTest on JVM.
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
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>