Adds the `inc` pub/sub bus so napplets that declare it boot and can exchange
topic events: `inc.subscribe {topic}` / `inc.unsubscribe {topic}` register
interest, `inc.emit {topic, payload}` fans out an `inc.event {topic, payload,
sender}` to OTHER subscribed napplet sessions (never echoing the sender) — the
kehto runtime's inc contract.
- Router edge ops (gated on the INC declaration alone, like identity.watch —
no per-call consent): SubscribeInc/UnsubscribeInc/EmitInc outcomes.
- Protocol: readTopic/readPayloadRaw + encodeIncEvent.
- NappletIncBus in the broker service routes across the live napplet sessions
(the one service every sandbox binds), keyed by reply Messenger.
- Tests for inc routing + declaration gating; updated capability/router tests
that asserted the old "inc/theme/notify are unknown" behavior.
NOTE: napplets run foreground-only/one-at-a-time, so cross-napplet delivery is
usually a no-op in practice; the bus is correct if sessions ever overlap. It is
app-wide (not author-scoped) — a future refinement could namespace topics by
author. Unblocks feed/profile-viewer/chat/bot. See the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.
- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
walks a directory (or single file), content-addresses each file, BUD-02
signed-uploads it via BlossomClient, and maps it to an absolute web path
(/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
hands the path tags to a kind-specific builder, signs with the account key,
and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.
Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):
- shim: onChanged registers a handler and opens a watch (identity.watch) on the
first handler; closing the last one stops it (identity.unwatch). identity.changed
pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
session manager and pushes identity.changed on each subsequent change (the
current value is dropped — the applet already has it via getPublicKey). Torn
down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.
Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.
The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.
Unit-tested in commons/jvmTest (NappletRequestRouterTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Phase 1 of the desktop relay-latency-health feature: add a rolling-window
latency tracker that decorates the quartz RelayConnectionListener, plus a
pure classifier that flags relays whose per-metric p50 exceeds 2× the cohort
median. No store integration or UI yet — those come in follow-up commits.
commons commonMain (CLI-safe, no Compose runtime, no JVM-only deps):
- LatencyMetric: OK_ACK / EOSE / FIRST_RESULT / PING
- MetricSample: @Immutable (p50Ms, count)
- RelayLatencySnapshot: @Immutable, backed by ImmutableMap so strong
skipping engages when unchanged rows are re-emitted
- SlowReason: @Immutable (metric, relayP50, cohortP50, multiplier)
- HealthReason sealed interface: Unresponsive(gap) | Slow(SlowReason)
- classifySlowRelays(): pure. Honors NIP-11 auth_required /
payment_required (paid/auth-only relays are excluded from both cohort
and target until auth completes — otherwise they'd be perpetually
flagged while CLOSED'ing anonymous queries).
commons jvmAndroid (ConcurrentHashMap is JVM-only):
- LatencyRingBuffer: fixed-capacity (default 50) IntArray ring,
synchronized push, snapshotMedian / snapshotSamples / restore.
- RelayLatencyTracker: pending-eventId / pending-subId / firstResultSeen
maps + per-(relay, metric) ring buffers. Handles every pairing rule
the deepened plan called out:
* onSent EventCmd → record eventId timestamp
* onSent ReqCmd → record subId timestamp; clear firstResultSeen
* onSent CloseCmd → drop pending subId (no sample) — prevents
ComposeSubscriptionManager's sub-id reuse from pairing late
events with a new REQ
* success=false → no-op (websocket buffer was full)
* OkMessage → pair by eventId, push OK_ACK
* EventMessage → first-only, push FIRST_RESULT
* EoseMessage → pair by subId, push EOSE
* ClosedMessage → drop pending (fast negative response, not a
latency signal — was previously recording 300s TTL samples for
any auth-required relay)
* onConnected → push PING
* onDisconnected → drop all pending (no TTL samples)
* sweep(now) → TTL-expire pending entries (60s OK / 300s REQ),
record TTL value as the sample
AUTH retries: the second onSent overwrites the timestamp, so samples
reflect the retry leg — matches the user's mental model of "speed of
the actual publish". Pending maps are size-capped at 256 entries per
relay as a safety net against adversarial relays. Tracker owns no
CoroutineScope — RelayHealthStore drives sweep + snapshot from its
existing 60s reclassify tick (Phase 2).
- RelayLatencyListener: thin RelayConnectionListener decorator,
installInto / uninstallFrom paralleling RelayHealthListener.
Tests:
- LatencyRingBufferTest (7): wrap, median odd/even, restore from larger
or smaller arrays, chronological snapshotSamples.
- RelayLatencyTrackerTest (13): OK pairing, EOSE + FIRST_RESULT pairing,
success=false ignore, CloseCmd drops pending, ClosedMessage drops
pending, AUTH retry overwrites timestamp, disconnect drops all,
sweep TTL semantics (OK vs REQ), FIRST_RESULT only sampled when not
yet seen, ping, per-relay isolation, 256-entry cap, restore
round-trip.
- ClassifySlowRelaysTest (9): empty, Tor short-circuit, cohort < 2,
2× flag, count-below-min excludes from cohort, NIP-11 auth_required
excludes / includes once auth complete, payment_required excludes,
worst-metric-multiplier wins when multiple flag, exact-2× does not
flag (strict greater-than).
Note: a pre-existing RelayHealthStoreCloseTest case on the base branch
(fix/relay-health-threading-and-sleep-resume) hangs in advanceUntilIdle.
Not related to this commit; new tests pass cleanly with a tighter test
filter. Will revisit when integrating Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clamp the seeded kind:445 subscription since at wall-clock now: the
inner createdAt is sender-controlled, so a single future-dated message
could push since past the present and silently skip genuinely new
events on every restart. Covered by a new regression test.
- Drop the remember() around the group-list unread count: the chatroom's
message set can shrink without newestMessage or lastReadTime changing
(pruning, kind:5 deletion of an older message), which left the cached
count stale. The set is pruned to ~100 entries, so counting per
recomposition is cheap.
- Extract marmotGroupLastReadRoute(): the "MarmotGroup/<id>" last-read
key was inlined at three call sites; a prefix drift between the
mark-as-read side and the unread checks would silently reintroduce
the bug this branch fixes.
- Derive GROUP_EVENT_REFETCH_OVERLAP_SEC from TimeUtils.ONE_DAY instead
of re-deriving 24*60*60.
The test lived in commons androidHostTest, but no CI workflow or
pre-push task runs :commons:testAndroidHostTest — and running it
manually fails before reaching any assertion: quartz's android
PlatformLog actual hits unmocked android.util.Log stubs
(NoSuchMethodError), since the source set is not configured with
returnDefaultValues. The end-to-end leave/rejoin coverage was
therefore never executed anywhere.
:commons:jvmTest runs in CI and in the pre-push hook, already has the
secp256k1 JVM bindings the test needs, and uses quartz's JVM logger.
Verified green there alongside MarmotManagerRestoreTest.
The Marmot subscription since, the processed-event dedup set, and the
application ratchet position (group state persists only at commits) are
all in-memory only. On restart, relays therefore redeliver the group's
entire kind:445 history and the rewound ratchet re-decrypts old
application messages as if they had just arrived — wasted decryption
work and, when a replay beats the disk restore, duplicate entries
appended to the persisted plaintext message log.
Two defenses:
- MarmotManager.restoreAll() now seeds each restored group's
subscription since from the newest persisted decrypted message, minus
a one-day overlap window for late/out-of-order publishes. Seeding
happens before syncWithGroupManager registers default entries, so
even the first filter set sent to relays carries it. The CLI is
unaffected: it builds group filters from its own persisted since.
- MarmotMessageStore appends are now explicitly idempotent (contract
was previously ambiguous and both real stores appended blindly):
the Android and CLI file stores skip an entry that is already in the
group's log, so replays inside the overlap window cannot grow it.
Covered by MarmotManagerRestoreTest in commons jvmTest — placed there
rather than androidHostTest because CI only runs :commons:jvmTest (the
androidHostTest task currently fails on android.util.Log stubs even
for the pre-existing Marmot test).
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.
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.
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 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.
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>
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints
that aggregates mint URLs from every relevant event the cache sees, and
wires it into the AddCashuWallet mint-URL text field as inline
autocomplete so users don't have to remember mint URLs.
What feeds the directory:
- NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet
publishes their accepted mints there. A typical inbox of cached
profiles seeds a useful starter directory automatically.
- MintRecommendationEvent (kind:38000) — explicit public vouches.
- CashuMintEvent (kind:38172) — formal mint announcements from the
NIP-87 directory subscription.
How it's populated:
- LocalCache.updateMintIndex(event) is called from
justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new
event with a mint URL adds to the index. wasNew gating prevents
re-emissions from inflating popularity counters.
- LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the
existing notes + addressables maps. The autocomplete UI kicks this in
a LaunchedEffect on screen open so suggestions are useful before the
next relay round-trip.
Where it surfaces today:
- AddCashuWalletScreen — under the mint-URL OutlinedTextField, a
MintSuggestionList card shows up to 6 cache-derived suggestions ranked
by popularity desc + URL asc. Tapping a row fills the field (does not
auto-add — users typically want to Verify first). Filters out URLs the
user already added and exact matches of what they typed.
The MintPicker dropdown inside the Receive / Send dialogs is unchanged
— those only need to choose between mints the user already has in their
wallet, so no directory autocomplete applies there.
Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash
stripping, http(s) gating), popularity ranking, substring filtering,
limit enforcement, and malformed-URL handling.
URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme
must be http(s). Same URL with different casing or trailing slash
collapses to one entry so popularity counts correctly.
Implementation notes:
- MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap;
iOS doesn't ship Cashu wallet yet).
- Thread-safe; safe to read from any dispatcher.
- No persistence — purely in-memory, accumulates over the session.
- Entries are never removed: stale entries don't hurt (user always
verifies before adding), and tracking which event added which URL
would add bookkeeping without UX benefit.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
live in commons so desktop and the future CLI can consume them. Package
changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
and LocalCache. The ViewModels stay for the same reason; lifting them
requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
properties don't support implicit smart-cast.
Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
January 15, 2025, 2 events, today, selected") via mergeDescendants so
TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
the concrete create action.
Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
fully-qualified androidx.compose / com.vitorpamplona references into
proper imports per the codebase style.
All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).