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.
Audio files have no video dimensions, so the player collapsed to a thin
strip and the playback controls were cramped. Size the inline-feed player
square (full width, height capped at 400dp) for every audio style except
voice notes, which keep their seek-bar strip. Full-screen still fills the
screen.
- AudioPlayerSquare: pure shouldSquareAudioPlayer decision + audioSquareSide
math + a Modifier.audioSquare layout modifier (guards zero-width/unbounded
first-layout passes), with unit tests.
- Detect audio via imeta mimeType (up front, no resize) plus a shared
rememberIsAudioTrack composable (runtime fallback for bare-URL audio),
extracted from AudioPlayingAnimation so the visualizer and the container
share one signal.
- CLASSIC keeps its compact 48dp wave centered inside the square; the
spectrum styles fill it via the existing audioVisualizerHeight policy.
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
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
- Make birdex_species_preview_more a <plurals> keyed on the remaining count
- Bound the species preview with maxLines=2
- Hoist the joined-names remember out of the conditional (stable slot).
- Drop the unused accountViewModel parameter
- BirdexEvent.speciesCount() derives from speciesNames().size instead of re-scanning tags
- remember() the joined species-name string so it is not rebuilt on every recomposition.
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 correctness bugs in `RemoteSignerManager` (NIP-46) and its NIP-55
sibling `IntentRequestManager`:
1. **Double-resume crash** — `awaitingRequests.get(id)?.resume(value)`
was non-atomic. Multi-relay delivery, bunker echo/retry, and
late-after-timeout responses could call `resume` twice for the same
continuation, throwing `IllegalStateException: Already resumed` on a
`Dispatchers.Default` worker.
2. **Retry id-reuse → wrong data** (NIP-46 only) —
`launchWaitAndParse` built the request and event once, then re-used
the same `request.id` across retry attempts. A late response from
attempt N could resume attempt N+1's continuation with stale data.
Replace the cached-`Continuation` map with the in-house Channel-per-request
correlation pattern already used in `quartz/.../accessories/NostrClientPublishExt.kt`
(`LargeCache<id, Channel<Response>(capacity=1)>` + atomic `remove` +
`trySend` + `withTimeoutOrNull { receive() }`). Each retry attempt now
builds a fresh request with a new id; the builder is still called only
once. `finally`-block cleanup removes the cache entry on every path,
incidentally fixing a slow leak on the success path.
Adds three regression tests:
- duplicate responses → no crash + single resume (fails on \`main\`
with \`IllegalStateException\`)
- late response after timeout → silently discarded
- late attempt-1 response does not corrupt attempt-2 result (fails on
\`main\`: the two attempts share an id)
Design + review notes: \`quartz/plans/2026-06-03-fix-nip46-bunker-double-resume-plan.md\`
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.
On the short new note screen, polls and zap polls kept their option text in
two separate maps (`pollOptions` vs `zapPollOptions`). Switching the poll type
only toggled the `wantsPoll`/`wantsZapPoll` flags, so the text typed for one
type was hidden and never reused for the other.
Make both poll types read and write the same `pollOptions` text fields, so
switching between zap and non-zap polls preserves whatever the user already
typed. The zap-specific settings (deadline, vote value range, consensus
threshold) remain independent.
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>