Addresses 10 audit findings from the post-build review:
CRITICAL — non-functional without this:
- LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154,
so every podcast event fell through to "Event Not Supported" and was
silently dropped. Added the four explicit branches (regular event for
PodcastEpisode; replaceable for the other three).
HIGH — silent invisibility / broken tap-through:
- Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and
notification feed filters didn't recognize PodcastEpisodeEvent /
PodcastMetadataEvent. Episodes were invisible everywhere outside the
dedicated tab; reactions/zaps on episodes were dropped from the
notifications feed.
- ThreadFeedView's renderer dispatch had no podcast branch, so tapping a
feed card opened a plain text-note view. Added explicit cases that call
the new RenderPodcastEpisode / RenderPodcastMetadata composables.
- The hashtag / geohash / relay / search REQ kind lists didn't include
podcast kinds, so discovery surfaces returned nothing for them.
- RelayInformationScreen kind→label map gained podcast entries +
4 new string resources (Podcast Episode, Podcast Show, Authored
Podcasts, Favorite Podcasts).
- HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so
shows surface alongside music/wiki/long-form on the home feed.
HIGH — privacy leak in Quartz:
- FavoritePodcastsListEvent.add(isPrivate=true) was passing
earlierVersion.tags through untouched, so toggling a previously-public
favorite to private left the public p-tag intact. Made both branches
symmetric: each removes the entry from the other half before adding to
its own. Two regression tests cover the round-trip.
MEDIUM — data hygiene:
- AuthorTag.parse used to accept ANY non-empty slot-2 string as a role
(rendering a stray relay-hint URL as "Role: wss://relay…"). Now
validates against the spec-defined {host, cohost, editor} allowlist;
unknown values resolve to role=null, preserving the pubkey association.
PERF:
- PodcastEpisode renderer was allocating a fresh 96-element WaveformData
and rebuilding the cover Modifier chain per visible card. Hoisted both
to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER,
PLAYER_BORDER_MODIFIER) so the whole feed shares one instance.
CODE QUALITY:
- Extracted PodcastCoverCard as a shared composable used by both renderers
(was duplicated byte-identical across PodcastEpisode + PodcastMetadata).
- Extracted PodcastFeedLoaded so the Episodes screen and Shows screen
share one feed body (was duplicated byte-identical).
- Dropped the misleading `group = listOf(singleAssembler)` wrapper in the
two FilterAssembler files.
- Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the
Communities sub-assembly (the lambda never returns null).
DOCUMENTED:
- PODCAST_KINDS "Following" resolution still goes through kind:3 follows,
but per NIP-F4 podcasts use their own keypairs tracked via kind:10054.
Added an inline comment naming the deferred work — proper fix needs
Account-level 10054 integration which is a separate scope.
Swaps PlayCircle / AudioFile (generic) for the canonical Material Symbols
podcast iconography — `headphones` (U+F01F) on the Episodes feed and
`podcasts` (U+F048, the mic + signal-waves glyph) on the Shows feed.
Both codepoints added to MaterialSymbols.kt and the subset font
regenerated via tools/material-symbols-subset/subset.sh.
Mirrors the existing Music/Playlists screen pair end-to-end so podcast
events flow through the standard feed pipeline:
- AccountSettings/Account/LocalPreferences gain the two new follow-list
selectors (defaultPodcastEpisodesFollowList, defaultPodcastsFollowList)
plus their derived liveX/liveXPerRelay flows.
- AccountFeedContentStates wires podcastEpisodesFeed (kind 54 from
LocalCache.notes) and podcastsFeed (kind 10154 from addressables) into
updateFeedsWith/deleteNotes.
- RelaySubscriptionsCoordinator + BottomBarFeedPreloaders register the
two new filter assemblers, each with its own EOSE/since cursor.
- Routes/AppNavigation/NavBarItem add the two destinations; both go in
DrawerFeedsItems with PlayCircle and AudioFile icons (existing subset
glyphs, no font regeneration required).
- New NoteCompose dispatch cases call RenderPodcastEpisode (cover +
audio player via the shared GetMediaItem/GetVideoController/
RenderVoicePlayer chain + description + markdown content) and
RenderPodcastMetadata (cover + title + description + website chips).
Skipped (separate PRs): authoring flows (NewPodcast/NewEpisode) and the
kind:10054 favorites toggle sheet — podcast publishers typically don't
hold their podcast keypair in Amethyst, and favorites need a
PrivateTagArrayEventCache hookup in Account that's larger than the read
path alone.
Implements the four event kinds defined by NIP-F4 so Quartz can parse and
build native Nostr podcasts: kind:10154 show metadata, kind:10064 author
counter-claim, kind:54 episode, and kind:10054 favorite-podcasts list. All
four are registered in EventFactory so the existing JSON deserialization
pipeline returns typed instances. Tag classes mirror the per-event package
layout used by the experimental music module.
LaunchedEffect runs on Dispatchers.Main by default. The first call to
LanguageTranslatorService triggers its class init, which loads a Properties
file from inside the play-services AAR via ZipFile/RandomAccessFile and
trips StrictMode's DiskReadViolation on the UI thread.
Wrap the translateAndCache call in withContext(Dispatchers.IO) so MLKit's
first-touch init happens off the UI dispatcher.
The find-missing-translations skill only diffed <string name=, so missing
<plurals> resources slipped through. Updated Steps 2, 2.5, 3 to diff
<plurals> independently and added 3 missing music playlist plurals
across cs/de/sv with correct CLDR category coverage (Czech: one/few/many/other).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The trailing icon used MaterialSymbols.Lock in both branches of the
visibility conditional, making it a no-op. Switch to Visibility /
VisibilityOff to match the AccountBackupScreen convention.
Pulls StickToTopOnPrepend out of every per-feed callsite and into
SaveableFeedContentState, SaveableGridFeedContentState, SaveableFeedState,
and SaveableGridFeedState — the same wrappers that already own
WatchScrollToTop. A new FeedContentState-keyed overload derives the head
key from feedContent → Loaded.feed → list.firstOrNull()?.idHex, so the
wrappers can wire auto-stick without any per-feed plumbing.
Removes the explicit StickToTopOnPrepend calls from FeedLoaded,
PictureFeedLoaded, ArticlesFeedLoaded, NestsFeedLoaded,
WebBookmarksFeedLoaded, GalleryFeedLoaded, DiscoverFeedLoaded,
DiscoverFeedColumnsLoaded, and ChatroomListFeedView — they all consume
listStates created by one of the four wrappers above.
Kept as explicit calls:
- UserFeedView (custom listState, no wrapper)
- CardFeedView (CardFeedContentState — different type)
- TabNotesNewThreads (custom listState, no wrapper)
- BrowseEmojiSetsScreen (doesn't use SaveableGridFeedContentState)
Also extracts the list/grid bodies to a private stickToTopOnPrepend
core that takes the state object as the LaunchedEffect key plus
lambdas for sampling / scroll, and switches the cached flag from
mutableStateOf to a plain BooleanArray holder (read only from effects,
never composition — no snapshot tracking needed). Adds the missing
"why isScrollInProgress gating is safe" line to the KDoc.
Adds StickToTopOnPrepend, a Compose helper that auto-scrolls back to
index 0 whenever new items land at the head of a feed — but only if the
user was already at the very top right before the update. Wired into
every FeedLoaded variant: Home/Hashtag (FeedLoaded), Notifications
(CardFeedView), Pictures, Articles, Discover (list + grid),
WebBookmarks, ProfileGallery, BrowseEmojiSets, Chatroom list,
UserFeed, NestsFeed, and TabNotesNewThreads.
Why this was broken: every feed uses stable key = item.idHex, so when N
items prepend Compose preserves the user's visual anchor by shifting
firstVisibleItemIndex from 0 to N. The existing
WatchScrollToTop only fires on explicit tab-bar taps, and the
LaunchedEffect(items.firstOrNull()) { if (firstVisibleItemIndex <= 1) }
pattern (used in ChatFeedView and PublicChatsFeedLoaded) breaks the
moment more than one item arrives in the same batch.
How the helper avoids the race: it tracks "was at top" continuously via
snapshotFlow but only flips it true → false when isScrollInProgress is
true. Data-driven index shifts happen with isScrollInProgress == false,
so they never poison the cached value. When firstItemKey changes and
the cached value is still true, we snap back to 0 with an instant
(non-animated) scroll so the prepend appears as in-place growth instead
of a visible jump-then-scroll.
ChatFeedView is left alone — it already works because reverseLayout
masks the prepend shift.
Rework the NIP-82 Software Applications feed item so it scans cleanly at
list density and split the detail content into its own route.
Feed card (RenderSoftwareApplication): icon + name + summary, full
description (3 lines), platforms / license chips, and a latest-version
chip resolved from LocalCache. Drops the screenshots strip, website /
repo link rows, and #topic chips at feed scale. The card is tappable
and the standard ReactionsRow now hosts replies, boosts, likes, zaps,
and share underneath each card.
New SoftwareAppDetailScreen (Route.SoftwareAppDetail) reached via the
card tap, the routeFor() dispatch, and naddr deep links. Layout: 72dp
header, screenshots carousel, About, Platforms, Topics (#tag chips
clickable through to Route.Hashtag), Links, ReactionsRow, latest
release with bundled assets, a collapsible "show older releases"
section, and the NIP-22 comment thread inline (driven off the app's
address tag through ThreadFeedViewModel + ThreadFilterAssembler).
Brings Amethyst Desktop to feature parity with Android for the Namecoin
resolution backend stack landed in #3056 / #3068. Three pieces:
- DesktopNamecoinPreferences now persists backend, namecoinCoreRpc,
fallbackToCustomElectrumx and fallbackToDefaultElectrumx (KEY_BACKEND
/ KEY_CORE_RPC / KEY_FALLBACK_*), mirroring NamecoinSharedPreferences.
Mutators are non-suspend because java.util.prefs is synchronous,
unlike Android's coroutine-backed DataStore. The Jackson mapper now
rejects unknown properties on read so kotlinx `@Serializable`
computed getters (e.g. NamecoinCoreRpcConfig.isUsable) round-trip
cleanly through java.util.prefs.
- DesktopNamecoinNameService takes an optional OkHttpClient provider,
lazily constructs a NamecoinCoreRpcClient when supplied, and builds
a fresh CompositeNamecoinBackend per lookup based on current
NamecoinSettings. Same shape as AppModules#buildNamecoinBackend.
Exposes the underlying RPC client (rpcClient) and a probeCoreRpc(cfg)
helper for the Settings Test RPC button.
- NamecoinSettingsSection gains a backend radio selector, a Core RPC
subform (URL / username / password / Save / Test RPC) with a TOFU
cert-pin AlertDialog mirroring Android's NamecoinCoreRpcSection, and
a fallback toggles section. The same KEY_PINNED_CERTS list is shared
with both ElectrumXClient and NamecoinCoreRpcClient via
setDynamicCerts(...), matching Android's behaviour where both
backends consume one trust store.
- Main.kt wires DesktopHttpClient.currentClient() in as the Core RPC
HTTP provider so .onion RPC URLs flow through the existing Tor
routing without extra plumbing, and propagates the new mutators to
the Settings UI.
- Extends DesktopNamecoinPreferencesTest with 8 new cases covering
default state, backend round-trip, Core RPC URL/user/pass/pin-flag
round-trip, fallback toggles, reset clearing, and a full
multi-field round-trip across a fresh preferences instance.
Verification on the canonical workspace clone:
- ./gradlew :commons:jvmTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:compileKotlin :desktopApp:test — BUILD SUCCESSFUL
- ./gradlew :amethyst:testPlayDebugUnitTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck
:desktopApp:spotlessCheck — BUILD SUCCESSFUL
Mirrors Android's NamecoinSharedPreferences pinned-cert API on Desktop so
user-accepted TLS pins survive process restart. Same JSON-list shape, same
distinct-append semantics, same wipe-on-reset behaviour.
What's new
- DesktopNamecoinPreferences gains addPinnedCert / loadPinnedCerts /
clearPinnedCerts (sync rather than suspend, since java.util.prefs is
synchronous). reset() now clears pinned certs too, matching Android.
- DesktopNamecoinNameService accepts a pinnedCertsProvider and pushes the
loaded list into ElectrumXClient.setDynamicCerts at init, mirroring
Android's AppModules.kt wiring. Exposes the underlying client so the
Settings UI can call testServer() and re-apply pins live.
- Desktop NamecoinSettingsSection grows an optional Test Connection + TOFU
pin sub-section: runs ElectrumXClient.testServer per active server,
collects PEM + SHA-256 fingerprint from successful TLS handshakes, and
prompts the user to pin each new cert via AlertDialog. UI hidden when
no service is wired (so existing call sites stay valid).
- Main.kt wires both halves together and updates the freshly-pinned cert
list into the live client without waiting for restart.
Persistence is plain java.util.prefs (same backing store as the rest of
DesktopNamecoinPreferences) — explicitly NOT EncryptedSharedPreferences.
Pinned cert PEMs are public material; no secrets stored.
Tests
- DesktopNamecoinPreferencesTest: +6 cases covering empty default,
persistence + reload, dedup, blank input ignored, reset wipes, and
independence from settings copies.
Verification
- ./gradlew :desktopApp:compileKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:test — BUILD SUCCESSFUL (16 tests, 0 failures)
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL
Stack note
Stacked behind #3072 (merged 2026-05-27). Next: PR-C for the full
Namecoin Core RPC backend + composite fallback persistence + UI.
Two issues with the send flow on the new-music-track screen:
1. Cover + audio uploaded sequentially. As cover finished, its picker
reverted to the placeholder while audio was still going, making the
user think the operation was done.
2. The coroutine ran on the screen's viewModelScope, so leaving the
screen cancelled it.
Rework the upload pipeline:
* Snapshot the form into an immutable SendSnapshot before launching, so
user edits to the form during the in-flight upload don't poison what
gets published, and the coroutine sees stable inputs even after the
per-screen VM is cleared.
* Run cover + audio uploads in parallel via async/awaitAll. Either
side failing throws an UploadException that the outer catch routes
to the global toast manager.
* Launch through accountViewModel.launchSigner so the work runs on the
AccountViewModel.viewModelScope — survives screen leave. Errors and
success both surface as global toasts.
* Keep coverMedia/audioMedia/pickedAudioName populated through the
ENTIRE upload+publish, not just per-phase. Only clear them on
success, so each picker keeps showing the user the file it's still
uploading.
* Single isSending flag replaces the separate isUploading/isPublishing
booleans; drives an inline progress banner + disables the Send
button + dims the pickers to read-only while in flight.
* One-shot completionEvents SharedFlow that the screen subscribes to.
If the user is still on the screen when the operation finishes, we
popBack; if they've already left, no one is listening and we don't
pop someone else's stack frame.
Also tighten the playback Row height in MusicTrack from 100dp to 80dp.
With a 75dp play button the previous 100dp container left a ~12.5dp
empty band top + bottom that the user flagged as too tall.
The route hint set by newKey() (Route.ImportFollowsSelectUser) was stored
on AccountState.LoggedIn and never cleared. LoggedInPage re-assigned
accountViewModel.firstRoute = route on every recomposition, so any
Activity recreation (rotation, dark-mode toggle, returning from
background) made the screen pop up again even after the user dismissed
it via popBack.
Consume the route once via remember(state) in AccountScreen, and gate the
firstRoute assignment behind LaunchedEffect(Unit) so it only fires once
per composition lifetime.
Five changes:
1. Drop the audio-URL text field from the composer. Upload tile at the
top is the single source of truth; the ViewModel still threads the
existing url through MusicTrackEvent.edit so edit mode keeps it.
2. Auto-fill title / artist / album / duration from MediaMetadataRetriever
when an audio file is picked. Only fills empty fields — anything the
user already typed wins. Duration is the exception: derived numbers
overwrite, since they're not opinions.
3. Rename the SavingTopBar 'Save' button to 'Send' for this screen
(introduces SendingTopBar + R.string.send so other screens can reuse).
4. Fix silent data corruption in AddToMusicPlaylistViewModel.init:
the previous `if account already set, return` guard kept the FIRST
track address the VM ever saw. Subsequent invocations with a
different trackAddress (process recreation, navigation reusing the
back-stack entry) routed toggle() at the stale track, adding the
wrong track to playlists. Now refreshes trackAddress on every call
and restarts the scan job when it changes.
5. Inset modifier order on NewMusicTrackScreen tightened in the prior
commit, kept here for continuity.
Three fixes in one:
1. MediaSaverToDisk crash when saving a music URL. The fall-through
branch routed every non-image, non-PDF MIME to
MediaStore.Video.EXTERNAL_CONTENT_URI; an audio/mpeg blob inserted
into the Video collection fails with IllegalArgumentException. Add a
dedicated audio/* branch that writes to MediaStore.Audio with
DIRECTORY_MUSIC.
2. Drop the redundant cover-URL text field from the new-music-track
composer. The upload tile at the top is the source of truth for the
cover — having a second field at the bottom asked the user to set
the same thing twice. ViewModel still threads coverUrl through to
MusicTrackEvent.edit so editing an existing track keeps its image.
3. Tighten the new-music-track Scaffold's inset modifier order:
padding(pad) -> consumeWindowInsets(pad) -> imePadding() ->
horizontal padding -> verticalScroll. Without consumeWindowInsets,
imePadding double-counts the nav-bar inset on Android 11+ and the
bottom field gets shoved too far up when the keyboard opens.
Two NamecoinSettings classes had drifted:
- commons (used by Desktop): only enabled + customServers
- amethyst service.namecoin (used by Android): full schema with backend,
namecoinCoreRpc, fallbackToCustomElectrumx, fallbackToDefaultElectrumx
This left Desktop unable to persist any of the Namecoin Core RPC or
fallback-policy state introduced in the Android settings UI. Promote
the rich Android version into commons as the single source of truth
and delete the Android duplicate.
- Move the rich schema (backend, namecoinCoreRpc, fallback toggles,
hasUsableCoreRpc, toFallbackPolicy) into the commons NamecoinSettings.
- Delete amethyst/service/namecoin/NamecoinSettings.kt and its test.
- Repoint the two Android imports (NamecoinSharedPreferences,
NamecoinSettingsSection) at the commons class. No behaviour change on
Android.
- Fold the Android-only backend/RPC/fallback test cases into the commons
NamecoinSettingsTest so the shared schema stays covered.
Desktop persistence (DesktopNamecoinPreferences) still only reads/writes
enabled + customServers; the extra commons fields fall back to defaults
on the existing Desktop store. Wiring those new fields into Desktop is
the next change.
The inline Namecoin resolution row in the global search bar (and the
on-chain zap recipient field) is gated by looksLikeNamecoinIdentifier,
which previously only matched the '.bit' shapes. Direct namespace
references like 'id/mstrofnone' or 'd/mstrofnone' — both accepted by
NamecoinNameResolver.isNamecoinIdentifier — fell through the gate and
the resolver was never called, so no on-chain feedback appeared in the
search bar.
Bring the UI gate in line with the resolver:
- accept 'd/<name>' (domain namespace, direct reference)
- accept 'id/<name>' (identity namespace)
- lower the length floor so single-character labels (valid, if
expensive, on chain) still trigger; '.bit' inputs keep their
5-char floor
Doc comment for the row's behaviour and the NamecoinResolutionRow
KDoc are updated to reflect the broader accepted set. New unit tests
cover both new prefixes (with case-insensitivity and leading '@'),
short single-label names, bare-prefix rejection, and explicit
non-routing for other Namecoin namespaces ('a/', 'u/').
The previous cover used SubcomposeAsyncImage with a single content lambda
that stacked the loaded image and the chip as BoxScope siblings. Under
Coil's subcompose-layout pass the chip's wrap-content Box got measured
to the cover's full size, painting a translucent black square over the
whole artwork.
Drop SubcomposeAsyncImage. Use rememberAsyncImagePainter + a plain
Image + painter-state observation inside a normal Box. The image fills
the box (Modifier.matchParentSize) and the chip sits as a normal
BoxScope child with Modifier.align(BottomStart).padding(12.dp) —
predictable wrap-content sizing.
The Apps screen was missing the top-nav follow-list spinner that other
single-feed screens (Articles, Longs, Pictures) use, and nothing was
loading because the relay subscription only queried the user's own
outbox and `SoftwareApplicationEvent` (kind 32267) was never consumed
by `LocalCache.justConsumeInnerInner` — it fell through to the
"Event Not Supported" else branch and got dropped. `ReleaseArtifactSetEvent`
(kind 30063) and `SoftwareAssetEvent` (kind 3063) had the same gap.
- Register `SoftwareApplicationEvent`, `SoftwareAssetEvent`, and
`ReleaseArtifactSetEvent` in the `LocalCache` consume dispatch.
- Add `defaultSoftwareAppsFollowList` setting (Account/Settings/Prefs)
and `liveSoftwareAppsFollowLists{,PerRelay}` flows.
- Reshape `SoftwareAppsSubAssembler` to extend
`PerUserAndFollowListEoseManager<_, TopFilter>` and assemble per-list
relay filters via `makeSoftwareAppsFilter`, mirroring Pictures/Longs.
- Add a `SoftwareAppsTopBar` with the standard `FeedFilterSpinner` and
a `WatchAccountForSoftwareAppsScreen` to invalidate the DAL when the
selected list changes.
- DAL now applies `FilterByListParams` so the rendered feed honors the
active top-nav list.