Adds the audio-track imeta properties from NIP-71 PR #2255 so video
events can advertise external audio tracks (multi-language, alternate
bitrates) alongside video variants:
- New imeta properties: bitrate, duration (float seconds), waveform,
and l <code> <standard> [ov] for language with an original-version flag
- Extends VideoMeta with bitrate, duration, waveform, language fields
plus isAudio/isVideo helpers
- VideoEvent exposes audioTracks()/videoTracks() so players can prefer
separate audio tracks over in-video audio while switching resolution
- Round-trip test against the PR's spec example
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.
Addresses review feedback on PR #3068: keep the entire NamecoinCoreRpcClient
bootstrap (config push + pinned-cert load) inside the single applicationIOScope
launch instead of doing setConfig synchronously in the by-lazy block. Matches
the ElectrumXClient init shape above.