Compare commits

...
Author SHA1 Message Date
Vitor PamplonaandGitHub 3f1b8ec833 Merge pull request #3869 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 21:08:04 -04:00
vitorpamplonaandgithub-actions[bot] f4fc9917f8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-06 00:48:28 +00:00
Vitor PamplonaandGitHub 0c4ea9031b Merge pull request #3870 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
fix(ime): settle the keyboard in Nav so every screen stops stranding imePadding
2026-08-05 20:45:43 -04:00
Claude 72d05e2eb8 refactor(ime): drop KeyboardAwareBackHandler now that Nav settles
Its job was to stop a composer's pop from racing the IME close animation,
and that pop is `nav.popBack()` in all 11 call sites — exactly what the
ImeSettler on Nav now serializes. So it no longer carries the fix; a plain
BackHandler reaches the same place safely.

What it did still provide was the two-back convention: first back dismisses
the keyboard (via the system's own animation, which on recent Android follows
the gesture), second back leaves. That came at a price it did not used to
have. The mechanism is to NOT consume back while the keyboard is up and let
the IME consume it instead — so on any device or API level where the IME does
not, back reaches the NavController, which pops without ever running onBack
and silently drops the draft, since nothing else saves one. Now that Nav
settles, that failure would also be invisible: no stranded padding to hint at
it, just a missing draft.

A plain BackHandler has no such failure mode. It always consumes, so the
draft is always flushed, on the first back rather than the second.

KeyboardState.kt keeps keyboardAsState(), which is a separate concern —
AppBottomBar uses it to hide the bottom bar while typing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 23:58:24 +00:00
Claude bcbf78d8ea fix(ime): settle the keyboard in Nav so every screen is covered
Leaving a screen while the soft keyboard is still animating strands
`imePadding()` at keyboard height for the whole app — `WindowInsets.ime` is a
single shared holder, so the padding survives leaving the screen that caused
it. PR #3864 fixed this for the post composers, at their call sites. That was
the wrong altitude: Search strands it too, and Search has no BackHandler and
no top bar of ours.

Search is the clearest case: it focuses its field on arrival, so the keyboard
is up before the user has done anything, and every way out is a navigation — a
bottom-nav tab, a tapped result, back. Any destination that can focus a text
field can strand the padding on the way out. There are 174 files with text
input in this module; enumerating the screens was never going to converge.

Two facts make a central fix possible: every in-app navigation goes through
INav (there is not one `controller.navigate` outside navigation/navs/, and
nothing touches OnBackPressedDispatcher, navigateUp or popBackStack directly),
and every Nav method already runs inside `navigationScope.launch`. So Nav
awaits an ImeSettler before each transition: keyboard down, it returns
immediately and nothing changes; keyboard up, it clears focus, hides the IME
and waits for the inset to actually reach zero, bounded, so the two animations
never overlap. ObservableNav delegates to Nav and inherits it.

That subsumes #3864's call-site patches, so they are removed rather than left
as a second mechanism: ActionTopBar goes back to plain callbacks (which also
drops the composition-scoped deferral of onPost, so posting no longer depends
on the top bar staying composed), and KeyboardAwareBackHandler keeps only its
imeAnimationTarget gate — the part that stops back falling through and
silently dropping a draft. It is now a UX preference (let the system animate
the dismissal) rather than the safety mechanism.

NavImeSettleTest pins the ordering: each transition must settle before it
navigates, and a settler that suspends must hold the navigation back rather
than run alongside it. All four fail with the settle calls removed.

Known gap: on a screen with no BackHandler the system's back pops through the
NavController directly, not Nav.popBack(), so a second back landing inside the
~250ms retraction can still race. Closing it needs a shell-level handler
registered after the NavHost to outrank its back callback, which is a
composition-order dependency subtle enough to break silently — worth a
deliberate decision rather than smuggling in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 23:28:48 +00:00
Vitor PamplonaandClaude Opus 5 32331ecd3d Merge PR: feat(gitRepositories): add ngit-specific search on the Git Repositories screen
Merges nostr proposal 9142a140 (v2) into main:
- feat(gitRepositories): add ngit-specific search on the Git Repositories screen
- refactor(commons): move GitRepositorySearchMatcher to commons/search

Adds a client-side filter to the Git Repositories screen that searches the
fields NIP-34 kind:30617 announcements actually carry — name, `d` identifier,
description, hashtags, clone/web/relay URLs, maintainer and author pubkeys
(hex or npub), and the earliest-unique-commit hash. Before this, the only
search affordance on the screen navigated away to the generic Route.Search,
which matches people/notes/hashtags/channels but never repositories. A filter
icon toggles an inline text field over the feed; the generic search icon stays
beside it. Whitespace-separated terms are ANDed, matching is case-insensitive
substring, and `npub1…` queries are decoded to hex before matching.

The matcher itself lives in commons/search (per commons/ARCHITECTURE.md:
"event search filtering/ranking", non-UI and CLI-safe) rather than in
amethyst, since it is pure platform-agnostic Kotlin over a Quartz event type.
Desktop can reuse it when that screen grows the same affordance. Its test sits
in commons/commonTest on kotlin.test, so it also covers the iOS targets that
source set compiles for.

Verified before merge: :commons:jvmTest 15/15, :commons:verifyKmpPurity,
:commons:compileTestKotlinIosSimulatorArm64, :amethyst:compileFdroidDebugKotlin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:54:58 -04:00
Vitor PamplonaandGitHub 3353e591d0 Merge pull request #3868 from davotoula/fix/file-header-non-media-fallback
fix(media): stop rendering non-media NIP-94 files as video
2026-08-05 18:20:41 -04:00
Vitor PamplonaandClaude Opus 5 221214e545 refactor(commons): move GitRepositorySearchMatcher to commons/search
The matcher is pure platform-agnostic Kotlin over a Quartz event type —
no Compose, no Android, no platform APIs — so per the sharing philosophy
it belongs in commons rather than amethyst. commons/ARCHITECTURE.md
assigns "event search filtering/ranking" to the `search` package
(non-UI, CLI-safe), which is where it lands. The desktop Git
Repositories screen can now use the same matcher instead of growing its
own copy.

The test moves to commons/src/commonTest and swaps org.junit for
kotlin.test, matching every other test in that source set. That gains
iOS coverage for free, and keeps the source set compiling for the
native targets — commonTest is built for iosArm64/iosSimulatorArm64 too,
so a JUnit import there is a build break, not a style nit. The test
builds GitRepositoryEvent from raw tags and never signs, so it needs no
secp256k1 binding.

Also drops `filter()`. It had no caller — the screen filters the loaded
feed itself with `matches` — and its KDoc promised behaviour it never
implemented ("duplicate `d` tags collapse to the newest event"; it did
no deduplication at all). Better to delete the unused API than to ship
a dedup nobody asked for or a doc comment that lies. Its two tests go
with it; the empty-query contract the screen does rely on stays
covered.

Verified: :commons:jvmTest (15/15 in the new location),
:commons:compileTestKotlinIosSimulatorArm64, :commons:verifyKmpPurity,
:amethyst:compileFdroidDebugKotlin, spotlessApply — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:13:31 -04:00
mstrofnoneandVitor Pamplona ff9855aad6 feat(gitRepositories): add ngit-specific search on the Git Repositories screen
Adds a client-side filter for the Git Repositories page that only searches
fields relevant to ngit repository announcements (kind:30617 NIP-34):
repo name, `d` identifier, description, hashtags/topics, clone URLs, web
URLs, maintainer relays, maintainer/author pubkeys (accepting both hex and
`npub…` bech32 in the query), and the earliest-unique-commit hash.

Before this change, the only search affordance on the screen was the
generic Nostr search icon that navigated away to `Route.Search`, which
matches people, notes, hashtags, and channels — none of which are ngit
repositories. Users who wanted to find a repo they'd already discovered
had to scroll through the full follow-list-scoped feed.

UX:
- A filter icon in the top bar toggles an inline `OutlinedTextField`
  directly above the feed. First-appearance focus opens the keyboard
  without a second tap.
- The general search icon is preserved beside the filter icon so
  outbound searches still work.
- While filtering, results render with the same `NoteCompose` cells the
  feed uses so every affordance (bookmark, open, share) still works.
- Filtered rendering uses a scoped `LazyListState` because the item-key
  set of the filtered list is not stable against the feed's cached
  scroll offset; sharing them would jump the user to an unrelated repo.
- Closing the filter icon clears the query, restoring the full feed
  in one tap.

Filter semantics:
- Whitespace-separated terms are ANDed against each repo (`amethyst
  nostr` matches only repos that carry both terms in some indexed field).
- Case-insensitive substring match on each indexed field.
- `npub1…` queries are decoded to hex before matching, so a maintainer
  can be found by either encoding.

Tests: `GitRepositorySearchMatcherTest` (17 hermetic cases) pins every
indexed field, plus the "empty query returns nothing / filter blank
returns everything" contract that the caller relies on to skip the
filter path.

Build check: `./gradlew :amethyst:compileFdroidDebugKotlin
:amethyst:testFdroidDebugUnitTest --tests
'com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositorySearchMatcherTest'
:amethyst:spotlessCheck` all green.
2026-08-05 18:08:45 -04:00
Vitor PamplonaandClaude Opus 5 372964194d Merge PR: ci: publish windows-arm64 desktop + windows amy/geode release assets
Merges nostr proposal 3ac62492 (v2) into main:
- ci: publish windows-arm64 desktop + windows amy/geode release assets
- ci(release): build windows-arm64 desktop as a portable zip only

Extends the release matrix to Windows on Arm using the free public-repo
windows-11-arm runner, and adds Windows legs (x64 + arm64) to build-cli and
build-geode. amyImage / geodeImage now emit both a POSIX launcher and a
.bat launcher so the flat image layout is uniform regardless of build host;
collect_cli_assets / collect_geode_assets package it as .zip on Windows and
tar.gz everywhere else.

The arm64 desktop leg ships the portable .zip only — no MSI. jpackage
--type msi shells out to WiX 3's heat/candle/light and the Windows 11 Arm64
runner image has no WiX (windows-latest has WiX 3.14 preinstalled, which is
why the x64 leg still packages an MSI). Installing it would mean pulling an
archived, x86-only toolchain (wixtoolset/wix3 archived Feb 2025; WiX 4+
dropped the candle/light CLI jpackage drives) into the job that publishes
signed release assets.

BUILDING.md now enumerates the expected release assets per matrix leg
instead of carrying a stale total: 14 desktop + 13 Android + 10 amy +
10 geode = 47.

The arm64 runner legs themselves are first exercised by the next real
release run; nothing about them can be verified harder locally, since
jlink/jpackage cannot cross-compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:54:54 -04:00
Vitor PamplonaandClaude Opus 5 f3104f0a6c ci(release): build windows-arm64 desktop as a portable zip only
The windows-11-arm leg added by the previous commit runs
`packageReleaseMsi`, which cannot succeed on that runner: jpackage
--type msi shells out to WiX 3's heat.exe / candle.exe / light.exe
(JDK 21 jpackage guide names WiX 3.11.1), and the Windows 11 Arm64
runner image ships no WiX at all.

Verified against actions/runner-images:
  images/windows/Windows2025-Readme.md    -> "WiX Toolset 3.14.1.8722"
  images/windows/Windows11-Arm64-Readme.md -> no WiX entry
(7zip 26.02, Python 3.13 and Java 21 aarch64 ARE present on the arm64
image, so the rest of the leg — createReleaseDistributable, the 7z
portable zip, collect_assets — is unaffected.)

Installing WiX in the job instead was the alternative and is worse:
wixtoolset/wix3 was archived in Feb 2025, WiX 4+ replaced the
candle/light CLI that jpackage drives with `wix build`, and the WiX 3
binaries are x86-only (emulated on arm64). That would mean pulling an
archived, unpinned third-party toolchain into the job that publishes
signed release assets, for one asset we already ship in portable form.

So: arm64 Windows gets the portable .zip, which is already the
documented Windows install path for amy and geode. The x64 leg is
untouched and still produces the MSI.

collect_assets needs no change — it globs with nullglob and skips the
absent msi/ directory.

BUILDING.md: replace the release-verification asset count, which this
branch had left vague ("5 formats x 2 arches shipped as one merged set
of 5 ... see the previous release"), with a per-leg enumeration counted
off the matrix: 14 desktop + 13 Android + 10 amy + 10 geode = 47. Also
corrects the Windows prerequisites note, which claimed CI produces
arm64 MSIs natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:31:52 -04:00
mstrofnoneandVitor Pamplona cd2ce05ee8 ci: publish windows-arm64 desktop + windows amy/geode release assets
Follow-up to the linux-arm64 CI leg (feat/release-linux-arm64). Extends the
release matrix to Windows in three places, all on free public-repo hosted
GitHub runners:

* build-desktop: adds windows-11-arm (arm64) alongside the existing
  windows-latest (x64). jpackage/jlink on Windows arm64 produce arm64 MSIs
  natively; the same packageReleaseMsi + createReleaseDistributable task
  list is used unchanged and the portable-archive step already parameterises
  on ${{ matrix.arch }}.

* build-cli: adds windows-latest (x64) and windows-11-arm (arm64) legs
  running :cli:amyImage. Windows has no jpackageDeb/Rpm and MSI-for-CLI is
  deferred (portable zip is the documented Windows install path); the
  headless-lib assertion runs unchanged under git-bash. amyImage now emits
  both a POSIX `bin/amy` shell launcher AND a Windows `bin/amy.bat`
  launcher into the flat image so the tree layout is uniform regardless of
  build host. The .bat pins UTF-8 (chcp 65001) for sun.jnu.encoding, same
  reason the installDist .bat was already patched.

* build-geode: adds windows-latest + windows-11-arm legs running
  :geode:geodeImage. The existing --port smoke test is generalised to pick
  bin/geode.bat on Windows; NIP-11 fetch via curl works unchanged under
  git-bash on GH windows runners. Same dual-launcher pattern as amy.

scripts/asset-name.sh: collect_cli_assets and collect_geode_assets now
package the flat image as .zip on Windows (7z when available, falling
back to `zip`, then a portable python3 zipfile.ZipFile invocation). Every
other OS continues to use tar.gz. Adds the expected Windows examples to
the header block.

BUILDING.md: mentions the windows-11-arm runner and updates the asset
count in the Release runbook. No asset-naming contract changes — the
existing amethyst-desktop-<v>-windows-<arch>.<ext>, amy-<v>-windows-<arch>.zip,
and geode-<v>-windows-<arch>.zip shapes were already in scope, they just
weren't produced by any CI leg before.

Local validation on macOS arm64 (build host: JDK 21, gradle 9.5.0):
  ./gradlew :cli:amyImage     -> bin/amy + bin/amy.bat both present
  ./gradlew :geode:geodeImage -> bin/geode + bin/geode.bat both present
  ./bin/amy --help            -> parses (unix launcher unbroken)
  ./bin/geode --port 17447    -> NIP-11 served, "supported_nips" present
  collect_cli_assets windows arm64 ...   -> valid .zip with bin/amy.bat
  collect_geode_assets windows x64 ...   -> valid .zip with bin/geode.bat
  actionlint .github/workflows/create-release.yml   -> no new findings
  Cross-compile is impossible for jlink/jpackage, so end-to-end
  Windows-runtime validation still happens on GH CI on the first PR
  build; nothing in this change can be verified any harder locally.
2026-08-05 17:31:52 -04:00
Vitor PamplonaandClaude Opus 5 79d7d27198 Merge PR: ci(release): add libegl1 to arm64 .deb Depends
Merges nostr proposal 948bc249 into main:
- ci(release): add libegl1 to arm64 .deb Depends

The aarch64 skiko native (libskiko-linux-arm64.so) has libEGL.so.1 in
DT_NEEDED, unlike the x86_64 build which links only libGL.so.1. jpackage
generates deb Depends from dpkg-shlibdeps over the bundled JRE under
lib/runtime/ only, never the app payload under lib/app/, so the arm64 .deb
never listed libegl1 and Amethyst died at startup on minimal aarch64
installs with UnsatisfiedLinkError: libEGL.so.1.

scripts/add-deb-libegl-dep.sh rewrites the .deb after the fact — same
approach as the existing scripts/relax-deb-libicu.sh. It only touches
payloads that actually contain libskiko-linux-arm64.so and is idempotent;
the workflow step is gated on matrix.arch == 'arm64' so the x64 .deb is
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:31:25 -04:00
Vitor PamplonaandGitHub a02066a700 Merge pull request #3866 from davotoula/feat/configurable-left-drawer
Make the left drawer configurable
2026-08-05 17:04:18 -04:00
David KasparandGitHub fdbad9396f Merge pull request #3867 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 22:37:37 +02:00
davotoulaandgithub-actions[bot] 5c18cee6ad chore: sync Crowdin translations and seed translator npub placeholders 2026-08-05 20:29:50 +00:00
davotoula 3565f75847 Code review:
- gate the file-attachment card
- move prettyMime to commons
- add tests
2026-08-05 22:29:07 +02:00
davotoula 0cf1534861 fix(media): stop rendering non-media NIP-94 files as video
A kind-1063 file header was classified by a binary `isImage` test: anything
that wasn't an image fell through to MediaUrlVideo. A webxdc app
(application/x-webxdc, a zip) therefore reached ExoPlayer and buffered
forever, as did every archive, installer and — since MediaUrlPdf was never
constructed here — every NIP-94 PDF.
2026-08-05 22:28:29 +02:00
davotoula 64019dbd7c update cs,pt,de,sv 2026-08-05 22:21:47 +02:00
davotoula 93fe3ac727 Code review:
- fold the pickers onto shared row/expand-state UI
2026-08-05 21:30:38 +02:00
Claudeanddavotoula dda0f0d9e8 Make the left drawer configurable
style: apply spotless to the configurable drawer code
docs: record why the settings state holders are deliberately unkeyed
refactor: address cleanup review of the configurable drawer
fix: rename the shared picker section header to avoid an overload clash
2026-08-05 21:30:38 +02:00
Vitor PamplonaandGitHub 9373c0a22c Merge pull request #3861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 14:35:23 -04:00
Vitor PamplonaandGitHub a6f41072a9 Merge pull request #3863 from vitorpamplona/feat/suspend-subscription-onevent
Suspend the incoming-message chain down to SubscriptionListener.onEvent
2026-08-05 14:35:03 -04:00
vitorpamplonaandgithub-actions[bot] d9ea3ef86a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-05 18:33:02 +00:00
Vitor PamplonaandGitHub 673184dd7b Merge pull request #3864 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
Replace BackHandler with KeyboardAwareBackHandler in posting screens
2026-08-05 14:29:46 -04:00
Claude 8b4220019a fix(ime): close the two remaining stuck-padding paths
Two exits still popped a composer while the IME was mid-animation — the race
that strands imePadding() at keyboard height app-wide.

1. Top-bar X and Post. KeyboardAwareBackHandler only guards the back gesture;
   ActionTopBar wired both buttons straight to nav.popBack(), with nothing
   dismissing the keyboard first. Tapping either while typing reproduced the
   original bug exactly. The earlier fix leaned on the back arrow as the
   "always-available exit" without noticing it was also a race source.

2. A ~250ms hole in the back gate. It read the animated WindowInsets.ime,
   which stays above zero for the whole close animation — a window in which
   the IME had already stopped consuming back but the handler was still
   disabled, so a second back fell through to the NavController and popped
   without ever running onBack. That silently dropped the draft the handler
   exists to save: nothing else saves it, onCleared() only closes the writing
   assistant and there is no autosave.

Both are the same underlying requirement — serialize the IME and window
animations instead of overlapping them — so both now route through one
helper, rememberAfterKeyboardCloses(): keyboard down, the action runs inline
and nothing changes; keyboard up, clear focus, hide, wait for the inset to
actually reach zero, then act. The wait is bounded so a stale inset (the very
failure being guarded) can never trap the user on screen, and re-entrant calls
are dropped since the deferral widens the window for a double-tap on Post to
fire twice.

The back gate now reads WindowInsets.imeAnimationTarget, which flips to zero
the moment the hide begins, so back keeps reaching onBack throughout the
animation. Re-enabling that early means onBack can fire mid-animation, which
is exactly what the helper absorbs.

Not covered: this is verified by compile and the unit suite only. The race
reproduces on release builds on a device, which this environment cannot run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 17:40:03 +00:00
Vitor PamplonaandGitHub d6e78a7857 Merge pull request #3862 from vitorpamplona/fix/sync-coverage-per-kind-bands
SyncCoverage: one band interval cannot speak for several kinds
2026-08-05 13:32:14 -04:00
Vitor PamplonaandClaude Opus 5 a3fac4fc08 Suspend the incoming-message chain down to SubscriptionListener.onEvent
A consumer that cannot suspend has to block, and blocking here deadlocks
the whole client.

Measured on a mirror built against this library, twice, ~13 minutes after
each start: all 64 shared coroutine workers parked in `runBlocking` beneath
`trySendBlocking`, called from the websocket message callback. The consumer
draining that channel needed threads from the same pool to reach its store,
so it could never make room, so the producers never woke. Every stream, the
health reporter, all of it stopped, at 2% CPU with a healthy, idle backend.
A full queue was the symptom; producers eating the threads the drain needed
was the cause.

The coroutine context was already there — BasicOkHttpWebSocket has always
processed messages inside `scope.launch { for (message in incomingMessages) }`
— so the only thing forcing a blocking hand-off was that the hops in between
were declared non-suspend. Now they are not:

    WebSocketListener.onMessage
    RelayConnectionListener.onIncomingMessage
    PoolRequests/PoolCounts/PoolEventOutbox.onIncomingMessage
    SubscriptionListener.onEvent
    fetchAllPages / negentropy accessories' onEvent parameter

A consumer that fills its buffer now suspends and releases its thread rather
than holding it, which is the same reasoning BasicOkHttpWebSocket already
documents for keeping its own channel UNLIMITED so a slow consumer cannot
block OkHttp reader threads. This extends it one layer down.

BLE is the one transport whose callback genuinely cannot suspend — the
platform hands notifications to a plain callback — so BleNostrClient gets
the same treatment the websocket transport already had: an UNLIMITED
hand-off channel so the BLE stack is never blocked, drained by ONE coroutine
so message order survives the boundary.

Tests that drove these entry points directly now do so from `runTest`, or
from `runBlocking` where the call sits inside a raw thread or Runnable that
models a platform callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:45:02 -04:00
Claude 9ac033effc fix(ime): use the keyboard-aware back handler in the post composers
The thread reply composer (and every other full-screen draft-saving editor)
still consumed back with a raw `BackHandler`, so `KeyboardAwareBackHandler` —
added for exactly this case — only protected the three chat composers.

Popping the screen while the keyboard is still up races the predictive-back
window animation against the IME close animation. When the window animation
wins, the IME `WindowInsetsAnimationCompat` is cancelled before its terminal
zero frame reaches Compose, the shared `WindowInsets.ime` holder stays
"animating", and every `Modifier.imePadding()` freezes at keyboard height —
the keyboard vanishes but its padding stays behind, even after leaving
the screen.

Switching these composers to `KeyboardAwareBackHandler` lets the first back
(or back-swipe) fall through to the system, which dismisses the keyboard with
its own animation that completes cleanly; the next back saves the draft and
pops as before. The top bar's cancel arrow remains an always-available exit.

Covers `ShortNotePostScreen` (which also backs `PollPostScreen`),
`GenericCommentPostScreen`, `LongFormPostScreen`, `NewProductScreen`,
`NewPublicMessageScreen`, `NewGoalScreen`, `NewWorkoutScreen` and
`AwardBadgeScreen`. `VoiceReplyScreen` keeps the plain handler — it has no
text input or `imePadding()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 15:41:29 +00:00
mstrofnone f1d2bdee49 ci(release): add libegl1 to arm64 .deb Depends
The compose-desktop skiko native shipped under
${app}/lib/app/libskiko-linux-arm64.so declares libEGL.so.1 in
DT_NEEDED — the aarch64 skiko uses EGL alongside GLX, unlike the
x86_64 skiko which only links libGL.so.1.

jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
against the bundled JRE under lib/runtime/, NOT the app payload under
lib/app/. As a result the arm64 .deb produced by the newly-added
linux-arm64 CI leg lists libgl1/libglvnd0/libglx0 in Depends but not
libegl1. On minimal aarch64 installs — Armbian Server + a lightweight
WM, Raspberry Pi OS Lite + LXDE, or any distro base image without an
EGL implementation pulled in transitively — Amethyst desktop crashes
at startup with:

  Exception in thread "main" org.jetbrains.skiko.LibraryLoadException:
    Failed to loade library …/libskiko-linux-arm64.so
  Caused by: java.lang.UnsatisfiedLinkError:
    libEGL.so.1: cannot open shared object file: No such file or directory

Neither jpackage nor the Compose Multiplatform 1.11 DSL exposes a way
to add extra deb Depends, so we rewrite the .deb after the fact —
same approach as scripts/relax-deb-libicu.sh (which handles the
libicu SONAME divergence across Debian/Ubuntu releases).

scripts/add-deb-libegl-dep.sh only touches .debs whose payload
actually contains libskiko-linux-arm64.so, and is idempotent (skips
if libegl1 is already listed). The workflow step is gated on
matrix.arch == 'arm64' so the x64 .deb is untouched (its skiko does
NOT NEED libEGL and its GLX-only path stays as-is).

Local validation on Apple Silicon (native linux/arm64 in Docker):
  1. Rebuilt v1.13.1 arm64 .deb from a110ce0a30's CI leg.
  2. readelf -d libskiko-linux-arm64.so | grep NEEDED
     → confirms libEGL.so.1
  3. Ran scripts/add-deb-libegl-dep.sh over the .deb; Depends line
     now ends `..., zlib1g, libegl1`. Idempotent on re-run.
  4. `apt-get install -y -f ./amethyst_*.deb` in a base
     eclipse-temurin:21-jdk-noble aarch64 container (which lacks
     libegl1 by default) now pulls libegl1 as a dep.
  5. Amethyst launches under Xvfb, `xwininfo -root -tree` shows the
     1200×800 "Amethyst" window + Content window + sun-awt-X11-XCanvasPeer
     Skia canvas. No UnsatisfiedLinkError.
2026-08-05 15:56:29 +10:00
181 changed files with 4429 additions and 1335 deletions
+65 -10
View File
@@ -44,11 +44,26 @@ jobs:
matrix:
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
# ubuntu-24.04-arm label is a standard free public-repo runner as of
# early 2025). jpackage / jlink / Compose Multiplatform 1.11 all
# produce host-native artifacts — no cross-compilation needed.
# early 2025). Windows arm64 uses windows-11-arm, added to the free
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
# jpackage / jlink / Compose Multiplatform 1.11 all produce
# host-native artifacts — no cross-compilation needed.
#
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
# windows-11-arm runner image ships no WiX (the windows-latest image
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
# MSI). Installing it here would mean pulling an archived, x86-only
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
# the candle/light CLI that JDK 21's jpackage requires) into the job
# that publishes signed release assets. The portable zip is the
# documented Windows install path for amy/geode already, so arm64
# Windows users get that until either the runner image gains WiX or
# jpackage learns the WiX 4+ CLI.
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
@@ -221,6 +236,20 @@ jobs:
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
# against the bundled JRE under lib/runtime/, NOT the app payload under
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
# install without EGL crashes at startup with:
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
- name: Add libegl1 dep to arm64 .deb
if: matrix.family == 'linux' && matrix.arch == 'arm64'
run: |
set -euo pipefail
chmod +x scripts/add-deb-libegl-dep.sh
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Build portable archives (windows + linux-portable)
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
run: |
@@ -355,9 +384,17 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
# and jpackageMsi for a CLI is deferred (the portable zip is the
# documented Windows install path). The launcher script writes both
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
# zips the image on Windows instead of tar.gz.
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -605,9 +642,16 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
# deferred (portable zip covers the primary use — operators still
# deploy geode via the Docker image or the tarball on Linux). The
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
# smoke test below runs under bash on the windows runner.
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -662,12 +706,23 @@ jobs:
# module list is complete for the real relay path (Ktor CIO + SQLite +
# NIP-11 serialization) — a too-tight module list links fine but fails
# here with NoClassDefFound instead of on an operator's machine.
#
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
# tmp path also differs between git-bash on Windows (which resolves /tmp
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
# because the workflow's `defaults.run.shell: bash` uses git-bash on
# Windows and /tmp is a valid mingw path there.
- name: Smoke-test the geode image
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
"$IMG/bin/geode" --version
"$IMG/bin/geode" --port 17447 &
if [[ "${{ matrix.family }}" == "windows" ]]; then
LAUNCHER="$IMG/bin/geode.bat"
else
LAUNCHER="$IMG/bin/geode"
fi
"$LAUNCHER" --version
"$LAUNCHER" --port 17447 &
PID=$!
ok=0
for i in $(seq 1 20); do
+25 -7
View File
@@ -35,7 +35,12 @@ All platforms:
Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
themselves are x86 and run under emulation).
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
`flatpak-builder` for the Flatpak bundle (see
@@ -328,14 +333,27 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
4. **Verify** — the GH Release should hold **31 assets**:
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
configured, so macOS ships arm64-only.
4. **Verify** — the GH Release should hold **47 assets**:
- **14 desktop**, one per matrix leg × format:
- macOS arm64: `dmg` (1)
- Windows x64: `msi` + portable `zip` (2)
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
- Linux x64 / arm64: `deb` + `rpm` (4)
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
and no Intel runner leg is configured, so macOS ships arm64-only.
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
jpackage learns the WiX 4+ `wix build` CLI.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **5 amy** + **5 geode** bundles.
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
- **10 geode** — same shape as amy.
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Android flow unchanged
@@ -150,6 +150,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
@@ -955,6 +956,9 @@ class Account(
*/
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
/** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */
fun applyHiddenDrawerItems(items: Set<NavBarItem>): Boolean = settings.changeHiddenDrawerItems(items)
suspend fun toggleChatroomPin(room: ChatroomKey) {
settings.toggleChatroomPin(room)
sendNewAppSpecificData()
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -501,6 +503,18 @@ class AccountSettings(
return false
}
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
// Settings as hidden, which would leave no route back to the screen that hides rows.
val sanitized = DrawerItemVisibility.sanitize(newItems)
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
saveAccountSettings()
return true
}
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
val navigation =
AccountNavigationPreferences(
MutableStateFlow(internalSettings.navigation.bottomBarItems),
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
)
fun toInternal(): AccountSyncedSettingsInternal =
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
.map { it.id }
.sorted(),
),
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
navigation =
AccountNavigationPreferencesInternal(
navigation.bottomBarItems.value,
navigation.hiddenDrawerItems.value.toNames(),
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
if (navigation.bottomBarItems.value != newBottomBarItems) {
navigation.bottomBarItems.tryEmit(newBottomBarItems)
}
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
@Stable
class AccountNavigationPreferences(
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
)
@Stable
@@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal(
// favorite apps, and individual joined chats/groups). Defaulted so blobs
// written before this field existed decode to the app's current defaults.
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
// Empty by default, which is what makes a newly shipped destination visible
// to everyone without a migration — see DrawerItemVisibility.
//
// Stored as strings rather than the enum on purpose: an id written by a
// newer client would fail the enum decoder and take the whole synced-settings
// blob down with it, so unknown names are dropped on read instead (the same
// approach AccountPoWPreferencesInternal.enabledCategories takes).
var hiddenDrawerItems: List<String> = emptyList(),
)
@Serializable
@@ -80,7 +80,7 @@ class NappletLiveSubscriptions {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
}
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -78,7 +78,7 @@ class NotifyCoordinator(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
newEose(TimeUtils.now(), relay, forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.giftWrapHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -74,7 +74,7 @@ class UserWatcherSubAssembler(
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
private val clientListener =
object : RelayConnectionListener {
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -48,7 +48,7 @@ class RelayUsageListener(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
/**
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
* returns null for.
*
* It exists so those files have somewhere to land other than the video player: an unknown blob
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
* which would try to download the blob just to rediscover the type the event already declared.
*/
@Composable
fun FileAttachmentCard(
url: String,
description: String?,
mimeType: String?,
sizeInBytes: Long?,
) {
val uriHandler = LocalUriHandler.current
val filename = remember(url) { extractFilename(url) }
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.fillMaxWidth()
.clickable { uriHandler.openUri(url) },
) {
FileAttachmentRow(
symbol = MaterialSymbols.AttachFile,
// The alt/content text names the file for a human ("Webxdc app: Quake");
// the hashed URL basename is the fallback when the event omits it.
title = description?.ifBlank { null } ?: filename,
subtitle = subtitle,
titleMaxLines = 2,
)
Spacer(modifier = DoubleVertSpacer)
}
}
/**
* The icon + title + subtitle row shared by every card that stands in for a file it can't
* render inline: this one and the PDF placeholder/skeleton in
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
*/
@Composable
internal fun FileAttachmentRow(
symbol: MaterialSymbol,
title: String,
subtitle: String?,
titleMaxLines: Int = 1,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
private fun fileSubtitle(
mimeType: String?,
sizeInBytes: Long?,
): String? =
listOfNotNull(
mimeType?.ifBlank { null }?.let(::prettyMime),
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
).joinToString(" · ").ifEmpty { null }
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
private fun FilenameRow(
filename: String,
subtitle: String,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
) = FileAttachmentRow(
symbol = MaterialSymbols.PictureAsPdf,
title = filename,
subtitle = subtitle,
)
private fun renderFirstPage(
file: java.io.File,
@@ -263,6 +263,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
@@ -578,6 +579,7 @@ fun BuildNavigation(
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
@@ -20,13 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
}
}
}
/**
* A [BackHandler] that steps aside while the soft keyboard is on screen.
*
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
* When that pop happens while the keyboard is still up, it races the predictive-back window
* animation against the IME's close animation. On release builds — fast enough that the window
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
* screen).
*
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
*/
@Composable
fun KeyboardAwareBackHandler(
enabled: Boolean = true,
onBack: () -> Unit,
) {
val keyboardState by keyboardAsState()
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import android.os.Build
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.serialization.Serializable
/**
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
* Order in this enum has no semantic meaning.
*/
@Serializable
enum class NavBarItem {
@@ -84,6 +84,18 @@ enum class NavBarItem {
FAVORITE_ALGO_FEEDS,
}
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
/**
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
*/
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
data class NavBarItemDef(
val id: NavBarItem,
val labelRes: Int,
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
// Ordered membership lists for each drawer section. The drawer renders these by looking up
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
// matching section list below — not two separate files.
val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
/**
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
*/
data class NavBarCategory(
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
)
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarCategory(
R.string.bottom_bar_category_main,
MaterialSymbols.Home,
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_chats,
MaterialSymbols.Group,
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_you,
MaterialSymbols.AccountCircle,
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_feeds,
MaterialSymbols.Subscriptions,
listOf(
NavBarItem.ARTICLES,
NavBarItem.LONGS,
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_apps,
MaterialSymbols.Apps,
listOf(
NavBarItem.BROWSER,
NavBarItem.FAVORITE_APPS,
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_other,
MaterialSymbols.Settings,
listOf(
NavBarItem.SETTINGS,
),
),
)
val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
@@ -584,42 +582,17 @@ fun ListContent(
accountViewModel: AccountViewModel,
nav: INav,
) {
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
// Side Menu settings screen. Empty (the default) means the full stock drawer.
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
Column(modifier) {
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
CollapsibleSection(title = R.string.drawer_section_create) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
CollapsibleSection(title = R.string.drawer_section_system) {
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
NavBarCatalog[NavBarItem.SETTINGS]?.let {
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
DrawerSections.forEach { section ->
// Keyed by section: hiding the last row of a section removes it from the drawer
// entirely, and without a key the sections below would slide up into its slots and
// inherit its CollapsibleSection expanded/collapsed state.
key(section.id) {
CatalogSection(section, hidden, accountViewModel, nav)
}
}
@@ -634,22 +607,64 @@ fun ListContent(
}
}
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
@Composable
private fun CreateRows(nav: INav) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
/**
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
* Profile gets the primary-colored tint; every other item uses onBackground.
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
*
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
* System carries the relay-status row (not a catalog destination — it shows a live counter).
*/
@Composable
fun CatalogSection(
titleRes: Int,
ids: List<NavBarItem>,
section: DrawerSection,
hidden: Set<NavBarItem>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val primary = MaterialTheme.colorScheme.primary
val onBackground = MaterialTheme.colorScheme.onBackground
CollapsibleSection(title = titleRes) {
ids.forEach { id ->
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
if (visible.isEmpty() && !section.hasFixedRows) return
CollapsibleSection(title = section.titleRes) {
when (section.id) {
DrawerSectionId.CREATE -> CreateRows(nav)
DrawerSectionId.SYSTEM ->
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
else -> {}
}
visible.forEach { id ->
NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
if (def.id == NavBarItem.SCHEDULED_POSTS) {
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* Which drawer rows the user cannot hide.
*
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
* so it is present by construction and never appears in the hidden set.
*/
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
/**
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
*
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
* account's drawer at the moment they first touched the setting, and every later release would have
* to migrate saved lists to introduce a screen.
*/
object DrawerItemVisibility {
fun isVisible(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Boolean = item in MandatoryDrawerItems || item !in hidden
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
fun toggle(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Set<NavBarItem> =
when {
item in MandatoryDrawerItems -> hidden
item in hidden -> hidden - item
else -> hidden + item
}
/**
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
* it runs on decode, on an external sync, and on every write — so a value synced from another
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
*
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
* preserving it means editing the drawer on that device doesn't silently clear the choice the
* user made on another one.
*/
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
/** The rows of [section] to render, in the section's fixed order. */
fun visibleItems(
section: DrawerSection,
hidden: Set<NavBarItem>,
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
fun hiddenCount(
section: DrawerSection,
hidden: Set<NavBarItem>,
): Int = section.items.count { !isVisible(hidden, it) }
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
fun hideAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
/** Shows every row of [section] again. */
fun showAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden - section.items.toSet()
/** Total hidden rows across every section — the count the settings screen shows at the top. */
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import android.os.Build
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* The drawer's layout: which destinations it lists, under which heading, in which order.
*
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
* filed into exactly one section.
*
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
* which rows are visible — see [DrawerItemVisibility].
*/
@Immutable
data class DrawerSection(
val id: DrawerSectionId,
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
/**
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
*/
val hasFixedRows: Boolean = false,
)
/**
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
* compile error and nothing to fail a test.
*/
enum class DrawerSectionId {
YOU,
NAVIGATE,
FEEDS,
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
CREATE,
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
SYSTEM,
}
private val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
private val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
private val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
val DrawerSections: List<DrawerSection> =
listOf(
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
)
/**
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
* missing, and fails on anything else — that's what keeps a newly added destination from silently
* skipping both the drawer and its settings screen.
*/
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** How long to wait for the IME inset to reach zero before navigating anyway. */
const val IME_SETTLE_TIMEOUT_MS = 700L
/**
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
* in the app serializes the IME and window animations instead of overlapping them.
*
* Navigating while the keyboard is up races the window animation against the IME's close animation.
* On release builds — fast enough that the window animation wins — the IME
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
* freezes at the keyboard height until some later inset pass happens to rebalance it.
*
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
* has done anything, and every way out of it is a navigation.
*/
fun interface ImeSettler {
suspend fun settle()
companion object {
/** For [EmptyNav] and previews, where there is no window to read insets from. */
val None = ImeSettler { }
}
}
/**
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
* and the padding can never disagree about whether the keyboard is gone.
*
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
* guards against, navigation still proceeds rather than stranding the user on the screen.
*/
@Composable
fun rememberImeSettler(): ImeSettler {
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
return remember(density, imeInsets, keyboard, focusManager) {
ImeSettler {
if (imeInsets.getBottom(density) > 0) {
focusManager.clearFocus(true)
keyboard?.hide()
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
}
}
}
}
}
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
class Nav(
val controller: NavHostController,
override val navigationScope: CoroutineScope,
/**
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
* through this class, so this is the one place that has to get it right — no screen, top bar
* or back handler needs to think about the keyboard on its way out.
*/
private val ime: ImeSettler = ImeSettler.None,
) : INav {
override val drawerState = DrawerState(DrawerValue.Closed)
@@ -63,6 +70,7 @@ class Nav(
override fun nav(route: Route) {
navigationScope.launch {
ime.settle()
if (getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
}
@@ -71,6 +79,7 @@ class Nav(
override fun nav(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
val route = computeRoute()
if (route != null && getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
@@ -80,6 +89,7 @@ class Nav(
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(route) {
inclusive = true
@@ -91,6 +101,7 @@ class Nav(
override fun navBottomBar(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
// Clear sibling bottom-nav entries but keep Home (the start
// destination) below, so back-swipe from any tab returns to
@@ -149,6 +160,7 @@ class Nav(
override fun popBack() {
navigationScope.launch {
ime.settle()
controller.navigateUp()
}
}
@@ -159,6 +171,7 @@ class Nav(
klass: KClass<T>,
) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(klass) { inclusive = true }
}
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
fun rememberNav(): Nav {
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val ime = rememberImeSettler()
return remember(navController, scope) {
Nav(navController, scope)
return remember(navController, scope, ime) {
Nav(navController, scope, ime)
}
}
@@ -457,6 +457,8 @@ sealed class Route {
@Serializable object BottomBarSettings : Route()
@Serializable object DrawerSettings : Route()
@Serializable object HomeTabsSettings : Route()
@Serializable object ProfileUiSettings : Route()
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val mimeType = remember(note) { event.mimeType() }
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
val content: BaseMediaContent =
remember(note) {
val blurHash = event.blurhash()
val thumbHash = event.thumbhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
}
}
// The sensitivity gate wraps both branches: a content warning is about the file, not about
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
if (content == null) {
FileHeaderAttachmentCard(event, fullUrl, mimeType)
} else {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
}
}
/**
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
*
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
* video event the kind itself asserts nothing about how to render the payload. A null here means
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
*/
internal fun FileHeaderEvent.toMediaContent(
note: Note,
url: String,
mimeType: String?,
): BaseMediaContent? {
val blurHash = blurhash()
val thumbHash = thumbhash()
val hash = hash()
val dimensions = dimensions()
val description = fileDescription()
val uri = note.toNostrUri()
return when (RichTextParser.classifyMedia(url, mimeType)) {
MediaContentKind.IMAGE ->
MediaUrlImage(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.VIDEO ->
MediaUrlVideo(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.PDF ->
MediaUrlPdf(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
null -> null
}
}
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
@Composable
internal fun FileHeaderAttachmentCard(
event: FileHeaderEvent,
url: String,
mimeType: String?,
) {
val description = remember(event) { event.fileDescription() }
val sizeInBytes = remember(event) { event.size()?.toLong() }
FileAttachmentCard(
url = url,
description = description,
mimeType = mimeType,
sizeInBytes = sizeInBytes,
)
}
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
}
}
internal fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
internal fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -88,7 +89,9 @@ fun VideoDisplay(
val content: BaseMediaContent =
remember(note) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
val uri = note.toNostrUri()
if (isImage) {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
val content by
remember(note) {
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -1986,6 +1987,15 @@ class AccountViewModel(
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
if (account.applyHiddenDrawerItems(items)) {
launchSigner { account.sendNewAppSpecificData() }
}
}
fun changeBottomBarItems(items: List<BottomBarEntry>) {
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
@@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler(
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler(
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler(
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,7 +78,7 @@ fun EditFieldRow(
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
@@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.nip04History
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -20,11 +20,36 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -34,8 +59,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
@Composable
fun GitRepositoriesScreen(
@@ -59,10 +93,31 @@ fun GitRepositoriesScreen(
WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel)
GitRepositoriesFilterAssemblerSubscription(accountViewModel)
// Search UI state is remembered across configuration changes so the
// user doesn't lose their query when rotating; scoped to this screen,
// not persisted to disk (unlike the follow-list filter above).
var isSearchOpen by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
GitRepositoriesTopBar(accountViewModel, nav)
GitRepositoriesTopBar(
isSearchOpen = isSearchOpen,
onToggleSearch = {
// Closing collapses the field AND clears the query so
// the feed is fully restored — the icon acts as a
// one-tap "reset" once the user has narrowed the view.
if (isSearchOpen) {
searchQuery = ""
isSearchOpen = false
} else {
isSearchOpen = true
}
},
accountViewModel = accountViewModel,
nav = nav,
)
},
bottomBar = {
AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route ->
@@ -75,20 +130,166 @@ fun GitRepositoriesScreen(
},
accountViewModel = accountViewModel,
) {
RefresheableBox(gitRepositoriesFeedContentState, true) {
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = gitRepositoriesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
Column(Modifier.fillMaxSize()) {
if (isSearchOpen) {
GitRepositorySearchField(
query = searchQuery,
onQueryChange = { searchQuery = it },
onClearQuery = { searchQuery = "" },
)
HorizontalDivider(thickness = DividerThickness)
}
RefresheableBox(gitRepositoriesFeedContentState, true) {
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
val query = searchQuery
if (query.isBlank()) {
RenderFeedContentState(
feedContentState = gitRepositoriesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
)
} else {
// When the filter is active we can't reuse the shared
// scroll state because the filtered list has a different
// set of item keys — using the same LazyListState would
// make Compose try to restore an index that no longer
// exists and jump the user to an unrelated repo. We
// scope a fresh, per-query LazyListState so scrolling
// stays inside the filtered view.
RenderFilteredFeed(
feedContentState = gitRepositoriesFeedContentState,
query = query,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
/**
* Inline text field that drives the client-side ngit-repository search. Sits
* directly under the top bar and above the feed so the user can see the
* result of every keystroke narrow the list beneath it.
*/
@Composable
private fun GitRepositorySearchField(
query: String,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Focus on first appearance so the keyboard opens without a second
// tap. Subsequent recompositions inside the same session don't re-
// request focus, which would fight with the user pressing "back to
// the feed" via the field's clear-text icon.
focusRequester.requestFocus()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = {
Text(
text = stringRes(R.string.git_repositories_search_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = onClearQuery) {
ClearTextIcon()
}
}
},
singleLine = true,
)
}
}
/**
* Renders the ngit repositories the user is already subscribed to, filtered
* by [query]. Loading and error states are delegated to the shared
* [RenderFeedContentState] via the appropriate branches; the loaded branch
* is intercepted so we can filter the notes without touching the shared
* feed model (which other screens also observe).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RenderFilteredFeed(
feedContentState: FeedContentState,
query: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val filteredListState = rememberLazyListState()
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = filteredListState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
onLoaded = { loaded ->
val loadedItems by loaded.feed.collectAsStateWithLifecycle()
val filtered =
remember(loadedItems, query) {
loadedItems.list.filter { note ->
val event = note.event as? GitRepositoryEvent ?: return@filter false
GitRepositorySearchMatcher.matches(event, query)
}
}
if (filtered.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
) {
Text(
text = stringRes(R.string.git_repositories_search_no_results),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = filteredListState,
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(
filtered,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = "GitRepositoriesFeed",
isBoostedNote = false,
isHiddenFeed = loadedItems.showHidden,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
},
)
}
@Composable
fun WatchAccountForGitRepositoriesScreen(
gitRepositoriesFeedContentState: FeedContentState,
@@ -20,35 +20,105 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Top bar for the ngit repositories discovery screen.
*
* Two search affordances live side-by-side in the actions row:
*
* 1. A **repository filter** (magnifier-with-a-plus icon) that toggles
* an inline text field over the loaded feed. This is the ngit-specific
* search — it matches the fields NIP-34 announcements carry: name,
* identifier, description, hashtags, clone/web/relay URLs, and
* maintainer pubkeys. It filters what the user is already looking
* at without touching relays.
*
* 2. The **generic Nostr search** (plain magnifier) that navigates to
* the global [Route.Search] screen, matching the affordance on
* every other top-level screen.
*
* Splitting them this way makes it obvious which magnifier does what: the
* inline one narrows the current list, the outbound one opens the fleet-
* wide search that also queries people, notes, hashtags, etc.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GitRepositoriesTopBar(
isSearchOpen: Boolean,
onToggleSearch: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
.collectAsStateWithLifecycle()
ShorterTopAppBar(
title = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
.collectAsStateWithLifecycle()
GitRepositoriesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
)
}
GitRepositoriesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
)
}
},
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
actions = {
IconButton(onClick = onToggleSearch) {
Icon(
symbol =
if (isSearchOpen) {
MaterialSymbols.Close
} else {
MaterialSymbols.FilterAlt
},
contentDescription =
stringRes(
if (isSearchOpen) {
R.string.git_repositories_search_close
} else {
R.string.git_repositories_search_open
},
),
)
}
IconButton(onClick = { nav.nav(Route.Search) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
}
},
)
}
@Composable
@@ -456,7 +456,7 @@ class EventSync(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
@@ -50,7 +45,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -60,7 +54,6 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -97,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
@@ -116,11 +109,6 @@ private val ExpandableItems =
/** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */
private const val RECOMMENDED_SLOTS = 5
// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility
// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left).
private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun BottomBarSettingsScreenPreview() {
@@ -157,14 +145,21 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
// All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and
// forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds
// when the saved list changes elsewhere without clobbering a drag.
//
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
// holder that outlived an account switch would write account A's edits to account B. It cannot:
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } }
LaunchedEffect(savedItems) { state.syncFrom(savedItems) }
val pinned = state.pinned
val pinnedKeys = remember(pinned) { state.pinnedKeys() }
val expandedCategories = remember { mutableStateMapOf<Int, Boolean>() }
val expandedItems = remember { mutableStateMapOf<NavBarItem, Boolean>() }
val expandedCategories = rememberExpandedKeys<Int>()
val expandedItems = rememberExpandedKeys<NavBarItem>()
Column(
modifier =
@@ -177,26 +172,19 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
// --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. ---
EditableBarCard(state, pinned, accountViewModel)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { state.restoreDefault() }) {
Text(stringRes(R.string.bottom_bar_settings_restore_default))
}
}
RestoreDefaultRow(onClick = { state.restoreDefault() })
Spacer(Modifier.height(4.dp))
// --- Available catalogue, grouped into collapsible category cards. ---
SectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
PickerSectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
BottomBarCategories.forEach { category ->
CategoryCard(
category = category,
pinnedKeys = pinnedKeys,
expanded = expandedCategories[category.titleRes] ?: false,
onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) },
expanded = expandedCategories.isExpanded(category.titleRes),
onToggleExpand = { expandedCategories.toggle(category.titleRes) },
expandedItems = expandedItems,
accountViewModel = accountViewModel,
onTogglePin = state::togglePin,
@@ -217,60 +205,43 @@ private fun EditableBarCard(
pinned: List<BottomBarEntry>,
accountViewModel: AccountViewModel,
) {
val accent = MaterialTheme.colorScheme.primary
Surface(
shape = RoundedCornerShape(22.dp),
color = accent.copy(alpha = 0.07f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
) {
Column(Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.bottom_bar_settings_pinned),
style = MaterialTheme.typography.labelMedium,
color = accent,
fontWeight = FontWeight.Bold,
)
Text(
text = "${pinned.size} / $RECOMMENDED_SLOTS",
style = MaterialTheme.typography.labelMedium,
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent,
fontWeight = FontWeight.Bold,
)
}
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth(),
) {
if (pinned.isEmpty()) {
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.bottom_bar_settings_pinned_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
} else {
EditableBar(state, pinned, accountViewModel)
}
}
PickerHeroCard(
title = stringRes(R.string.bottom_bar_settings_pinned),
trailing = {
Text(
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
text = "${pinned.size} / $RECOMMENDED_SLOTS",
style = MaterialTheme.typography.labelMedium,
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
},
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth(),
) {
if (pinned.isEmpty()) {
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.bottom_bar_settings_pinned_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
} else {
EditableBar(state, pinned, accountViewModel)
}
}
Text(
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@@ -466,76 +437,37 @@ private fun CategoryCard(
pinnedKeys: Set<String>,
expanded: Boolean,
onToggleExpand: () -> Unit,
expandedItems: SnapshotStateMap<NavBarItem, Boolean>,
expandedItems: ExpandedKeys<NavBarItem>,
accountViewModel: AccountViewModel,
onTogglePin: (BottomBarEntry) -> Unit,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
CatalogCard(
icon = category.icon,
title = stringRes(category.titleRes),
expanded = expanded,
onToggleExpand = onToggleExpand,
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(34.dp)
.clip(RoundedCornerShape(11.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
category.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val entry = BottomBarEntry.BuiltIn(item)
if (item in ExpandableItems) {
ExpandableAvailableRow(
icon = def.icon,
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
expanded = expandedItems.isExpanded(item),
onTogglePin = { onTogglePin(entry) },
onToggleExpand = { expandedItems.toggle(item) },
) {
Icon(
symbol = categoryIcon(category.titleRes),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
}
Text(
text = stringRes(category.titleRes),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
} else {
AvailableRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
Column(Modifier.padding(bottom = 6.dp)) {
category.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val entry = BottomBarEntry.BuiltIn(item)
if (item in ExpandableItems) {
ExpandableAvailableRow(
icon = def.icon,
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
expanded = expandedItems[item] ?: false,
onTogglePin = { onTogglePin(entry) },
onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) },
) {
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
}
} else {
AvailableRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
)
}
}
}
}
}
}
@@ -757,18 +689,6 @@ private fun ConcordServerPickerGroup(
// Rows & shared bits
// ------------------------------------------------------------------------------------------------
/**
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
* NIP-29 group under its relay, or a Concord channel under its community).
*/
private fun indentPadding(level: Int) =
when (level) {
0 -> 13.dp
1 -> 24.dp
else -> 40.dp
}
@Composable
private fun AvailableRow(
leading: @Composable () -> Unit,
@@ -777,23 +697,12 @@ private fun AvailableRow(
onToggle: () -> Unit,
indentLevel: Int = 0,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
CatalogRow(
leading = leading,
label = label,
onToggle = onToggle,
indentLevel = indentLevel,
) {
leading()
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
AddPill(added = pinned, onClick = onToggle)
}
}
@@ -808,27 +717,15 @@ private fun ExpandableAvailableRow(
onToggleExpand: () -> Unit,
children: @Composable () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpand)
.padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
CatalogRow(
leading = { LeadingGlyph(icon) },
label = label,
onToggle = onToggleExpand,
) {
LeadingGlyph(icon)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = stringRes(R.string.bottom_bar_settings_expand),
modifier = Modifier.size(22.dp),
modifier = Size22Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
AddPill(added = pinned, onClick = onTogglePin)
@@ -838,52 +735,18 @@ private fun ExpandableAvailableRow(
}
}
/**
* Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states
* share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows
* stay aligned whether an item is added or not.
*/
/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */
@Composable
private fun AddPill(
added: Boolean,
onClick: () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
val content = if (added) MaterialTheme.colorScheme.onPrimary else accent
Surface(
shape = CircleShape,
color = if (added) accent else Color.Transparent,
border = if (added) null else BorderStroke(1.dp, accent),
) {
Row(
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = content,
)
Text(
text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
style = MaterialTheme.typography.labelLarge,
color = content,
)
}
}
}
/** A category/destination glyph in a soft accent-tinted circle. */
@Composable
private fun LeadingGlyph(icon: MaterialSymbol) {
Box(
modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary)
}
TogglePill(
on = added,
label = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
icon = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
onClick = onClick,
)
}
/** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */
@@ -902,40 +765,6 @@ private fun FavoriteLeading(app: FavoriteApp) {
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp),
)
}
@Composable
private fun EmptyChildHint(
textRes: Int,
indentLevel: Int = 1,
) {
Text(
text = stringRes(textRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp),
)
}
private fun categoryIcon(titleRes: Int): MaterialSymbol =
when (titleRes) {
R.string.bottom_bar_category_main -> MaterialSymbols.Home
R.string.bottom_bar_category_chats -> MaterialSymbols.Group
R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle
R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions
R.string.bottom_bar_category_apps -> MaterialSymbols.Apps
else -> MaterialSymbols.Settings
}
// ------------------------------------------------------------------------------------------------
// Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar).
// Computed once so a group's channel is subscribed at most once per row.
@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun DrawerSettingsScreenPreview() {
ThemeComparisonRow {
DrawerSettingsScreen(
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun DrawerSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.drawer_settings), nav)
},
) { padding ->
Column(Modifier.padding(padding)) {
DrawerSettingsContent(accountViewModel)
}
}
}
/**
* Show/hide editor for the side menu's rows. It renders [DrawerSections] directly — the very list the
* drawer renders — so a destination added to a section shows up here with no work, and a row that
* exists here always exists there.
*/
@Composable
fun DrawerSettingsContent(accountViewModel: AccountViewModel) {
// Per-account, synced through the NIP-78 app-specific data event.
val savedHidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
// All show/hide logic lives in the holder (unit-tested); the composable only renders and forwards
// events. Each edit republishes the account's NIP-78 settings event. syncFrom re-seeds when the
// saved set changes elsewhere.
//
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
// holder that outlived an account switch would write account A's edits to account B. It cannot:
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
val state = remember { DrawerSettingsState(savedHidden) { accountViewModel.changeHiddenDrawerItems(it) } }
LaunchedEffect(savedHidden) { state.syncFrom(savedHidden) }
// Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden
// counter is what tells the user which one to open.
val expandedSections = rememberExpandedKeys<DrawerSectionId>()
val totalHidden = state.totalHidden()
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(12.dp))
SummaryCard(totalHidden)
RestoreDefaultRow(onClick = { state.restoreDefault() })
Spacer(Modifier.height(4.dp))
PickerSectionHeader(title = stringRes(R.string.drawer_settings_sections))
// A section with no catalog rows has nothing to configure (Create is composer entry points),
// so it isn't listed here even though the drawer renders it.
DrawerSections.forEach { section ->
if (section.items.isEmpty()) return@forEach
SectionCard(
section = section,
state = state,
expanded = expandedSections.isExpanded(section.id),
onToggleExpand = { expandedSections.toggle(section.id) },
)
}
Spacer(Modifier.height(24.dp))
}
}
/** What the setting does and how far from stock the menu currently is. */
@Composable
private fun SummaryCard(totalHidden: Int) {
PickerHeroCard(
title = stringRes(R.string.drawer_settings_title),
trailing = {
Text(
text = stringRes(R.string.drawer_settings_hidden_count, totalHidden),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
},
) {
Text(
text = stringRes(R.string.drawer_settings_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SectionCard(
section: DrawerSection,
state: DrawerSettingsState,
expanded: Boolean,
onToggleExpand: () -> Unit,
) {
// Each card reads the same coarse `hidden` state, so without derivedStateOf a toggle in one
// section would recompose (and re-count) all of them.
val hiddenHere by remember(section) { derivedStateOf { state.hiddenCount(section) } }
CatalogCard(
icon = section.icon,
title = stringRes(section.titleRes),
expanded = expanded,
onToggleExpand = onToggleExpand,
trailing = {
if (hiddenHere > 0) {
Text(
text = stringRes(R.string.drawer_settings_hidden_count, hiddenHere),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
) {
// Bulk actions: turning ~29 feed rows off one at a time is the kind of chore that makes
// people give up halfway and leave the menu in a worse state than they found it.
if (DrawerItemVisibility.hasHideableRows(section)) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { state.showAll(section) }) {
Text(stringRes(R.string.drawer_settings_show_all))
}
TextButton(onClick = { state.hideAll(section) }) {
Text(stringRes(R.string.drawer_settings_hide_all))
}
}
}
section.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val mandatory = item in MandatoryDrawerItems
val visible = state.isVisible(item)
CatalogRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
onToggle = if (mandatory) null else ({ state.toggle(item) }),
) {
VisibilityPill(visible = visible, mandatory = mandatory, onClick = { state.toggle(item) })
}
}
}
}
/**
* Filled "Visible" / outlined "Hidden" — the bottom bar's Add/Added pill, saying what this screen
* says instead. A mandatory row gets a locked "Always on" badge: it reads as deliberately fixed
* rather than as a control that ignores taps.
*/
@Composable
private fun VisibilityPill(
visible: Boolean,
mandatory: Boolean,
onClick: () -> Unit,
) {
// One branch decides both halves of the pill, so a label can't drift away from its glyph.
val (labelRes, icon) =
when {
mandatory -> R.string.drawer_settings_always_on to MaterialSymbols.Lock
visible -> R.string.drawer_settings_visible to MaterialSymbols.Visibility
else -> R.string.drawer_settings_hidden to MaterialSymbols.VisibilityOff
}
TogglePill(
on = visible,
label = stringRes(labelRes),
icon = icon,
enabled = !mandatory,
onClick = onClick,
)
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
/**
* State holder for the Side Menu settings screen: owns the set of switched-off drawer rows and the
* show / hide / restore-default operations, so the composable only renders and forwards events.
*
* The rules themselves live in [DrawerItemVisibility] (pure, unit-tested); this adds only the Compose
* state and the write-through to the account's synced settings. Unlike the bottom bar there is no
* transient/commit split — a toggle is a single discrete edit, not a drag, so every change persists
* immediately.
*
* No sanitizing here: every value in is either already sanitized by the persistence layer or produced
* by a [DrawerItemVisibility] operation that can't introduce a mandatory row, and the write side
* sanitizes again anyway. One authority, not three.
*/
@Stable
class DrawerSettingsState(
initial: Set<NavBarItem>,
private val persist: (Set<NavBarItem>) -> Unit,
) {
var hidden by mutableStateOf(initial)
private set
fun isVisible(item: NavBarItem): Boolean = DrawerItemVisibility.isVisible(hidden, item)
fun toggle(item: NavBarItem) = update(DrawerItemVisibility.toggle(hidden, item))
fun hiddenCount(section: DrawerSection): Int = DrawerItemVisibility.hiddenCount(section, hidden)
fun totalHidden(): Int = DrawerItemVisibility.totalHidden(hidden)
fun showAll(section: DrawerSection) = update(DrawerItemVisibility.showAll(hidden, section))
fun hideAll(section: DrawerSection) = update(DrawerItemVisibility.hideAll(hidden, section))
/** Back to the stock drawer: nothing hidden. */
fun restoreDefault() = update(emptySet())
/**
* Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op
* when equal, so the echo of our own [persist] doesn't fight an in-progress edit.
*/
fun syncFrom(items: Set<NavBarItem>) {
if (items != hidden) hidden = items
}
private fun update(newHidden: Set<NavBarItem>) {
if (newHidden == hidden) return
hidden = newHidden
persist(newHidden)
}
}
@@ -0,0 +1,352 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size12dp
import com.vitorpamplona.amethyst.ui.theme.Size13dp
import com.vitorpamplona.amethyst.ui.theme.Size14dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22dp
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
/**
* The shared visual language of the navigation-configuration screens — the Bottom Navigation Bar
* picker and the Side Menu picker. Both present the same shape (collapsible cards of catalog rows,
* each row a glyph + label + a pill stating its current state), so the pieces live here once and
* each screen supplies only its own semantics: the bottom bar pins and reorders entries, the side
* menu switches rows on and off.
*
* [SectionExpand]/[SectionCollapse] reveal expandable sections by unrolling straight down from the
* top edge (the default AnimatedVisibility enter also expands horizontally from the bottom-end,
* which reads as a diagonal slide from the top-left).
*/
val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
/**
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
* NIP-29 group under its relay, or a Concord channel under its community).
*/
private fun indentPadding(level: Int) =
when (level) {
0 -> Size13dp
1 -> Size24dp
else -> Size40dp
}
/**
* Which collapsible rows of a picker are currently open, keyed by whatever identifies a row (a
* section id, a string-resource id, a catalog item). Absent means collapsed, so the initial state
* costs nothing and no list has to be seeded.
*/
@Stable
class ExpandedKeys<K> {
private val open = mutableStateMapOf<K, Boolean>()
fun isExpanded(key: K): Boolean = open[key] == true
fun toggle(key: K) {
open[key] = !isExpanded(key)
}
}
@Composable
fun <K> rememberExpandedKeys(): ExpandedKeys<K> = remember { ExpandedKeys() }
@Composable
fun PickerSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = Size18dp, bottom = Size6dp),
)
}
/** A category/destination glyph in a soft accent-tinted circle. */
@Composable
fun LeadingGlyph(icon: MaterialSymbol) {
Box(
modifier = SimpleImage35Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(symbol = icon, contentDescription = null, modifier = Size19Modifier, tint = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun EmptyChildHint(
textRes: Int,
indentLevel: Int = 1,
) {
Text(
text = stringRes(textRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = indentPadding(indentLevel), end = Size13dp, top = Size6dp, bottom = Size6dp),
)
}
/**
* One catalog row: leading visual, label, and a caller-supplied [trailing] state control. Tapping
* anywhere on the row runs [onToggle]; pass null for a row whose state can't change (a mandatory
* side-menu item), which also drops the ripple so the row doesn't advertise an action it won't take.
*/
@Composable
fun CatalogRow(
leading: @Composable () -> Unit,
label: String,
onToggle: (() -> Unit)?,
indentLevel: Int = 0,
trailing: @Composable () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.let { if (onToggle != null) it.clickable(onClick = onToggle) else it }
.padding(start = indentPadding(indentLevel), end = Size13dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size12dp),
) {
leading()
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing()
}
}
/**
* The state pill at the end of a catalog row: outlined in the "off" state, filled in the "on" state —
* so it states both the current state and, by contrast, that it can be changed. Both states share one
* Row body (only color/border/tint differ) so the pill keeps a constant height and rows stay aligned.
*
* [enabled] false renders the pill as a locked, non-interactive badge — used for a row the user isn't
* allowed to switch off.
*/
@Composable
fun TogglePill(
on: Boolean,
label: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
val container = if (enabled) accent else MaterialTheme.colorScheme.surfaceVariant
val content =
when {
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
on -> MaterialTheme.colorScheme.onPrimary
else -> accent
}
Surface(
shape = CircleShape,
color = if (on) container else Color.Transparent,
border = if (on) null else BorderStroke(1.dp, content),
) {
Row(
modifier =
Modifier
.let { if (enabled) it.clickable(onClick = onClick) else it }
.padding(horizontal = Size14dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Size15Modifier,
tint = content,
)
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = content,
)
}
}
}
/**
* A collapsible card holding catalog rows. [trailing] renders between the title and the chevron —
* the side menu puts its "n hidden" counter there; the bottom bar leaves it empty.
*/
@Composable
fun CatalogCard(
icon: MaterialSymbol,
title: String,
expanded: Boolean,
onToggleExpand: () -> Unit,
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(Size13dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size12dp),
) {
Box(
modifier =
Modifier
.size(Size34dp)
.clip(RoundedCornerShape(11.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
trailing()
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
Column(Modifier.padding(bottom = Size6dp)) { content() }
}
}
}
}
/**
* The accent-tinted card each picker opens with: a bold title, an optional [trailing] status, and a
* body. The bottom bar puts its editable preview bar in the body; the side menu puts its description.
*/
@Composable
fun PickerHeroCard(
title: String,
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
Surface(
shape = RoundedCornerShape(Size22dp),
color = accent.copy(alpha = 0.07f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
) {
Column(Modifier.padding(Size14dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = Size10dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = accent,
fontWeight = FontWeight.Bold,
)
trailing()
}
content()
}
}
}
/** The end-aligned "Restore Default" action both pickers put under their hero card. */
@Composable
fun RestoreDefaultRow(onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onClick) {
Text(stringRes(R.string.bottom_bar_settings_restore_default))
}
}
}
@@ -72,6 +72,7 @@ fun buildSettingsCatalog(
symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings),
symEntry(R.string.messages_settings, MaterialSymbols.Mail, R.string.messages_settings_search_keywords, Route.MessagesSettings),
symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings),
symEntry(R.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, R.string.drawer_search_keywords, Route.DrawerSettings),
symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings),
symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -104,7 +105,9 @@ private fun VideoCardImage(
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
val content by
remember(note) {
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop
@@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard
import com.vitorpamplona.amethyst.ui.note.types.toMediaContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -101,45 +98,33 @@ private fun FileHeaderCardImage(
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
val mimeType = remember(note) { event.mimeType() }
val blurHash = remember(note) { event.blurhash() }
val thumbHash = remember(note) { event.thumbhash() }
val dimensions = remember(note) { event.dimensions() }
val content by remember(note) {
val hash = event.hash()
val description = event.content.ifEmpty { null } ?: event.alt()
val uri = note.toNostrUri()
val mimeType = event.mimeType()
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
mutableStateOf<BaseMediaContent>(
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
},
)
// Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on
// `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event
// whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps
// the viewer in FileHeaderDisplay: a content warning is about the file, and the card still
// spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults
// (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash
// to show behind it.
if (content == null) {
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = emptyList(),
accountViewModel = accountViewModel,
) {
FileHeaderAttachmentCard(event, fullUrl, mimeType)
}
return
}
val isImage = content is MediaUrlImage
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl)
ContentWarningGate(
@@ -211,6 +211,7 @@
<string name="connection_success_rate_description">Procento úspěšných připojení k relé</string>
<string name="search_and_add_a_user">Vyhledat a přidat uživatele</string>
<string name="add_a_relay">Přidat přeposílání</string>
<string name="relay_url_not_valid">Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Moje @tag jméno</string>
<string name="display_name">Zobrazované jméno</string>
<string name="my_display_name">Moje zobrazované jméno</string>
@@ -2024,14 +2025,105 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d relay</item>
<item quantity="few">%1$s \u00b7 %2$d relaye</item>
<item quantity="many">%1$s \u00b7 %2$d relaye</item>
<item quantity="other">%1$s \u00b7 %2$d relayů</item>
</plurals>
<string name="relay_purpose_browsing">Procházení</string>
<string name="relay_purpose_media">Média</string>
<string name="relay_purpose_tags">Hashtagy</string>
<string name="relay_purpose_topics">Témata</string>
<string name="relay_purpose_thread">Konverzace</string>
<string name="relay_purpose_search">Hledání</string>
<string name="relay_purpose_referenced">Hledání chybějících událostí</string>
<string name="relay_purpose_engagement">Sledování událostí</string>
<string name="relay_explain_referenced">Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna.</string>
<string name="relay_explain_engagement">Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení.</string>
<string name="relay_purpose_add_ons">Doplňky</string>
<string name="relay_purpose_relay_info">Informace o relayi</string>
<string name="relay_purpose_other">Ostatní</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Vyhledávač seznamů relayů</string>
<string name="relay_purpose_observing_profiles">Sledování profilů</string>
<string name="relay_purpose_your_account">Data účtu</string>
<string name="relay_purpose_home_feed">Domovský zdroj</string>
<string name="relay_purpose_relay_groups">Relay skupiny</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d skupina</item>
<item quantity="few">%1$d skupiny</item>
<item quantity="many">%1$d skupiny</item>
<item quantity="other">%1$d skupin</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Mizící chaty</string>
<string name="relay_purpose_geohash_chats">Chaty podle místa</string>
<string name="relay_purpose_live_chat">Chat živého vysílání</string>
<string name="relay_explain_relay_groups">Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu.</string>
<string name="relay_explain_ephemeral_chats">Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo.</string>
<string name="relay_explain_geohash_chats">Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou.</string>
<string name="relay_explain_live_chat">Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete.</string>
<string name="relay_purpose_dm_inbox">Schránka DM</string>
<string name="relay_purpose_your_wallet">Peněženka</string>
<string name="relay_purpose_nutzap_inbox">Schránka nutzapů</string>
<string name="relay_purpose_mint_directory">Adresář mintů</string>
<string name="relay_purpose_nwc">Wallet Connect</string>
<string name="relay_purpose_community_chats">Chaty komunit</string>
<string name="relay_purpose_community_feeds">Zdroje komunit</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam.</string>
<string name="relay_explain_direct_messages">Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu.</string>
<string name="relay_explain_public_chats">Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili.</string>
<string name="relay_explain_community_chats">Relaye, na které každá komunita publikuje své roviny.</string>
<string name="relay_explain_encrypted_groups">Skupinové zprávy a balíčky klíčů na relayích každé skupiny.</string>
<string name="relay_explain_live_rooms">Relaye místnosti, dokud je otevřená.</string>
<string name="relay_explain_account_data">Váš vlastní profil, nastavení a koncepty na vašich domovských relayích.</string>
<string name="relay_explain_profiles">Profily lidí právě na obrazovce.</string>
<string name="relay_explain_relay_lists">Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě.</string>
<string name="relay_explain_follows">Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry.</string>
<string name="relay_explain_moderation">Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují.</string>
<string name="relay_purpose_reports_from_follows">Nahlášení od sledovaných</string>
<string name="relay_explain_wallet">Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali.</string>
<string name="relay_explain_nutzap_inbox">Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba.</string>
<string name="relay_explain_mint_directory">Prohledává relaye, které minty existují a které lidé doporučují.</string>
<string name="relay_explain_nwc">Upozornění z vaší připojené peněženky.</string>
<string name="active_subs_title">Aktivní odběry relayů</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filtr</item>
<item quantity="few">%1$d filtry</item>
<item quantity="many">%1$d filtru</item>
<item quantity="other">%1$d filtrů</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d relay</item>
<item quantity="few">%1$d relaye</item>
<item quantity="many">%1$d relaye</item>
<item quantity="other">%1$d relayů</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr zatím není přiřazen</item>
<item quantity="few">%1$d filtry zatím nejsou přiřazeny</item>
<item quantity="many">%1$d filtru zatím není přiřazeno</item>
<item quantity="other">%1$d filtrů zatím není přiřazeno</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Nepřiřazeno k žádnému účtu</string>
<string name="active_subs_no_entity">Vše</string>
<string name="active_subs_scope_global">Všichni</string>
<string name="active_subs_scope_follows">Lidé, které sledujete</string>
<string name="active_subs_scope_authors">Vybraný seznam lidí</string>
<string name="active_subs_scope_muted">Ztlumení lidé</string>
<string name="active_subs_scope_all_communities">Vaše komunity</string>
<string name="active_subs_scope_algo">Oblíbený algoritmický zdroj</string>
<string name="active_subs_share">%1$d %% ze všech</string>
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
<string name="relay_explain_home">Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje.</string>
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
<string name="always_on_notif_setting_description">Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu.</string>
@@ -4726,6 +4818,7 @@
<string name="buzz_workflow_gate_needs_you">Potřebuje vaše schválení</string>
<string name="buzz_workflow_gate_awaiting">Čeká na schválení</string>
<string name="buzz_workflow_no_description">(bez popisu)</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_by">od</string>
<string name="buzz_workflow_waiting_on">čeká na</string>
<string name="buzz_workflow_readonly_approver">Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat.</string>
@@ -4761,6 +4854,7 @@
<string name="buzz_workflow_no_defs_hint">Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte.</string>
<string name="buzz_workflow_task_label">Co má udělat?</string>
<string name="buzz_workflow_trigger_run">Spustit běh</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="buzz_workflow_picker_empty">Zatím nejsou definována žádná workflow</string>
<string name="buzz_workflow_picker_choose">Vyberte workflow</string>
<string name="buzz_workflow_new_definition">Nová definice…</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">Prozentsatz erfolgreicher Verbindungen zum Relay</string>
<string name="search_and_add_a_user">Benutzer suchen und hinzufügen</string>
<string name="add_a_relay">Relay hinzufügen</string>
<string name="relay_url_not_valid">Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Mein @tag-Name</string>
<string name="display_name">Anzeigename</string>
<string name="my_display_name">Mein Anzeigename</string>
@@ -1942,14 +1943,91 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d Relay</item>
<item quantity="other">%1$s \u00b7 %2$d Relays</item>
</plurals>
<string name="relay_purpose_browsing">Stöbern</string>
<string name="relay_purpose_media">Medien</string>
<string name="relay_purpose_topics">Themen</string>
<string name="relay_purpose_thread">Unterhaltung</string>
<string name="relay_purpose_search">Suche</string>
<string name="relay_purpose_referenced">Fehlende Events finden</string>
<string name="relay_purpose_engagement">Events beobachten</string>
<string name="relay_explain_referenced">Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel.</string>
<string name="relay_explain_engagement">Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben.</string>
<string name="relay_purpose_add_ons">Erweiterungen</string>
<string name="relay_purpose_relay_info">Relay-Info</string>
<string name="relay_purpose_other">Sonstiges</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Relay-Listen-Finder</string>
<string name="relay_purpose_observing_profiles">Profile beobachten</string>
<string name="relay_purpose_your_account">Kontodaten</string>
<string name="relay_purpose_home_feed">Startseiten-Feed</string>
<string name="relay_purpose_relay_groups">Relay-Gruppen</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d Gruppe</item>
<item quantity="other">%1$d Gruppen</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Verschwindende Chats</string>
<string name="relay_purpose_geohash_chats">Standort-Chats</string>
<string name="relay_purpose_live_chat">Live-Stream-Chat</string>
<string name="relay_explain_relay_groups">NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt.</string>
<string name="relay_explain_ephemeral_chats">Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt.</string>
<string name="relay_explain_geohash_chats">Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen.</string>
<string name="relay_explain_live_chat">Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst.</string>
<string name="relay_purpose_dm_inbox">DM-Posteingang</string>
<string name="relay_purpose_nutzap_inbox">Nutzap-Posteingang</string>
<string name="relay_purpose_mint_directory">Mint-Verzeichnis</string>
<string name="relay_purpose_community_chats">Community-Chats</string>
<string name="relay_purpose_community_feeds">Community-Feeds</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde.</string>
<string name="relay_explain_direct_messages">Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden.</string>
<string name="relay_explain_public_chats">Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist.</string>
<string name="relay_explain_community_chats">Die Relays, auf denen jede Community ihre Planes veröffentlicht.</string>
<string name="relay_explain_encrypted_groups">Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe.</string>
<string name="relay_explain_live_rooms">Die Relays des Raums, solange er geöffnet ist.</string>
<string name="relay_explain_account_data">Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays.</string>
<string name="relay_explain_profiles">Profile der gerade angezeigten Personen.</string>
<string name="relay_explain_relay_lists">Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können.</string>
<string name="relay_explain_follows">Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden.</string>
<string name="relay_explain_moderation">Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen.</string>
<string name="relay_purpose_reports_from_follows">Meldungen von Gefolgten</string>
<string name="relay_explain_wallet">Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast.</string>
<string name="relay_explain_nutzap_inbox">Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht.</string>
<string name="relay_explain_mint_directory">Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden.</string>
<string name="relay_explain_nwc">Benachrichtigungen von deiner verbundenen Wallet.</string>
<string name="active_subs_title">Aktive Relay-Abonnements</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d Filter</item>
<item quantity="other">%1$d Filter</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d Relay</item>
<item quantity="other">%1$d Relays</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
<item quantity="other">%1$d Filter sind noch nicht zugeordnet</item>
</plurals>
<string name="active_subs_unattributed">Keinem Konto zugeordnet</string>
<string name="active_subs_no_entity">Alle</string>
<string name="active_subs_scope_global">Jeder</string>
<string name="active_subs_scope_follows">Personen, denen du folgst</string>
<string name="active_subs_scope_authors">Eine ausgewählte Liste von Personen</string>
<string name="active_subs_scope_muted">Stummgeschaltete Personen</string>
<string name="active_subs_scope_all_communities">Deine Communitys</string>
<string name="active_subs_scope_algo">Ein bevorzugter Feed-Algorithmus</string>
<string name="active_subs_share">%1$d %% von allen</string>
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
<string name="relay_explain_home">Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht.</string>
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
<string name="always_on_notif_setting_description">Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst.</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ</string>
<string name="search_and_add_a_user">ढूँढें तथा प्रयेक्ता जोडें</string>
<string name="add_a_relay">पुनःप्रसारक जोडें</string>
<string name="relay_url_not_valid">मान्य पुनःप्रसारक पता नहीं। एक जालावास नाम का उपयोग करें। अथवा कोष्ठकों में एक अंकीय जालपता उदाहरण [201:d0e:9ba5:8bbc::1]:8080 के जैसे।</string>
<string name="my_name">मेरा @सूचक नाम</string>
<string name="display_name">प्रदर्शन नाम</string>
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">Percentage succesvolle verbindingen met deze relay</string>
<string name="search_and_add_a_user">Zoek en voeg gebruiker toe</string>
<string name="add_a_relay">Relay toevoegen</string>
<string name="relay_url_not_valid">Geen geldig relay-adres. Gebruik een hostnaam, of een IP-adres tussen blokhaken (bijvoorbeeld [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Mijn @naam</string>
<string name="display_name">Weergavenaam</string>
<string name="my_display_name">Mijn weergavenaam</string>
@@ -1929,14 +1930,95 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d relay</item>
<item quantity="other">%1$s \u00b7 %2$d relays</item>
</plurals>
<string name="relay_purpose_browsing">Bladeren</string>
<string name="relay_purpose_media">Media</string>
<string name="relay_purpose_tags">Hashtags</string>
<string name="relay_purpose_topics">Onderwerpen</string>
<string name="relay_purpose_thread">Gesprek</string>
<string name="relay_purpose_search">Zoeken</string>
<string name="relay_purpose_referenced">Ontbrekende events zoeken</string>
<string name="relay_purpose_engagement">Events observeren</string>
<string name="relay_explain_referenced">Haalt events op via hun id waar iets op je scherm naar verwijst maar die je nog niet hebt — een quote, de note waarop een reactie antwoordt, de start van een discussie.</string>
<string name="relay_explain_engagement">Houdt de events die nu in beeld staan in de gaten voor nieuwe antwoorden, reacties, reposts, zaps en rapportages, zodat de tellers bijwerken terwijl je leest.</string>
<string name="relay_purpose_add_ons">Add-ons</string>
<string name="relay_purpose_relay_info">Relay-info</string>
<string name="relay_purpose_other">Overig</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Relaylijsten zoeken</string>
<string name="relay_purpose_observing_profiles">Profielen observeren</string>
<string name="relay_purpose_your_account">Accountgegevens</string>
<string name="relay_purpose_home_feed">Startfeed</string>
<string name="relay_purpose_relay_groups">Relay-groepen</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d groep</item>
<item quantity="other">%1$d groepen</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Vluchtige chats</string>
<string name="relay_purpose_geohash_chats">Locatiechats</string>
<string name="relay_purpose_live_chat">Livestream-chat</string>
<string name="relay_explain_relay_groups">NIP-29-groepen waar je lid van bent. Elke groep staat op één host-relay, dus de app verbindt met elke relay die een groep van jou host.</string>
<string name="relay_explain_ephemeral_chats">Chatruimtes die geen geschiedenis bewaren — berichten bestaan alleen zolang je verbonden bent, dus deze blijven geabonneerd om überhaupt iets te ontvangen.</string>
<string name="relay_explain_geohash_chats">Locatiegebonden ruimtes voor de gebieden die je volgt, opgevraagd bij de relays die ze aanbieden.</string>
<string name="relay_explain_live_chat">Chat en zap-doelen die horen bij livestreams die je open hebt staan of volgt.</string>
<string name="relay_purpose_dm_inbox">DM-inbox</string>
<string name="relay_purpose_your_wallet">Wallet</string>
<string name="relay_purpose_nutzap_inbox">Nutzap-inbox</string>
<string name="relay_purpose_mint_directory">Mintoverzicht</string>
<string name="relay_purpose_nwc">Wallet Connect</string>
<string name="relay_purpose_community_chats">Community-chats</string>
<string name="relay_purpose_community_feeds">Community-feeds</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Je inbox-relays, plus een kleine wisselende steekproef van de relays waarop de mensen die je volgt plaatsen, voor het geval een vermelding ergens anders is afgeleverd.</string>
<string name="relay_explain_direct_messages">Je DM-inbox-relays, waar gift-wrapped berichten worden afgeleverd.</string>
<string name="relay_explain_public_chats">De thuisrelay van elke chat die je open hebt staan of waar je lid van bent.</string>
<string name="relay_explain_community_chats">De relays waarop elke community zijn planes publiceert.</string>
<string name="relay_explain_encrypted_groups">Groepsberichten en sleutelpakketten, op de relays van elke groep.</string>
<string name="relay_explain_live_rooms">De relays van de ruimte, zolang die open is.</string>
<string name="relay_explain_account_data">Je eigen profiel, instellingen en concepten, op je eigen relays.</string>
<string name="relay_explain_profiles">Profielen van de mensen die nu in beeld zijn.</string>
<string name="relay_explain_relay_lists">Zoekt uit naar welke relays iemand publiceert, zodat hun posts van de juiste plek kunnen worden opgehaald.</string>
<string name="relay_explain_follows">Volglijsten, gebruikt om je feed en je web-of-trust op te bouwen.</string>
<string name="relay_explain_moderation">Rapportages die de mensen die je volgt schreven over de profielen die nu in beeld zijn, opgevraagd bij elke relay waarop die mensen plaatsen.</string>
<string name="relay_purpose_reports_from_follows">Rapportages van wie je volgt</string>
<string name="relay_explain_wallet">Je eigen wallet-events, teruggelezen van de relays waarop je ze hebt gepubliceerd.</string>
<string name="relay_explain_nutzap_inbox">Luistert op je nutzap-relays plus je inbox- en DM-relays, zodat een betaling er niet langs kan glippen.</string>
<string name="relay_explain_mint_directory">Kijkt op alle relays welke mints er bestaan en welke door mensen worden aanbevolen.</string>
<string name="relay_explain_nwc">Meldingen van je verbonden wallet.</string>
<string name="active_subs_title">Actieve relay-abonnementen</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filter</item>
<item quantity="other">%1$d filters</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter is nog niet toegewezen</item>
<item quantity="other">%1$d filters zijn nog niet toegewezen</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Niet toegewezen aan een account</string>
<string name="active_subs_no_entity">Alles</string>
<string name="active_subs_scope_global">Iedereen</string>
<string name="active_subs_scope_follows">Mensen die je volgt</string>
<string name="active_subs_scope_authors">Een gekozen lijst mensen</string>
<string name="active_subs_scope_muted">Gedempte mensen</string>
<string name="active_subs_scope_all_communities">Jouw communities</string>
<string name="active_subs_scope_algo">Een favoriete algo-feed</string>
<string name="active_subs_share">%1$d%% van alles</string>
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string>
<string name="relay_explain_home">Posts van de mensen die je volgt, gelezen van de relays waarop ieder van hen publiceert.</string>
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
<string name="always_on_notif_setting_description">Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist.</string>
@@ -2218,8 +2300,10 @@
<string name="geohash_exclusive">Alleen locatie-exclusief bericht</string>
<string name="geohash_exclusive_explainer">Alleen volgers van de locatie zien dit bericht.</string>
<string name="hashtag_exclusive">Alleen hashtag-exclusief bericht</string>
<string name="external_content_title">Externe inhoud</string>
<string name="external_url_scope">Reageer op een website</string>
<string name="external_id_scope">Reageer op een externe bron</string>
<string name="url_preview_open_in_browser">Openen in browser</string>
<string name="long_form_reading_minutes">%1$d min lezen</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d nummer</item>
@@ -2531,6 +2615,9 @@
<string name="share_to_dm_title">Verzenden naar…</string>
<string name="share_to_dm_start_new">Nieuw bericht</string>
<string name="share_target_as_highlight">Nieuwe Highlight</string>
<string name="share_target_as_picture">Nieuwe afbeelding</string>
<string name="share_target_as_short_video">Nieuwe Short</string>
<string name="share_target_as_video">Nieuwe video</string>
<string name="new_highlight_title">Nieuwe Highlight</string>
<string name="new_highlight_passage_label">Gemarkeerde tekst</string>
<string name="new_highlight_passage_placeholder">Wat viel je op?</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">Porcentagem de conexões bem-sucedidas ao relay</string>
<string name="search_and_add_a_user">Pesquisar e adicionar usuário</string>
<string name="add_a_relay">Adicionar um Relay</string>
<string name="relay_url_not_valid">Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Meu nome de @tag</string>
<string name="display_name">Nome de Exibição</string>
<string name="my_display_name">Meu nome de exibição</string>
@@ -1940,14 +1941,84 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<string name="relay_purpose_browsing">Navegação</string>
<string name="relay_purpose_media">Mídia</string>
<string name="relay_purpose_topics">Tópicos</string>
<string name="relay_purpose_thread">Conversa</string>
<string name="relay_purpose_search">Pesquisa</string>
<string name="relay_purpose_referenced">Encontrando eventos faltantes</string>
<string name="relay_purpose_engagement">Observando eventos</string>
<string name="relay_explain_referenced">Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa.</string>
<string name="relay_explain_engagement">Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê.</string>
<string name="relay_purpose_add_ons">Complementos</string>
<string name="relay_purpose_relay_info">Informações do relay</string>
<string name="relay_purpose_other">Outros</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Localizador de listas de relays</string>
<string name="relay_purpose_observing_profiles">Observando perfis</string>
<string name="relay_purpose_your_account">Dados da conta</string>
<string name="relay_purpose_home_feed">Feed inicial</string>
<string name="relay_purpose_relay_groups">Grupos de relay</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d grupo</item>
<item quantity="other">%1$d grupos</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Chats efêmeros</string>
<string name="relay_purpose_geohash_chats">Chats por localização</string>
<string name="relay_purpose_live_chat">Chat de transmissão ao vivo</string>
<string name="relay_explain_relay_groups">Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu.</string>
<string name="relay_explain_ephemeral_chats">Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue.</string>
<string name="relay_explain_geohash_chats">Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam.</string>
<string name="relay_explain_live_chat">Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue.</string>
<string name="relay_purpose_dm_inbox">Caixa de entrada de DM</string>
<string name="relay_purpose_your_wallet">Carteira</string>
<string name="relay_purpose_nutzap_inbox">Caixa de entrada de nutzaps</string>
<string name="relay_purpose_mint_directory">Diretório de mints</string>
<string name="relay_purpose_community_chats">Chats de comunidades</string>
<string name="relay_purpose_community_feeds">Feeds de comunidades</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar.</string>
<string name="relay_explain_direct_messages">Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues.</string>
<string name="relay_explain_public_chats">O relay de origem de cada chat que você abriu ou do qual participa.</string>
<string name="relay_explain_community_chats">Os relays em que cada comunidade publica seus planos.</string>
<string name="relay_explain_encrypted_groups">Mensagens de grupo e pacotes de chaves, nos relays de cada grupo.</string>
<string name="relay_explain_live_rooms">Os relays da sala, enquanto ela estiver aberta.</string>
<string name="relay_explain_account_data">Seu próprio perfil, configurações e rascunhos, nos seus relays de origem.</string>
<string name="relay_explain_profiles">Perfis das pessoas atualmente na tela.</string>
<string name="relay_explain_relay_lists">Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo.</string>
<string name="relay_explain_follows">Listas de seguindo, usadas para montar seu feed e sua rede de confiança.</string>
<string name="relay_explain_moderation">Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam.</string>
<string name="relay_purpose_reports_from_follows">Denúncias de quem você segue</string>
<string name="relay_explain_wallet">Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou.</string>
<string name="relay_explain_nutzap_inbox">Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido.</string>
<string name="relay_explain_mint_directory">Procura pelos relays quais mints existem e quais as pessoas recomendam.</string>
<string name="relay_explain_nwc">Notificações da sua carteira conectada.</string>
<string name="active_subs_title">Assinaturas de relay ativas</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filtro</item>
<item quantity="other">%1$d filtros</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
<item quantity="other">%1$d filtros ainda não foram atribuídos</item>
</plurals>
<string name="active_subs_unattributed">Não atribuído a nenhuma conta</string>
<string name="active_subs_no_entity">Tudo</string>
<string name="active_subs_scope_global">Todos</string>
<string name="active_subs_scope_follows">Pessoas que você segue</string>
<string name="active_subs_scope_authors">Uma lista escolhida de pessoas</string>
<string name="active_subs_scope_muted">Pessoas silenciadas</string>
<string name="active_subs_scope_all_communities">Suas comunidades</string>
<string name="active_subs_scope_algo">Um algoritmo de feed favorito</string>
<string name="active_subs_share">%1$d%% de todos</string>
<string name="active_subs_search_keywords">assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico</string>
<string name="relay_explain_home">Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica.</string>
<string name="always_on_notif_connecting">Conectando aos relays de caixa de entrada\u2026</string>
<string name="always_on_notif_setting_title">Serviço de notificações sempre ativo</string>
<string name="always_on_notif_setting_description">Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem.</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">Andel lyckade anslutningar till reläet</string>
<string name="search_and_add_a_user">Sök och lägg till användare</string>
<string name="add_a_relay">Lägg till Relä</string>
<string name="relay_url_not_valid">Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Mitt @tag-namn</string>
<string name="display_name">Visningsnamn</string>
<string name="my_display_name">Mitt visningsnamn</string>
@@ -1940,14 +1941,95 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d relä</item>
<item quantity="other">%1$s \u00b7 %2$d reläer</item>
</plurals>
<string name="relay_purpose_browsing">Bläddring</string>
<string name="relay_purpose_media">Media</string>
<string name="relay_purpose_tags">Hashtaggar</string>
<string name="relay_purpose_topics">Ämnen</string>
<string name="relay_purpose_thread">Konversation</string>
<string name="relay_purpose_search">Sök</string>
<string name="relay_purpose_referenced">Hittar saknade händelser</string>
<string name="relay_purpose_engagement">Observerar händelser</string>
<string name="relay_explain_referenced">Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot.</string>
<string name="relay_explain_engagement">Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser.</string>
<string name="relay_purpose_add_ons">Tillägg</string>
<string name="relay_purpose_relay_info">Reläinfo</string>
<string name="relay_purpose_other">Övrigt</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Relälistsökare</string>
<string name="relay_purpose_observing_profiles">Observerar profiler</string>
<string name="relay_purpose_your_account">Kontots data</string>
<string name="relay_purpose_home_feed">Hemflöde</string>
<string name="relay_purpose_relay_groups">Relägrupper</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d grupp</item>
<item quantity="other">%1$d grupper</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Försvinnande chattar</string>
<string name="relay_purpose_geohash_chats">Platschattar</string>
<string name="relay_purpose_live_chat">Livesändningschatt</string>
<string name="relay_explain_relay_groups">NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper.</string>
<string name="relay_explain_ephemeral_chats">Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram.</string>
<string name="relay_explain_geohash_chats">Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem.</string>
<string name="relay_explain_live_chat">Chatt och zap-mål kopplade till livesändningar du har öppna eller följer.</string>
<string name="relay_purpose_dm_inbox">DM-inkorg</string>
<string name="relay_purpose_your_wallet">Plånbok</string>
<string name="relay_purpose_nutzap_inbox">Nutzap-inkorg</string>
<string name="relay_purpose_mint_directory">Mint-katalog</string>
<string name="relay_purpose_nwc">Wallet Connect</string>
<string name="relay_purpose_community_chats">Gemenskapschattar</string>
<string name="relay_purpose_community_feeds">Gemenskapsflöden</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans.</string>
<string name="relay_explain_direct_messages">Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras.</string>
<string name="relay_explain_public_chats">Hemrelät för varje chatt du har öppen eller har gått med i.</string>
<string name="relay_explain_community_chats">Reläerna som varje gemenskap publicerar sina plan till.</string>
<string name="relay_explain_encrypted_groups">Gruppmeddelanden och nyckelpaket, på varje grupps reläer.</string>
<string name="relay_explain_live_rooms">Rummets reläer, medan det är öppet.</string>
<string name="relay_explain_account_data">Din egen profil, dina inställningar och utkast, på dina hemreläer.</string>
<string name="relay_explain_profiles">Profiler för personerna som just nu visas på skärmen.</string>
<string name="relay_explain_relay_lists">Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe.</string>
<string name="relay_explain_follows">Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät.</string>
<string name="relay_explain_moderation">Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till.</string>
<string name="relay_purpose_reports_from_follows">Rapporter från personer du följer</string>
<string name="relay_explain_wallet">Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till.</string>
<string name="relay_explain_nutzap_inbox">Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi.</string>
<string name="relay_explain_mint_directory">Söker över reläer efter vilka mints som finns och vilka folk rekommenderar.</string>
<string name="relay_explain_nwc">Aviseringar från din anslutna plånbok.</string>
<string name="active_subs_title">Aktiva reläprenumerationer</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filter</item>
<item quantity="other">%1$d filter</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d relä</item>
<item quantity="other">%1$d reläer</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter är inte kopplat ännu</item>
<item quantity="other">%1$d filter är inte kopplade ännu</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Inte kopplat till något konto</string>
<string name="active_subs_no_entity">Allt</string>
<string name="active_subs_scope_global">Alla</string>
<string name="active_subs_scope_follows">Personer du följer</string>
<string name="active_subs_scope_authors">En vald lista med personer</string>
<string name="active_subs_scope_muted">Tystade personer</string>
<string name="active_subs_scope_all_communities">Dina gemenskaper</string>
<string name="active_subs_scope_algo">En favorit-flödesalgoritm</string>
<string name="active_subs_share">%1$d %% av alla</string>
<string name="active_subs_search_keywords">prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik</string>
<string name="relay_explain_home">Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till.</string>
<string name="always_on_notif_connecting">Ansluter till inbox-relän\u2026</string>
<string name="always_on_notif_setting_title">Alltid på-notifieringstjänst</string>
<string name="always_on_notif_setting_description">Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande.</string>
+15
View File
@@ -2378,6 +2378,7 @@
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13</string>
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
<string name="drawer_search_keywords" translatable="false">side menu, drawer, hamburger, sections, hide, show</string>
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
<string name="profile_ui_search_keywords" translatable="false">profile, layout</string>
<string name="backup_keys_search_keywords" translatable="false">nsec, private key, seed, mnemonic, export</string>
@@ -3512,6 +3513,16 @@
<string name="bottom_bar_category_feeds">Feeds</string>
<string name="bottom_bar_category_apps">Apps &amp; Web</string>
<string name="bottom_bar_category_other">Other</string>
<string name="drawer_settings">Side Menu</string>
<string name="drawer_settings_title">Your side menu</string>
<string name="drawer_settings_description">Open a section and switch off the rows you never use. Settings always stays visible, so you can always get back here. Section order is fixed.</string>
<string name="drawer_settings_sections">Sections</string>
<string name="drawer_settings_hidden_count">%1$d hidden</string>
<string name="drawer_settings_visible">Visible</string>
<string name="drawer_settings_hidden">Hidden</string>
<string name="drawer_settings_always_on">Always on</string>
<string name="drawer_settings_show_all">Show all</string>
<string name="drawer_settings_hide_all">Hide all</string>
<string name="home_tabs_settings">Home Tabs</string>
<string name="home_tabs_settings_description">Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden.</string>
<string name="home_tab_everything">Everything</string>
@@ -3919,6 +3930,10 @@
<string name="git_repo_settings_save">Save</string>
<string name="git_repositories">Git Repositories</string>
<string name="highlights">Highlights</string>
<string name="git_repositories_search_open">Filter repositories</string>
<string name="git_repositories_search_close">Close filter</string>
<string name="git_repositories_search_placeholder">Filter by name, topic, host, maintainer…</string>
<string name="git_repositories_search_no_results">No repositories in the current feed match this search.</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Permissions:</string>
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Locks the per-account side-menu persistence. The hidden rows ride along in the same NIP-78
* app-specific data blob as the bottom bar, so every account keeps its own menu and it syncs across
* the user's devices.
*/
class DrawerPersistenceTest {
@Test
fun defaultIsAnUntouchedMenu() {
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(JsonMapper.toJson(AccountNavigationPreferencesInternal()))
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
}
@Test
fun blobWrittenBeforeTheFieldExistedDecodesToAnUntouchedMenu() {
// Every existing account is in this state, and so is every account that never opens the
// screen — which is exactly why the preference stores hidden rows rather than visible ones.
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>("{}")
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
}
@Test
fun hiddenRowsRoundTripThroughTheSyncedSettingsBlob() {
val hidden = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES)
val json = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = hidden.toNames()))
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
assertEquals(hidden, navBarItemsFromNames(decoded.hiddenDrawerItems))
}
@Test
fun serializedFormIsDeterministic() {
// Two equal sets must produce byte-identical JSON, or a republish that changed nothing would
// still look like a change and churn the account's NIP-78 event.
val a = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES).toNames()))
val b = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.BADGES, NavBarItem.DRAFTS).toNames()))
assertEquals(a, b)
}
@Test
fun anIdFromANewerClientIsDroppedInsteadOfFailingTheWholeBlob() {
// The names are stored as strings precisely for this: decoding them as the enum would throw
// and take every other synced setting down with it.
val json = """{"hiddenDrawerItems":["DRAFTS","SOME_SCREEN_FROM_THE_FUTURE"]}"""
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
assertEquals(setOf(NavBarItem.DRAFTS), navBarItemsFromNames(decoded.hiddenDrawerItems))
}
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The show/hide rules behind the Side Menu settings screen, exercised without the UI.
*
* The preference stores what is *hidden*, so the interesting cases are the empty set (a stock drawer,
* and the state every new install and every newly shipped destination starts in) and the mandatory
* rows, which no code path may switch off.
*/
class DrawerItemVisibilityTest {
private val you = DrawerSections.first { it.id == DrawerSectionId.YOU }
private val system = DrawerSections.first { it.id == DrawerSectionId.SYSTEM }
@Test
fun nothingHiddenMeansEverythingVisible() {
val visible = DrawerItemVisibility.visibleItems(you, emptySet())
assertEquals(you.items, visible)
assertEquals(0, DrawerItemVisibility.hiddenCount(you, emptySet()))
}
@Test
fun toggleHidesThenShows() {
val once = DrawerItemVisibility.toggle(emptySet(), NavBarItem.DRAFTS)
assertEquals(setOf(NavBarItem.DRAFTS), once)
assertFalse(DrawerItemVisibility.isVisible(once, NavBarItem.DRAFTS))
val twice = DrawerItemVisibility.toggle(once, NavBarItem.DRAFTS)
assertEquals(emptySet<NavBarItem>(), twice)
assertTrue(DrawerItemVisibility.isVisible(twice, NavBarItem.DRAFTS))
}
@Test
fun aHiddenRowDropsOutOfItsSectionKeepingTheOrderOfTheRest() {
val hidden = setOf(NavBarItem.DRAFTS)
val visible = DrawerItemVisibility.visibleItems(you, hidden)
assertEquals(you.items.filter { it != NavBarItem.DRAFTS }, visible)
assertEquals(1, DrawerItemVisibility.hiddenCount(you, hidden))
}
@Test
fun settingsCannotBeHidden() {
// The escape hatch: hiding Settings would leave no route back to the screen that hides rows.
assertEquals(emptySet<NavBarItem>(), DrawerItemVisibility.toggle(emptySet(), NavBarItem.SETTINGS))
assertTrue(DrawerItemVisibility.isVisible(setOf(NavBarItem.SETTINGS), NavBarItem.SETTINGS))
}
@Test
fun sanitizeStripsMandatoryItemsSyncedFromElsewhere() {
// Another client (or an older build) could put Settings in the set; reading it back must not
// strand the row as hidden-yet-unhideable.
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.SETTINGS, NavBarItem.DRAFTS))
assertEquals(setOf(NavBarItem.DRAFTS), sanitized)
}
@Test
fun sanitizeKeepsIdsThisDeviceDoesNotRender() {
// Favorite Apps is gated off below API 30. Editing the menu on such a device must not clear
// the choice the same account made on a newer one.
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.FAVORITE_APPS))
assertEquals(setOf(NavBarItem.FAVORITE_APPS), sanitized)
}
@Test
fun hideAllLeavesTheMandatoryRowsOfASection() {
val hidden = DrawerItemVisibility.hideAll(emptySet(), system)
assertTrue(DrawerItemVisibility.isVisible(hidden, NavBarItem.SETTINGS))
assertEquals(0, DrawerItemVisibility.hiddenCount(system, hidden))
}
@Test
fun aSectionOfOnlyMandatoryRowsHasNothingToHide() {
// What gates the section's bulk Show all / Hide all actions.
assertFalse(DrawerItemVisibility.hasHideableRows(system))
assertTrue(DrawerItemVisibility.hasHideableRows(you))
}
@Test
fun hideAllThenShowAllRoundTripsASection() {
val hidden = DrawerItemVisibility.hideAll(emptySet(), you)
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, hidden))
val shown = DrawerItemVisibility.showAll(hidden, you)
assertEquals(emptySet<NavBarItem>(), shown)
}
@Test
fun showAllOnlyTouchesItsOwnSection() {
val hidden = DrawerItemVisibility.hideAll(DrawerItemVisibility.hideAll(emptySet(), you), system)
val shown = DrawerItemVisibility.showAll(hidden, system)
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, shown))
}
@Test
fun stateHolderPersistsEveryEditAndRestoresDefaults() {
val saved = mutableListOf<Set<NavBarItem>>()
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
state.toggle(NavBarItem.DRAFTS)
state.toggle(NavBarItem.BOOKMARKS)
assertEquals(listOf(setOf(NavBarItem.DRAFTS), setOf(NavBarItem.DRAFTS, NavBarItem.BOOKMARKS)), saved)
assertEquals(2, state.totalHidden())
state.restoreDefault()
assertEquals(emptySet<NavBarItem>(), state.hidden)
assertEquals(0, state.totalHidden())
assertEquals(emptySet<NavBarItem>(), saved.last())
}
@Test
fun stateHolderDoesNotRepublishANoOpEdit() {
// Tapping a mandatory row must not republish the account's NIP-78 settings event.
val saved = mutableListOf<Set<NavBarItem>>()
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
state.toggle(NavBarItem.SETTINGS)
state.restoreDefault()
assertTrue(saved.isEmpty())
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The drawer and its settings screen both render [DrawerSections], so a destination missing from
* every section is invisible in both places at once — no compiler error, no crash, just a screen
* nobody can reach from the menu. These pin the invariants that keep that from happening quietly.
*/
class DrawerSectionsTest {
@Test
fun everyCatalogItemAppearsInADrawerSection() {
val sectioned = DrawerSections.flatMap { it.items }.toSet()
val missing = NavBarCatalog.keys - sectioned
assertEquals(
"a catalog destination is in no drawer section — add it to one in NavBarItem.kt, " +
"or to SdkGatedDrawerItems with the reason it can't be there",
emptySet<Any>(),
missing - SdkGatedDrawerItems,
)
}
@Test
fun noItemIsListedInTwoSections() {
val sectioned = DrawerSections.flatMap { it.items }
assertEquals("an item is listed in more than one drawer section", sectioned.size, sectioned.toSet().size)
}
@Test
fun everySectionedItemResolvesInTheCatalog() {
// The drawer looks each id up in NavBarCatalog and skips misses, so an id with no catalog
// entry would silently render nothing while still occupying a row in the settings screen.
DrawerSections.forEach { section ->
section.items.forEach { item ->
assertTrue("$item has no NavBarCatalog entry", NavBarCatalog.containsKey(item))
}
}
}
@Test
fun sectionsAreOrderedWithCreateBetweenFeedsAndSystem() {
// The drawer renders DrawerSections in order, so this list *is* the menu's layout. Create sits
// between the feeds and System, and is the one section with nothing configurable in it.
assertEquals(
listOf(
DrawerSectionId.YOU,
DrawerSectionId.NAVIGATE,
DrawerSectionId.FEEDS,
DrawerSectionId.CREATE,
DrawerSectionId.SYSTEM,
),
DrawerSections.map { it.id },
)
assertEquals(emptyList<Any>(), DrawerSections.first { it.id == DrawerSectionId.CREATE }.items)
}
@Test
fun everySectionHasItsOwnId() {
val ids = DrawerSections.map { it.id }
assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size)
}
@Test
fun aSectionWithNoCatalogItemsRendersFixedRowsOrNothingAtAll() {
// hasFixedRows is declared on the section but consumed by CatalogSection's `when (section.id)`,
// in another file — so the flag and the branch that honours it can drift apart with no compile
// error. A section that carries neither is unreachable in both directions at once: the settings
// screen skips it on items.isEmpty(), and CatalogSection returns before rendering a heading.
val unreachable = DrawerSections.filter { it.items.isEmpty() && !it.hasFixedRows }
assertEquals(
"a drawer section has no catalog items and no fixed rows, so it renders nowhere — " +
"give it items, set hasFixedRows and a branch in CatalogSection, or delete it",
emptyList<Any>(),
unreachable.map { it.id },
)
}
@Test
fun mandatoryItemsAreActuallyRenderedByASection() {
// A mandatory item that no section renders would be unhideable *and* invisible — the worst
// of both. Settings is mandatory precisely because it is the way back to this configuration.
val sectioned = DrawerSections.flatMap { it.items }.toSet()
assertTrue("a mandatory drawer item is in no section", sectioned.containsAll(MandatoryDrawerItems))
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
* visual result: every transition must settle the keyboard *first*.
*
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NavImeSettleTest {
private fun controllerRecording(order: MutableList<String>): NavHostController =
mockk<NavHostController>(relaxed = true) {
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
{ order.add("navigate") }
every { navigate(any<Route>()) } answers { order.add("navigate") }
every { navigateUp() } answers {
order.add("navigate")
true
}
}
@Test
fun popBackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.popBack()
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
runTest {
// The search tab focuses its field on arrival, so the keyboard is already up when the
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.navBottomBar(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun newStackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.newStack(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun aSlowKeyboardStillHoldsTheNavigationBack() =
runTest {
// The real settler suspends for the length of the IME close animation. Navigation must
// wait for it, not fire alongside it — that overlap is the bug.
val order = mutableListOf<String>()
val nav =
Nav(
controllerRecording(order),
this,
ImeSettler {
delay(250)
order.add("settle")
},
)
nav.popBack()
assertEquals(emptyList<String>(), order)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
}
+33 -4
View File
@@ -254,7 +254,14 @@ val jlinkRuntime =
}
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
// Cross-platform — the release workflow tars this up on every OS.
// Cross-platform — the release workflow archives this on every OS (tar.gz on
// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a
// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is
// runnable on any target after extraction, regardless of which OS built it.
// (The bundled jlink runtime is host-native — you still need to unzip a
// Windows-built image on Windows to actually launch it — but the launcher
// scripts themselves are host-agnostic, which keeps the layout uniform and
// makes ad-hoc cross-machine inspection painless.)
val amyImage =
tasks.register<Sync>("amyImage") {
group = "distribution"
@@ -282,13 +289,35 @@ val amyImage =
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
// Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in
// a backslash) so it resolves the app root without depending on CWD, then
// execs the bundled JRE against lib\*. `chcp 65001` pins the console to
// UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale
// as the installDist launcher patch above. CRLF line endings so cmd.exe
// parses it correctly.
//
// The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..`
// out of DIR to the parent directory — same idiom Gradle's own
// installDist .bat uses to resolve APP_HOME. Java tolerates the `..`
// segment but canonicalising once here keeps every classpath entry and
// error message clean (and matches the loose-directory layout users see
// after unzipping the release archive).
val windowsLauncher =
"@echo off\r\n" +
"chcp 65001 > NUL 2>&1\r\n" +
"setlocal\r\n" +
"set \"DIR=%~dp0..\"\r\n" +
"for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" +
"\"%DIR%\\runtime\\bin\\java.exe\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
doLast {
val binDir = amyImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val launcher = binDir.resolve("amy")
launcher.writeText(unixLauncher)
launcher.setExecutable(true, false)
val unix = binDir.resolve("amy")
unix.writeText(unixLauncher)
unix.setExecutable(true, false)
val windows = binDir.resolve("amy.bat")
windows.writeText(windowsLauncher)
}
}
@@ -668,7 +668,7 @@ class Context(
val filters = relays.associateWith { listOf(responseFilter) }
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -133,7 +133,7 @@ object GeochatCommands {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -167,7 +167,7 @@ object NipCommand {
val remaining = SEARCH_RELAYS.toMutableSet()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -118,7 +118,7 @@ object NostrConnect {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -81,7 +81,7 @@ object SubscribeCommand {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -100,7 +100,7 @@ class ChessRelayFetchHelper(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -99,7 +99,7 @@ class FeedMetadataCoordinator(
val listener =
if (onEvent != null) {
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -295,7 +295,7 @@ class FeedMetadataCoordinator(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -371,7 +371,7 @@ class FeedMetadataCoordinator(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -90,7 +90,7 @@ abstract class PerKeyEoseManager<T, K : Any>(
newEose(queryState, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -86,7 +86,7 @@ abstract class SingleSubEoseManager<T>(
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -44,7 +44,7 @@ class RelayHealthListener(
store.recordConnect(relay.url, TimeUtils.now())
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
/**
* Which player/viewer can render a declared blob, as resolved by
* [RichTextParser.classifyMedia].
*
* The set is deliberately closed: it enumerates the renderers [BaseMediaContent] actually has
* (`MediaUrlImage`, `MediaUrlVideo`, `MediaUrlPdf`), so "no constant fits" — a `null`
* classification — is the honest answer for every other file type rather than a bucket some
* caller has to invent a default for. Audio folds into [VIDEO] because both play through the
* same pipeline; see `RichTextParser.videoExt`.
*/
enum class MediaContentKind {
IMAGE,
VIDEO,
PDF,
}
@@ -61,41 +61,12 @@ class RichTextParser {
val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()
var isImage = false
var isVideo = false
var isPdf = false
// Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash
// and forcing a URL-preview round-trip to rediscover a type the imeta already declared —
// which is why classifyMedia falls back to the extension before giving up.
val kind = classifyMedia(fullUrl, contentType)
if (contentType != null) {
isImage = contentType.startsWith("image/")
// HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl`
// and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the
// null bucket below and the renderer drops back to a plain hyperlink — even though
// the matching extension would have routed it to MediaUrlVideo. Mirror the canonical
// list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType.
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType)
isPdf = contentType.startsWith("application/pdf")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
isPdf = fullUrl.startsWith("data:application/pdf")
}
// Fall back to file-extension detection when the type is still unknown. This covers both
// the no-MIME case and a *malformed* imeta MIME — e.g. Primal iOS emits `m jpeg` instead
// of `m image/jpeg`, which matches none of the `startsWith` prefixes above. Without this
// fallback such a URL returns null and drops to a plain link: that discards the imeta
// `dim`/blurhash (so the loading placeholder can't reserve the image's height and the
// feed jumps once the bitmap arrives) and forces a needless URL-preview network
// round-trip just to rediscover the type the imeta already declared. `data:` URIs carry
// their type in the prefix, so a miss there is genuine — don't extension-probe them.
if (!isImage && !isVideo && !isPdf && !fullUrl.startsWith("data:")) {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
return if (kind == MediaContentKind.IMAGE) {
MediaUrlImage(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -108,7 +79,7 @@ class RichTextParser {
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isVideo) {
} else if (kind == MediaContentKind.VIDEO) {
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -125,7 +96,7 @@ class RichTextParser {
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isPdf) {
} else if (kind == MediaContentKind.PDF) {
MediaUrlPdf(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -582,6 +553,46 @@ class RichTextParser {
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
/**
* Resolves which renderer can display a declared blob — the single decision every media
* renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL.
*
* A declared MIME type wins; the URL extension is the fallback both for the no-MIME case
* and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which
* matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is
* genuine and the base64 payload is never extension-probed.
*
* Returns **null** when nothing can render the file. Callers must not substitute a media
* kind for that null: handing an arbitrary blob — a webxdc app, a zip, an APK — to the
* video player yields a permanently-buffering ExoPlayer where a plain link belongs. The one
* defensible default is on kinds whose *event* already asserts the type (a NIP-71 video
* event is a video however odd its imeta), and those call sites say so explicitly.
*/
fun classifyMedia(
url: String,
mimeType: String?,
): MediaContentKind? {
if (mimeType != null) {
if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE
// HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType].
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/") || isHlsMimeType(mimeType)) return MediaContentKind.VIDEO
if (mimeType.startsWith("application/pdf")) return MediaContentKind.PDF
} else if (url.startsWith("data:")) {
if (url.startsWith("data:image/")) return MediaContentKind.IMAGE
if (url.startsWith("data:video/") || url.startsWith("data:audio/")) return MediaContentKind.VIDEO
if (url.startsWith("data:application/pdf")) return MediaContentKind.PDF
}
if (url.startsWith("data:")) return null
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO
if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF
return null
}
fun isValidURL(url: String?): Boolean = isValidUrl(url)
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* Local, in-memory matcher for the Git Repositories screen search box.
*
* The screen already loads the full set of ngit repository announcements
* (`kind:30617` `GitRepositoryEvent`) the user has subscribed to via their
* follow lists / follow set, so a client-side filter avoids issuing an
* extra NIP-50 relay query for the common "I know its name/topic/host"
* lookup. It also matches on fields the generic NIP-50 search would
* ignore — clone/web URLs, maintainer npubs, the ngit `d` identifier
* — which are exactly what someone browsing repos on gitworkshop /
* ngit tends to remember.
*
* The query is split on whitespace so `"amethyst nostr"` requires each
* term to appear in at least one indexed field of the same repository.
* Every term match is case-insensitive.
*
* Indexed fields:
* - repo name (`name` tag)
* - repo identifier (`d` tag; what appears in the ngit URL path)
* - description
* - hashtags/topics (`t` tags)
* - clone URLs (`clone` tag values)
* - web URLs (`web` tag values)
* - relay URLs the maintainers listen on (`relays` tag values)
* - maintainer pubkeys (both hex and NIP-19 `npub…` form)
* - repo author pubkey (hex and npub)
* - earliest-unique-commit hash (`r … euc`) — lets you paste a commit
* hash from a nostr:naddr and land on the repo
*/
object GitRepositorySearchMatcher {
/**
* @return `true` when [event] matches every whitespace-separated term
* in [query]. An empty query matches nothing (callers should skip the
* filter path in that case).
*/
fun matches(
event: GitRepositoryEvent,
query: String,
): Boolean {
val terms = query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
if (terms.isEmpty()) return false
val haystack = buildHaystack(event)
return terms.all { term ->
val needle = term.lowercase()
// Support "npub1…" queries by resolving them to hex; the hex
// form is already in the haystack via authorNpubs / dTag /
// maintainers.
val hexFromBech32 = tryDecodeNpubToHex(needle)
haystack.any { field -> field.contains(needle) } ||
(hexFromBech32 != null && haystack.any { field -> field.contains(hexFromBech32) })
}
}
private fun buildHaystack(event: GitRepositoryEvent): List<String> {
val out = ArrayList<String>(16)
event.name()?.lowercase()?.let(out::add)
event
.dTag()
.takeIf { it.isNotEmpty() }
?.lowercase()
?.let(out::add)
event.description()?.lowercase()?.let(out::add)
event.hashtags().forEach { out.add(it.lowercase()) }
event.clones().forEach { out.add(it.lowercase()) }
event.webs().forEach { out.add(it.lowercase()) }
event.relays().forEach { out.add(it.lowercase()) }
// Maintainers as hex + npub. Author is an implicit maintainer per
// NIP-34, so include it in both forms too.
val authors = HashSet<String>()
authors.add(event.pubKey)
authors.addAll(event.maintainers())
authors.forEach { hex ->
out.add(hex.lowercase())
hexToNpub(hex)?.let { out.add(it.lowercase()) }
}
event.earliestUniqueCommit()?.lowercase()?.let(out::add)
return out
}
private val WHITESPACE = Regex("\\s+")
private fun tryDecodeNpubToHex(candidate: String): String? {
if (!candidate.startsWith("npub1")) return null
return runCatching {
when (val parsed = Nip19Parser.uriToRoute(candidate)?.entity) {
is NPub -> parsed.hex
else -> null
}
}.getOrNull()
}
private fun hexToNpub(hex: String): String? = runCatching { NPub.create(hex) }.getOrNull()
}
@@ -118,7 +118,7 @@ class BroadcastTracker {
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -294,7 +294,7 @@ class BroadcastTracker {
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
/**
* The short label a user recognises for a distributable file type — "APK", not
* "application/vnd.android.package-archive".
*
* Unmapped types return the raw MIME unchanged, which is the honest fallback: a bare
* `application/x-webxdc` still tells the reader more than an invented label would.
*
* Used by the NIP-82 software-app chips and by the file-attachment card that stands in for any
* blob no viewer can render.
*/
fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
@@ -371,7 +371,7 @@ class OutboxDispatcher(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -415,7 +415,7 @@ class OutboxDispatcher(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ClassifyMediaTest {
@Test
fun webxdcAppIsNotMedia() {
// Regression: a NIP-94 header for a webxdc app (a zip bundle) used to reach the
// ExoPlayer branch, because the only test was `isImage` and everything else fell
// through to video. https://blossom.ditto.pub/<sha256>.xdc, m=application/x-webxdc
assertNull(
RichTextParser.classifyMedia(
"https://blossom.ditto.pub/d810ba7873d710b197fc402c0573cd95ce7d44fff7f904e8f58e48af3a47c107.xdc",
"application/x-webxdc",
),
)
}
@Test
fun unknownTypesAreNotMedia() {
assertNull(RichTextParser.classifyMedia("https://x.com/app.apk", "application/vnd.android.package-archive"))
assertNull(RichTextParser.classifyMedia("https://x.com/archive.zip", "application/zip"))
assertNull(RichTextParser.classifyMedia("https://x.com/notes.txt", "text/plain"))
// No mime at all and an extension we don't render.
assertNull(RichTextParser.classifyMedia("https://x.com/file.xdc", null))
assertNull(RichTextParser.classifyMedia("https://x.com/no-extension-at-all", null))
}
@Test
fun declaredMimeTypesClassify() {
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a", "image/png"))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "video/mp4"))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpeg"))
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a", "application/pdf"))
}
@Test
fun hlsPlaylistMimesAreVideo() {
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/vnd.apple.mpegurl"))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/x-mpegURL"))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpegurl"))
}
@Test
fun extensionIsUsedWhenMimeIsAbsent() {
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", null))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4", null))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.m3u8", null))
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a.pdf", null))
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null))
}
@Test
fun aDeclaredMimeBeatsAContradictingExtension() {
// The check this replaced was an OR — `mime.startsWith("image/") || isImageUrl(url)` —
// so a poster-named video URL classified as an image. A declared MIME is the publisher
// stating the type; the extension is only a guess for when they didn't. Pins the
// precedence against a future "simplification" back to OR-semantics.
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/thumb.jpg", "video/mp4"))
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/clip.mp4", "image/png"))
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/scan.png", "application/pdf"))
}
@Test
fun anUnrecognisedMimeDefersToTheExtensionRatherThanVetoingIt() {
// Precedence applies only to MIMEs we recognise. An unrecognised one means "no usable
// declaration", not "declared unrenderable" — the two are indistinguishable here, and
// treating them alike is what lets [extensionRescuesAMalformedMime] work. So a real
// video mislabelled `application/x-webxdc` still plays…
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/bundle.mp4", "application/x-webxdc"))
// …while the webxdc app that motivated this class stays unrenderable, because nothing
// rescues it: `.xdc` is in no extension list either.
assertNull(RichTextParser.classifyMedia("https://x.com/bundle.xdc", "application/x-webxdc"))
}
@Test
fun extensionRescuesAMalformedMime() {
// Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win
// over "unknown". Preserves the behaviour createMediaContent already documented.
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "jpeg"))
}
@Test
fun queryStringsAndFragmentsAreStripped() {
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg?token=1", null))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4#t=10", null))
assertNull(RichTextParser.classifyMedia("https://x.com/a.xdc?token=1", null))
}
@Test
fun dataUrisAreClassifiedByTheirPrefixOnly() {
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("data:image/png;base64,AAAA", null))
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("data:video/mp4;base64,AAAA", null))
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("data:application/pdf;base64,AAAA", null))
// A data: URI carries its type in the prefix, so a miss there is genuine — the
// payload must never be extension-probed (base64 can end in any letters).
assertNull(RichTextParser.classifyMedia("data:application/zip;base64,AAAAmp4", null))
}
@Test
fun classifyMediaAgreesWithCreateMediaContent() {
// createMediaContent is the long-standing reference for this decision; the two must
// not drift, since half the renderers call one and half the other.
val cases =
listOf(
"https://x.com/a.jpg" to null,
"https://x.com/a" to "image/png",
"https://x.com/a" to "video/mp4",
"https://x.com/a" to "audio/mpeg",
"https://x.com/a" to "application/pdf",
"https://x.com/a" to "application/vnd.apple.mpegurl",
"https://x.com/a.xdc" to "application/x-webxdc",
"https://x.com/a.zip" to "application/zip",
"https://x.com/a.jpg" to "jpeg",
"data:image/png;base64,AAAA" to null,
"data:application/zip;base64,AAAAmp4" to null,
)
cases.forEach { (url, mime) ->
val tags = mime?.let { mapOf(url to imeta(url, it)) } ?: emptyMap()
val expected =
when (RichTextParser().createMediaContent(url, tags, null)) {
is MediaUrlImage -> MediaContentKind.IMAGE
is MediaUrlVideo -> MediaContentKind.VIDEO
is MediaUrlPdf -> MediaContentKind.PDF
null -> null
else -> error("unexpected content type for $url / $mime")
}
assertEquals<MediaContentKind?>(expected, RichTextParser.classifyMedia(url, mime), "disagreement on $url / $mime")
}
}
private fun imeta(
url: String,
mimeType: String,
) = IMetaTag(url = url, properties = mapOf("m" to listOf(mimeType)))
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Pins the ngit-repository-relevant search matcher used by
* `GitRepositoriesScreen`. Every field the matcher promises to index is
* exercised once here so a future refactor that drops one (e.g. relays)
* shows up as a red test.
*/
class GitRepositorySearchMatcherTest {
private val ownerHex = "aa".repeat(32)
private val maintainerHex = "bb".repeat(32)
private val ownerNpub = NPub.create(ownerHex)
private val maintainerNpub = NPub.create(maintainerHex)
private fun repo(
name: String = "amethyst",
dTag: String = "amethyst",
description: String? = "A Nostr client for Android",
clones: List<String> = listOf("https://github.com/vitorpamplona/amethyst.git"),
webs: List<String> = listOf("https://amethyst.social"),
relays: List<String> = listOf("wss://relay.ngit.dev"),
maintainers: List<String> = listOf(maintainerHex),
hashtags: List<String> = listOf("nostr", "android"),
euc: String? = "99614f07e4ffa99dff4143d7457be8923690bbba",
pubKey: String = ownerHex,
): GitRepositoryEvent {
val tags = mutableListOf<Array<String>>()
tags += arrayOf("d", dTag)
tags += arrayOf("name", name)
description?.let { tags += arrayOf("description", it) }
clones.forEach { tags += arrayOf("clone", it) }
webs.forEach { tags += arrayOf("web", it) }
if (relays.isNotEmpty()) tags += arrayOf("relays", *relays.toTypedArray())
if (maintainers.isNotEmpty()) tags += arrayOf("maintainers", *maintainers.toTypedArray())
hashtags.forEach { tags += arrayOf("t", it) }
euc?.let { tags += arrayOf("r", it, "euc") }
return GitRepositoryEvent(
id = "00".repeat(32),
pubKey = pubKey,
createdAt = 0L,
tags = tags.toTypedArray(),
content = "",
sig = "00",
)
}
@Test
fun emptyQueryMatchesNothing() {
// Callers must skip the filter path themselves — the matcher is
// conservative and refuses to accept an empty term list.
assertFalse(GitRepositorySearchMatcher.matches(repo(), ""))
assertFalse(GitRepositorySearchMatcher.matches(repo(), " "))
}
@Test
fun matchesRepoNameCaseInsensitive() {
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "amethyst"))
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "AMET"))
}
@Test
fun matchesRepoIdentifierDTag() {
assertTrue(GitRepositorySearchMatcher.matches(repo(dTag = "ngit-cli"), "ngit-cli"))
}
@Test
fun matchesDescription() {
assertTrue(
GitRepositorySearchMatcher.matches(
repo(description = "A private Nostr messenger"),
"messenger",
),
)
}
@Test
fun matchesHashtag() {
assertTrue(GitRepositorySearchMatcher.matches(repo(hashtags = listOf("kotlin", "mobile")), "kotlin"))
}
@Test
fun matchesCloneUrl() {
assertTrue(
GitRepositorySearchMatcher.matches(
repo(clones = listOf("https://relay.ngit.dev/npub1abc/foo.git")),
"relay.ngit.dev",
),
)
}
@Test
fun matchesWebUrl() {
assertTrue(GitRepositorySearchMatcher.matches(repo(webs = listOf("https://gitworkshop.dev/x")), "gitworkshop"))
}
@Test
fun matchesRelayHost() {
assertTrue(
GitRepositorySearchMatcher.matches(
repo(relays = listOf("wss://relay.damus.io")),
"damus.io",
),
)
}
@Test
fun matchesMaintainerHex() {
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerHex))
}
@Test
fun matchesMaintainerNpub() {
// The `bb…` npub is a valid bech32 pubkey; supplying it as a
// query must resolve to the same hex the tag carries.
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerNpub))
}
@Test
fun matchesAuthorHex() {
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerHex))
}
@Test
fun matchesAuthorNpub() {
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerNpub))
}
@Test
fun matchesEarliestUniqueCommit() {
// Full euc must match; a prefix that lives inside it should too.
assertTrue(GitRepositorySearchMatcher.matches(repo(euc = "99614f07e4ff"), "99614f07"))
}
@Test
fun multipleTermsMustAllMatch() {
val target = repo(name = "amethyst", hashtags = listOf("nostr", "android"))
assertTrue(GitRepositorySearchMatcher.matches(target, "amethyst android"))
// "kotlin" isn't in this repo's fields, so the AND fails.
assertFalse(GitRepositorySearchMatcher.matches(target, "amethyst kotlin"))
}
@Test
fun invalidNpubTreatedAsRawText() {
// "npub1notreallybech32" is not a decodable npub; the matcher
// should still let it match as a raw substring of e.g. the
// description, without throwing.
val target = repo(description = "npub1notreallybech32 is a placeholder")
assertTrue(GitRepositorySearchMatcher.matches(target, "npub1notreallybech32"))
}
}
@@ -90,7 +90,7 @@ class ChessEventBroadcaster(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -212,7 +212,7 @@ fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List<Filter
forward(relay, forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -53,7 +53,7 @@ class RelayLatencyListener(
tracker.recordSent(relay.url, cmd, success)
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -97,7 +97,7 @@ class DmInboxRelayResolverOutboxTest {
filterList.forEach { filter ->
filter.kinds?.forEach { kind ->
script[kind to relay]?.forEach { event ->
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) }
}
}
}
@@ -171,7 +171,7 @@ class OutboxDispatcherTest {
filterList.forEach { filter ->
filter.kinds?.forEach { kind ->
script[kind to relay]?.forEach { event ->
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) }
}
}
}
@@ -1786,7 +1786,7 @@ fun MainContent(
filters = listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -1845,7 +1845,7 @@ fun MainContent(
relays = outbox,
listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -249,7 +249,7 @@ class AccountManager internal constructor(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -164,7 +164,7 @@ class FollowPacksState(
private fun subscribeToDiscovery() {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -45,7 +45,7 @@ fun RelayConnectionManager.subscribeMetadataFor(
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = pubkeys)
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -92,7 +92,7 @@ fun FromThePackFeed(
listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -102,7 +102,7 @@ fun RenderFollowPackCard(
listOf(filter),
listener =
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,

Some files were not shown because too many files have changed in this diff Show More