Commit Graph

13688 Commits

Author SHA1 Message Date
Claude f35e3767ad build: enable parallel + build cache + Kotlin classpath snapshot
Halves cold compile time for compilePlayDebugKotlin on this machine
(6m25s -> 3m26s, 47% reduction):

- org.gradle.parallel=true lets quic/nestsClient/commons compile
  concurrently once quartz is done, instead of serially.
- org.gradle.caching=true lets warm cross-clean builds reuse task
  outputs.
- kotlin.incremental.useClasspathSnapshot=true narrows the incremental
  recompile blast radius after dependency-jar changes.
- Bumps Kotlin daemon MaxMetaspaceSize 3g -> 4g for headroom under
  the Compose IR phase across ~2.8K @Composable functions.

Up-to-date and same-file incremental times are unchanged (~3s and ~4s)
since those weren't bottlenecked by these settings.

Configuration cache is NOT enabled: amethyst/build.gradle:12 runs
'git rev-parse --abbrev-ref HEAD' at configuration time, which is
incompatible with the configuration cache. Migrating that to a
ValueSource is a separate change.
2026-05-16 14:16:44 +00:00
Vitor Pamplona f8dd5b61a6 Merge pull request #2934 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-16 09:57:56 -04:00
Crowdin Bot 9253387c93 New Crowdin translations by GitHub Action 2026-05-16 13:43:39 +00:00
Vitor Pamplona bbeba28f0b Merge pull request #2936 from vitorpamplona/claude/fix-dns-loopback-caching-ntjbN
Filter DNS poison (loopback/any-local) from non-loopback hosts
2026-05-16 09:42:03 -04:00
Claude c36c9ccf43 fix(dns): treat trailing-dot FQDN form of localhost as loopback
RFC 1034: `localhost.` and `localhost` are the same name — the trailing
dot just marks the FQDN form. Without this, an upstream answer of
127.0.0.1 for `localhost.` (or `relay.localhost.`) would get filtered as
poison, breaking user-configured local relays addressed in FQDN form.
2026-05-16 13:37:02 +00:00
Claude 440f5495a4 fix(dns): reject loopback poison + dirty cache on restore drop
SurgeDns was faithfully caching whatever the system resolver returned —
including 127.0.0.1 / ::1 / 0.0.0.0 — for 24-48h plus an on-disk
snapshot. A single bad answer (captive portal, ad-blocker DNS, transient
VPN hiccup) could leave the user unable to reach any non-loopback relay
for days: connection attempts go to their own loopback, fail, and the
next lookup re-resolves through the same source.

- Filter loopback/any-local addresses out of lookupAndCache and restore,
  unless the hostname is itself a loopback name (localhost, .localhost
  subdomains, or 127.0.0.1 / ::1 literals) so user-configured local
  relays keep working.
- Mark cache dirty when restore drops poisoned on-disk entries so the
  next save rewrites the snapshot without them — otherwise the bad
  entries would be re-restored on every cold start.
2026-05-16 13:17:08 +00:00
Vitor Pamplona a68ce75313 Merge pull request #2935 from vitorpamplona/claude/move-surgednsstore-cache-OKApI
Migrate DNS cache from SharedPreferences to cacheDir
2026-05-16 09:09:12 -04:00
Vitor Pamplona 8b164baab3 Merge pull request #2931 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-16 08:55:27 -04:00
Claude 58cbae7abc refactor(dns-cache): store SurgeDns snapshot under cacheDir
Moves the SurgeDns persisted snapshot from a SharedPreferences blob
under /data/data/.../shared_prefs to a plain JSON file under cacheDir.
The snapshot is pure perf data — if the OS evicts it under storage
pressure, the resolver just falls back to sync getaddrinfo and rebuilds
as lookups happen, which is the correct trade-off for a cache.

Writes go through a sibling .tmp + rename so a crash mid-write can't
leave a half-written blob behind. AppModules also deletes the legacy
amethyst_dns_cache SharedPreferences on next launch so the old store
doesn't linger.
2026-05-16 12:50:01 +00:00
Crowdin Bot d3c6518696 New Crowdin translations by GitHub Action 2026-05-16 12:41:53 +00:00
Vitor Pamplona b947a1b223 Merge pull request #2933 from vitorpamplona/claude/fix-timeout-samsung-android-m2Q2v
Use accountViewModel.launchSigner for relay join/leave requests
2026-05-16 08:40:12 -04:00
Vitor Pamplona b336af6cc3 Merge pull request #2932 from vitorpamplona/claude/fix-fdroid-exception-8ZHJH
Fix ChatroomListKnownFeedFilter to use flowSet instead of flow
2026-05-16 08:36:05 -04:00
Claude 106977ccd4 fix(relay-members): route NIP-43 join/leave through launchSigner
The join/leave-request buttons launched a raw scope.launch(Dispatchers.IO)
and called account.signer.sign() directly. When the signer threw —
TimedOutException from a Samsung-A53 / Android-16 Amber prompt the user
didn't respond to in 30s, or any other SignerException — the failure
escaped the composable's scope and was reported as an uncaught crash
instead of being toasted/logged like every other signing operation.

Route both buttons through accountViewModel.launchSigner so the same
SignerExceptions handling that every other signing entry point uses
applies here too.
2026-05-16 12:31:07 +00:00
Claude 1c4ddfb2ce fix(chats): dedupe public channels in known list by channel id
ChatroomListKnownFeedFilter.feed() iterated account.publicChatList.flow,
a Set<ChannelTag>. ChannelTag uses identity equality (it's not a data
class) and the set is built from raw tag parsing, so a channel list
that names the same channel id twice (e.g., the same channel appearing
in the public section and the encrypted private section, or repeated
with different relay hints) produces multiple ChannelTag entries for
the same channel. Each one resolved to the same newest Note via
getOrCreatePublicChatChannel(it.eventId), so the feed contained the
same Note twice and the chat list LazyColumn crashed with "Key
PublicChannelLazyKey(channelId=...) was already used".

Use the sibling flowSet (Set<HexKey>, already deduped by event id),
which is the same source filterRelevantPublicMessages reads from.
2026-05-16 12:28:30 +00:00
Claude f965d3b331 Revert "fix(chats): match public-channel rows by channel id when merging updates"
This reverts commit 13c3ddd446.
2026-05-16 12:26:52 +00:00
Vitor Pamplona ad8db9b9f8 Merge pull request #2930 from vitorpamplona/claude/fix-indexoutofbounds-android-iTCZr
Fix crash when toggling home tabs with persisted pager state
2026-05-16 08:24:49 -04:00
Claude 386d827807 fix: clamp Home TabRow selectedTabIndex when tab count shrinks
rememberForeverPagerState persists currentPage across tab-count changes.
When the user toggles off one of the conditional Home tabs (New Threads,
Conversations, Everything), tabs.size shrinks but pagerState.currentPage
is still pointing at the removed slot, so Material3's
TabIndicatorOffsetNode reads tabPositions[currentPage] out of bounds and
crashes with IndexOutOfBoundsException.

Clamp the index for the TabRow and use getOrNull for the bottom-bar
re-tap callback.
2026-05-16 12:09:46 +00:00
Claude 13c3ddd446 fix(chats): match public-channel rows by channel id when merging updates
ChatroomListKnownFeedFilter.updateListWith only recognized
ChannelMessageEvent when checking the old list for an existing row to
replace. When a public channel's row was first populated from a
ChannelCreateEvent or ChannelMetadataEvent (no message had arrived
yet), the next incoming ChannelMessageEvent failed to match, so the
filter appended a second note for the same channel. Both rows then
produced the same PublicChannelLazyKey, crashing the LazyColumn with
"Key was already used".

Resolve the channel id from any IsInPublicChatChannel event (covers
ChannelMessageEvent and ChannelMetadataEvent) and fall back to the
event id for ChannelCreateEvent, mirroring how the lazy key is built
in ChatroomListFeedView.
2026-05-16 12:09:31 +00:00
Vitor Pamplona 8c73cc1d3b Merge pull request #2927 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-15 23:03:38 -04:00
Crowdin Bot 8fd9f71a58 New Crowdin translations by GitHub Action 2026-05-16 02:41:26 +00:00
Vitor Pamplona 2ca9488188 Merge pull request #2926 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-15 22:39:57 -04:00
Vitor Pamplona 50b69b1ca5 Merge pull request #2924 from mstrofnone/feat/desktop-render-import-follow-list-dialog
feat(desktop): wire Import Follow List dialog into UI
2026-05-15 22:39:48 -04:00
Crowdin Bot 7dd754cf96 New Crowdin translations by GitHub Action 2026-05-16 02:39:44 +00:00
Vitor Pamplona 907dead3a0 Merge pull request #2923 from mstrofnone/feat/desktop-namecoin-settings-ui
feat(desktop): wire NamecoinSettingsSection into Settings screen
2026-05-15 22:39:13 -04:00
Vitor Pamplona 3007dc92b3 Merge pull request #2920 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-15 22:38:18 -04:00
Vitor Pamplona 11aefe824b Merge pull request #2922 from mstrofnone/fix/desktop-proguard-jni-and-bytecode
fix(desktop): mirror Android ProGuard strategy for release builds
2026-05-15 22:37:51 -04:00
Vitor Pamplona 76394309c1 Merge pull request #2925 from vitorpamplona/claude/fix-background-video-playback-udxXX
fix(video): pause playback when app goes to background
2026-05-15 22:37:01 -04:00
Claude 0de70e51cd fix(video): pause playback when app goes to background
Re-introduces the lifecycle observer that was dropped in the Feb 24
flow refactor of GetVideoController (f2410a69 "removing most of the
little hacks to get the controller to work in the lifecycle"). Without
it, the scroll-position mutex is the only thing that pauses a video —
and the window's visible rect doesn't change on ON_PAUSE, so the
currently-playing video kept playing forever behind other apps.

Pause on ON_PAUSE, resume on ON_RESUME if the video is still the
visible/active one. The explicit BackgroundMedia (PiP) instance is
preserved as the opt-in to keep playing.
2026-05-16 02:27:05 +00:00
m 931a217251 fix(desktop): mirror Android ProGuard strategy for release builds
Compose Multiplatform 1.11.0 wired ProGuard 7.7.0 into the release
build for the first time. With optimize + shrink + obfuscate all on,
the desktop v1.09.1 DMG hit four distinct runtime failures:

  A. Jackson's SerializationFeature.values()/valueOf() stripped, so
     Class.getEnumConstants() returned null and ObjectMapper failed
     to initialise (app didn't launch). Fixed in #2921.
  B. fr.acinq.secp256k1.* JNI bridge classes renamed by obfuscate;
     bundled libsecp256k1-jni.dylib could not FindClass the original
     names, so KeyPair / sign / verify crashed on first use.
  C. androidx.sqlite-bundled native declarations (nativeThreadSafeMode,
     nativeOpen) shrunk away because no Kotlin caller referenced them
     directly; libsqliteJni.dylib raised NoSuchMethodError on the first
     EventStore query.
  D. ProGuard's method/specialization/returntype optimize sub-pass
     generated a synthetic okio bridge (Okio__OkioKt.buffer$<hash>)
     whose declared return type was RealBufferedSource but whose body
     returned the BufferedSource super-interface. The JVM verifier
     rejected the bridge, killing every OkHttp connection.

The Android (mobile) module solved the same class of problems in
amethyst/proguard-rules.pro with a single global strategy:

    -dontobfuscate
    -keepnames class ** { *; }
    -keep enum ** { *; }
    + per-library -keep rules for JNA / libsodium / libscrypt
    + first-party -keep com.vitorpamplona.**

This commit replays that strategy in desktopApp/compose-rules.pro
and drops the previous optimize.set(false)/obfuscate.set(false)
escape hatch in desktopApp/build.gradle.kts. Shrink and optimize
both stay on; only the one optimize sub-pass that produced invalid
okio bytecode (method/specialization/returntype) is disabled.

Additional desktop-only keep rules (Jackson, full JNA, VLCj,
SLF4J, OkHttp / Conscrypt / BouncyCastle / OpenJSSE / Graal
dontwarns) stay in place. Two libraries shipped only on desktop
also get explicit -keep rules so their native callbacks survive
shrink:

  - androidx.sqlite.** + native <methods>; (nativeThreadSafeMode
    was previously stripped even with -keepnames, because shrink
    removes members the global rule doesn't cover).
  - pt.davidafsilva.apple.** + native <methods>; for the macOS
    Keychain JNI bridge.

Verified on macOS arm64 packageReleaseDmg:
  - javap on the post-ProGuard jars confirms:
      * fr.acinq.secp256k1.NativeSecp256k1 keeps its FQCN.
      * SerializationFeature.values() / valueOf() present.
      * androidx.sqlite.driver.bundled.BundledSQLiteDriverKt
        retains native nativeThreadSafeMode() + nativeOpen().
      * Okio__OkioKt only exposes buffer(Source): BufferedSource
        and buffer(Sink): BufferedSink — no specialized
        buffer$<hash> bridge.
  - Bundled app launches and reaches VLC MediaPlayerFactory init
    with zero VerifyError / NoSuchMethodError / UnsatisfiedLinkError
    in the log.
2026-05-16 11:38:15 +10:00
m 33c97c3991 feat(desktop): wire Import Follow List dialog into UI
The ImportFollowListDialog composable was already implemented but never
rendered. The File menu item set a boolean state that no observer
consumed.

Render the dialog from MainContent inside the CompositionLocalProvider
that supplies LocalNamecoinService, so Namecoin (.bit, d/, id/)
identifier resolution works in addition to npub/hex/NIP-05.

Also add a left-side launcher in both layouts so the feature is
discoverable without using the File menu:
- single-pane: NavigationRailItem (PersonAdd icon, 'Import' label)
- deck: IconButton in the DeckSidebar next to 'Add Column'
2026-05-16 11:25:46 +10:00
m d1f037c678 feat(desktop): wire NamecoinSettingsSection into Settings screen
The desktop client already ships DesktopNamecoinPreferences, the
DesktopNamecoinNameService that consumes them, and the NamecoinSettingsSection
composable that's a port of the Android UI. The section just wasn't surfaced
in the desktop Settings screen.

This wires NamecoinSettingsSection into RelaySettingsScreen, between the Tor
section and the Developer / Relay sections. Preferences come from the
namecoinPreferences parameter that the deck container already passes in, and
fall back to LocalNamecoinPreferences when called from another caller.

User-visible behaviour: the .bit / d/ / id/ ElectrumX server settings (master
toggle, custom servers, defaults indicator, add/remove, reset) are now
reachable from desktop Settings and persist via java.util.prefs.Preferences.
2026-05-16 10:09:12 +10:00
Crowdin Bot 296ac02125 New Crowdin translations by GitHub Action 2026-05-15 23:24:59 +00:00
Vitor Pamplona 3eb1403540 Merge pull request #2921 from mstrofnone/fix/desktop-proguard-keep-jackson
fix(desktop): add ProGuard keep rules so v1.09.1 desktop builds actually launch
2026-05-15 19:23:21 -04:00
m 31e73e3865 fix(desktop): add ProGuard keep rules for reflection-heavy deps
The v1.09.1 desktop release (DMG/MSI/DEB/RPM) fails to launch on every
platform with:

  Exception in thread "main" java.lang.ExceptionInInitializerError
      at com.fasterxml.jackson.databind.ObjectMapper.<init>(ObjectMapper.java:700)
      at com.vitorpamplona.amethyst.desktop.ui.deck.DeckState.<clinit>
  Caused by: java.lang.NullPointerException:
      Cannot read the array length because the return value of
      "java.lang.Class.getEnumConstants()" is null
      at com.fasterxml.jackson.databind.cfg.MapperConfig
              .collectFeatureDefaults(MapperConfig.java:107)
  Failed to launch JVM

Root cause: PR #2914 wired Compose 1.11.0's new ProGuard pass into the
release build but added only `-dontwarn` rules. ProGuard then optimized
Jackson's enum classes (`SerializationFeature`, `MapperFeature`, etc.) and
stripped their synthetic `values()` / `valueOf()` methods because nothing
in Amethyst's own bytecode calls them. Jackson does call them — but
reflectively, at `ObjectMapper` construction time, long after ProGuard
finished. The result is `Class.getEnumConstants()` returning null and the
JVM giving up.

The same regression silently broke VLC media playback (VLCj's
`ServiceLoader` lookup of `DiscoveryDirectoryProvider` impls printed
`ConfigDirConfigFileDiscoveryDirectoryProvider not found` because
ProGuard stripped both the META-INF/services file and the impl class).

Fix: add explicit `-keep` rules for the four reflection-heavy libraries
on the desktop classpath:

- Jackson (databind, core, annotations, module-kotlin): keep classes
  intact and keep `values()`/`valueOf()` on every Jackson enum.
- JNA (com.sun.jna.\*\*): used by VLCj and kmp-tor — its `Structure`
  field-order reflection requires field metadata to survive.
- VLCj (uk.co.caprica.vlcj.\*\*): `ServiceLoader`-based discovery providers
  and reflective binding classes throughout.
- SLF4J: detected by Jackson via `Class.forName`.

Verified by building `:desktopApp:createReleaseDistributable` and
launching the resulting bundle on macOS arm64:
  - No `ExceptionInInitializerError`
  - `VLC: bundled discovery succeeded`
  - `VLC: MediaPlayerFactory created successfully`

The same ProGuard pass runs on all four target formats (Dmg, Msi, Deb,
Rpm) so this single change fixes the regression on every desktop OS.
2026-05-16 09:13:56 +10:00
Vitor Pamplona 48592ae965 Merge pull request #2919 from greenart7c3/claude/fix-cache-server-url-IbSg8
Fix Blossom blob detection to reject non-compliant filenames
2026-05-15 19:10:22 -04:00
Claude 62d97c6d82 fix(blossom): require sha256 at start of last path segment
URLs like https://nostr.build/i/nostr.build_<sha>.jpg embed a 64-char
hex in the filename but aren't BUD-01 Blossom blobs — the last path
segment must be exactly <sha256> or <sha256>.<ext>. The previous regex
matched the embedded hash and rewrote the request to the local cache
with xs=https://nostr.build/i, which 404s on miss because the real blob
lives at /i/nostr.build_<sha>.jpg, not /i/<sha>.

Switch both extraction sites (MediaUrlContentExt and the OkHttp
interceptor) to a matchEntire regex that anchors the sha at the start
of the segment with at most a .<ext> suffix.
2026-05-15 22:32:01 +00:00
Vitor Pamplona 5a30b10a77 v1.09.1 2026-05-15 18:25:17 -04:00
Vitor Pamplona a04e7ddc84 Merge pull request #2917 from vitorpamplona/claude/modernize-git-issues-design-dzomX
Add Git repository detail screen with issues/patches tabs
2026-05-15 18:20:01 -04:00
Vitor Pamplona 0953862cfd Merge pull request #2918 from vitorpamplona/claude/fix-bottombar-reorder-icons-M57CG
Remove collectAsStateWithLifecycle from BottomBarSettingsContent
2026-05-15 18:19:42 -04:00
Claude 47e503a023 fix: ingest NIP-34 status, PR, repo-state and grasp-list events
LocalCache.consume's type switch only had branches for kinds 1617
(patch), 1621 (issue), 1622 (reply) and 30617 (repository). Every
other NIP-34 event kind fell through to the else branch and was
silently dropped with a "Event Not Supported" warning, even though
Quartz's EventFactory parses them all.

This affected:

- kinds 1630-1633 (status open/applied/closed/draft) — the status
  pills on the new Git Repository screen and on issue/patch cards
  could never populate, since the status events themselves never
  reached LocalCache.
- kind 1618 (pull request) — the Patches/PRs tab on the repository
  screen would never list PR events.
- kind 1619 (PR update) — couldn't be observed, even though Quartz
  models the parent-PR relationship.
- kind 30618 (repository state) — branch/tag pointers were dropped.
- kind 10317 (user grasp server list) — analog to NIP-65, dropped.

Wires each into the existing consumeRegularEvent /
consumeBaseReplaceable helpers so they land in the cache where the
screen filters, GitStatusIndex, and any future UI surfaces can see
them.
2026-05-15 22:02:31 +00:00
Claude 1151106cdf fix(bottom-bar-settings): stop reverting newly-pinned icons on drag
Replace `remember(pinned)` with a single `remember`, so the local
`items` MutableState isn't recreated each time the StateFlow emits.
Previously, when the user toggled an item ON, the resulting
recomposition swapped the local items state to a new MutableState
backed by `initialRows(newPinned)`. Drag-gesture callbacks captured
in earlier compositions kept writing to the old MutableState, and on
drag end they re-emitted a `pinned` list computed from that stale
state — dropping the just-pinned item.

Initialize once from `bottomBarItems.value` (already correct at first
composition) and let toggle / drag / restore-default be the only
writers. This makes the screen self-contained and removes the
position-vs-state race entirely.
2026-05-15 22:02:15 +00:00
Vitor Pamplona 0d90df2dbf Merge pull request #2916 from vitorpamplona/claude/fix-pulltorefresh-padding-eR5sf
Fix pull-to-refresh indicator positioning in DisappearingScaffold
2026-05-15 17:42:07 -04:00
Claude b9f98a4f5b refactor(feeds): collapse RefresheableBox overloads to a single PullToRefreshBox
The InvalidatableContent overload now delegates to the onRefresh overload,
so there is only one PullToRefreshBox/state-management path to maintain.

https://claude.ai/code/session_01MgnMaw8U83rXPSnoGF4KBY
2026-05-15 21:40:34 +00:00
Claude f52b3df4f8 fix: defer content subscription until repo event is loaded
When a Git Repository screen is opened via deep-link (naddr) the
addressable note exists but its event payload arrives later through
EventFinder. RepositoryContentSubAssembler.updateFilter checks
note.event and returns an empty filter when it isn't a
GitRepositoryEvent yet — and since ComposeSubscriptionManager only
re-invalidates on subscribe/unsubscribe, the now-stale empty filter
persists indefinitely, leaving the Issues/Patches tabs permanently
blank for cold-started repos.

Gate the subscription composable on event presence so the underlying
DisposableEffect fires fresh (running updateFilter again, this time
with the populated event) only after the repo event has loaded.
2026-05-15 21:31:32 +00:00
Claude 1d3c1edfea fix(feeds): offset PullToRefresh indicator below top bar
Inside a DisappearingScaffold the content fills the full screen height so
feeds can scroll behind the top bar. PullToRefreshBox anchors its indicator
at the top of the box, which placed it behind the top bar.

Read LocalDisappearingScaffoldPadding inside RefresheableBox and pass a
custom indicator with that top padding applied, so the spinner surfaces
just below the bar. Screens not inside a DisappearingScaffold see the
default 0dp padding and behave as before.

https://claude.ai/code/session_01MgnMaw8U83rXPSnoGF4KBY
2026-05-15 21:29:02 +00:00
Vitor Pamplona b6b1fb44d5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-15 17:22:31 -04:00
Claude 995741c2f1 chore: minor cleanups from audit round 3
- Drop two unused strings (git_repo_no_issues, git_repo_no_patches);
  the default FeedEmpty composable already provides a generic "Feed
  is empty" + refresh button which is sufficient.
- Import MaterialSymbol in GitRepositoryOverview so the LinkLine
  parameter type isn't a fully-qualified name, per CLAUDE.md style.
- Switch two vertical Spacer(Modifier.size(N.dp)) to
  Modifier.height(N.dp) — functionally identical but conveys intent.
2026-05-15 21:22:28 +00:00
Vitor Pamplona f38a27fc26 Merge pull request #2914 from vitorpamplona/claude/debug-desktop-release-83tpQ
Fix desktop app ProGuard build with Compose 1.11.0
2026-05-15 17:19:27 -04:00
Vitor Pamplona cfc86f1065 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-15 17:16:04 -04:00
Claude bfea285a6a fix: tighten GitStatusIndex initial-scan and pill warming UX
Two issues from a follow-up audit on the Git Repository screen.

- Move the initial cache scan into flow `onStart` so the
  newEventBundles collector is attached before scanning. Closes a small
  race window where status events emitted between scan completion and
  collect attachment would have been dropped.

- Distinguish "index warming up" from "no status exists" by typing the
  StateFlow as `Map?` (null until first scan completes). The pill now
  hides itself entirely while warming, instead of briefly rendering
  the `defaultIfMissing` fallback and then flipping to the real status.

Also drops the dead public `rememberLatestStatus` helper and the
unnecessary derivedStateOf scaffolding — at the scale this screen
operates (tens of pills), the straightforward map read is fine.
2026-05-15 21:14:16 +00:00