Compare commits

..
Author SHA1 Message Date
Vitor PamplonaandGitHub 35f96a936d Merge pull request #3872 from vitorpamplona/perf/outbox-no-quadratic-publish
Stop the outbox getting slower with every publish
2026-08-06 17:36:16 -04:00
Vitor PamplonaandClaude Opus 5 36a79d74de Stop the outbox getting slower with every publish
PoolEventOutbox kept its pending publishes in an immutable map and rebuilt
it on every send:

    eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(...))

That copies every entry, per event, so publishing N events copies
1 + 2 + … + N. The relay-set bookkeeping alongside it was the same shape —
needsToUpdateRelays() and updateRelays() each walk every value, and both ran
on every send.

Measured on a bulk push against a relay with ~970k entries resident: 22.7ms
per event, of which ~20.5ms was the outbox. The store fetch feeding the same
loop cost 1.2ms and the configured pace 1ms, so the map was ~90% of the
budget — and the rate decayed as the backlog grew, 45.6 -> 44.6 -> 43.2 ev/s
across three windows.

The map is now LargeCache (ConcurrentHashMap on JVM/Android), so put/get/
remove are O(1) and the cross-thread visibility that @Volatile republishing
provided comes from the map itself.

The relay set is now maintained asymmetrically, because the two directions
are not equally expensive. Adding is exact and cheap: union the event's own
relays, touching the flow only when it actually changes. Deciding a relay may
LEAVE means asking whether any remaining entry still wants it, which is
inherently O(outbox) — so it is swept every SWEEP_EVERY removals, and always
when the outbox empties. Keeping a relay a little too long costs an idle
connection; scanning a million entries to retire it promptly costs the push.

The test asserts the SHAPE of the cost, not a wall-clock budget: equal
windows at the start and end of a 60k-publish run, where the late window
carries ~29x the backlog. Halves were not enough — over 20k publishes the
average backlog only grows 7k to 17k, a 2.4x expected ratio that hid inside
JIT noise, and the first version of this test passed against the very code it
was written to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:09:34 -04:00
Vitor PamplonaandGitHub 7caf4a8a25 Merge pull request #3871 from vitorpamplona/negentropy-window-sizing
negentropy: size reconcile windows from the caller's index, and state the relay's cap
2026-08-06 16:18:45 -04:00
Claude 8555309492 negentropy: audit fixes over the windowing change
A read-back over the two commits before this, rather than a failure —
which is the only way these would have turned up, since every one of them
lives on a path that runs when something has already gone wrong.

**accept() is no longer single-threaded, and its comment said it was.**
"Both phases run sequentially, so no concurrent access" was true right up
until a paged window started running on a reconciler coroutine while the
sync's own delivery consumer was still calling accept(). An unguarded
HashSet between two coroutines can corrupt, and the delivered counter can
lose updates. Now behind a Mutex — with onEvent kept INSIDE it, because
callers are promised it never runs concurrently with itself and some of
them keep unsynchronised state in that callback. pagedWindows becomes an
AtomicInt for the same reason.

**The kotlinx cap parse could take down the whole frame.** `.jsonPrimitive`
throws on an object or array, so a relay putting something structured in
the fourth element would have failed the NEG-ERR and lost the reason with
it — where before that element existed, anything extra was simply ignored.
`as?` restores that. Both mappers are now tested against a structured
fourth element as well as a string one.

**Int overflow in the split fan-out.** `mine + ceiling - 1` wraps when a
window holds close to Int.MAX events, which is reachable on exactly the
corpora this targets; done in Long now.

**The count-driven split cuts N ways, not two.** The work queue is FIFO, so
halving means every internal node's count() runs before the first NEG-OPEN
goes out: on a corpus ~30,000 windows wide that is ~30,000 store counts of
dead time with nothing downloading. Cutting into ceil(count/budget) pieces
(capped at 32) reaches the same corpus in about three levels instead of
fifteen, and pieces that guess wrong are re-split by the same rule.

**The budget moves by CAS.** With reconcileConcurrency > 1 two reconcilers
adjust it at once, and a lost SHRINK is the one that costs something real:
the next window is then asked at a size the relay has already refused.
2026-08-06 20:12:36 +00:00
Claude 18998c897c negentropy: size reconcile windows from the caller's own index
A NEG-OPEN is all-or-nothing at both ends of the wire and neither end can
see the other's size. The relay half has been handled since windowing
landed — refuse, halve, retry. The client half has not: localEntries has to
hold every matching (created_at, id) pair before the first NEG-OPEN goes
out, so peak memory is a property of the CORPUS, not of the window. On a
multi-million-event filter that list is the sync's high-water mark, and it
is built even when the sync then splits into windows that each touch a
fraction of it.

NegentropyLocalIndex is that half. A caller whose store answers by range
passes an index instead of a list, and the engine reads a window's worth at
a time. count() is what makes it work: a window is sized BEFORE the round
trip, so entriesFor() is only ever asked for something bounded. Callers
that pass a list are unchanged — internally the list becomes an index that
sorts once and binary-searches per window, exactly what the engine did
inline before.

targetWindow (0 = off, the old behaviour) turns the two signals into one
loop. Our count splits a window before asking; their refusal shrinks the
target — straight to the relay's stated cap where there is one, halved
where there isn't — and windows that reconcile in one piece grow it back
toward, never past, the caller's number. Neither side knows anything about
the other and the same work queue absorbs both, which is what makes it
adapt rather than need tuning. peerCap carries the relay's number back out,
so a caller can persist it and start the NEXT sync at a window that fits.

The local pre-split deliberately does NOT count against MAX_WINDOWS: that
backstop exists for an overflow loop that never converges, while this split
is driven by a number that provably halves with the range.

Also here, because it is the same loop: page the window that overflowed
rather than the whole filter. A second dense enough to exceed the cap is
reachable — created_at has second granularity and is author-controlled —
and negentropySyncOrFetch used to answer it by re-paging everything,
including every window that had already reconciled cleanly. reconcileWindows
now takes onUnreconcilableWindow and hands that window over; the sweep
carries on with the rest of the range, so a dense second costs that second.
Raw negentropySync/negentropyReconcile callers that pass no hook still get
the exception, unchanged.

pagedFallback stays conservative and now means "any part of this range came
over REQ rather than a reconcile", with pagedWindows saying how much — the
distinction matters to anyone recording coverage, since a paged walk booked
as a completed reconcile would claim a range nothing compared. The existing
over-cap test is updated rather than deleted: its ten events share one
created_at, so the whole filter IS the un-reconcilable window — same events,
now via the window path instead of by abandoning the sync. Its sibling test,
that raw negentropySync still throws, is untouched.
2026-08-06 16:43:20 +00:00
Claude d6b8a54d8a NEG-ERR: state the relay's max_sync_events on an overflow refusal
A client that is refused for matching too much has exactly one thing to
decide — how much smaller to ask next time — and no way to find out. NIP-11
has no field for max_sync_events, so the only route to a window the relay
will answer is to guess and halve, and every wrong guess costs the relay the
snapshot scan that produces the refusal. strfry already states the number in
its rejection text; this makes it a first-class part of the frame.

  ["NEG-ERR", <subId>, <reason>]           unchanged, still what NIP-77 says
  ["NEG-ERR", <subId>, <reason>, <cap>]    when the refusal is about size

Both mappers write the fourth element only when there is one, so a refusal
with nothing to state is byte-identical to before, and both tolerate a
non-numeric fourth element from someone else's relay.

NegErrMessage.statedCap reads either form — the wire field or strfry's
"(2431002 > 1000000)" prose — but only for a refusal that is about SIZE.
That gate is the point of the property: a rate limit or a quota can carry
numbers too, and it does not shrink when the window shrinks, so a client
that mistook one for a cap would shrink its windows forever against a relay
that has no size limit at all.

The relay side sends its own configured cap for the same reason it is cheap:
it had to know the number to refuse.
2026-08-06 16:21:32 +00:00
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 bb95cad98b Audit fixes: one clock per record, no shared mutable list, pin the file format
Deep-audit pass over the branch. Nothing here changes what a band claims;
these are the defects that pass tests and bite later.

- record() read the clock twice PER KIND. A 40-kind map took 80 readings,
  and worse, a span's floor and ceiling were judged against two different
  instants — so a span could be accepted at one end and rejected at the
  other on a clock tick. One read, one instant, for the whole call. The
  aggregate path had the same double read and now shares it.

- legs() handed the SAME MutableList instance to every Filter in a group,
  publishing its accumulator through a public return value. Filters are
  treated as immutable everywhere else; this keeps that true by
  construction rather than by nobody having tried yet.

- The state file's round trip was asserted only for the fields, never for
  the behaviour. Three tests now pin it: per-kind spans survive a restart
  AND still narrow per kind afterwards; the ALL_KINDS sentinel survives
  its negative key through toString/toInt; and a pre-split file (min/max,
  no spans) loads as the claim it always was. Plus the rollback contract
  — `min`/`max` must remain the OUTER edges, since a binary from before
  per-kind spans reads those and would otherwise skip ground it has not
  covered.

Checked and found sound, recorded so the next reader need not re-derive
it: ConcurrentMap.snapshot() copies, so export() cannot be mutated under
a writer; Band is immutable (widen() copies its map), so a shared Band
across threads is safe; merge() keeps old.fullAt, preserving the
re-walk clock across widening; and coveringWindow does NOT regress —
a paged band gave >1 leg before this change too, and a reconciled band
still collapses to one leg and narrows the shared snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:45 +00:00
Vitor PamplonaandClaude Opus 5 74145ee8f3 Code-review fixes: two ways per-kind spans could be recorded and not used
Both found in the review pass over the previous commit, both the same
shape — a band written that no lookup can reach, or reaches wrongly.

- Spans for kinds the filter never named were stored as given. Inert for
  legs(), which only looks up the filter's own kinds, but NOT for
  Band.minCreatedAt — and that is what SyncCoverageFile writes as its
  rollback-compat `min`/`max`. A relay answering with more than it was
  asked for (or a caller whose containment check runs against a
  different filter than the band is keyed by) would push that floor
  below anything the filter's kinds support, so a binary from before
  per-kind spans would read the file and over-claim. The fix, undone
  through the compatibility path it added.

- observedByKind on a filter that names NO kinds was stored per kind,
  while legs() for such a filter reads only ALL_KINDS. The band was
  recorded, persisted, and never consulted: a resume that silently did
  not resume. Collapsed to the union, which is the only claim a
  kind-less filter can make.

Why these were not in the initial diff: both live where the new per-kind
path meets an OLD assumption — that record()'s input is already scoped
to the filter, and that a band's keys are always the filter's kinds.
Neither held once callers began supplying the map themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:08:56 +00:00
Vitor PamplonaandClaude 42a91ffb79 SyncCoverage: one band interval cannot speak for several kinds
A band held ONE created_at interval per (relay, filter). For a filter
naming several kinds that is a claim no walk can support: ask for
`kinds: [0, 30382]`, find profiles going back years and score cards only
from last month, and the band records 2020..now for the pair. The next
run then skips that whole interior for BOTH — so score cards written
inside it are never asked for again, and nothing anywhere says so. A
long-lived kind vouched for a short-lived one.

Band.spans is now per kind. Each carries only the evidence actually
collected for it, so the profile kind keeps its wide interval and the
score kind keeps its narrow one, and legs() re-opens the interior for
the second while still skipping it for the first.

Three things keep the cost of that where it was:

- legs() REGROUPS kinds by the windows they want. Identical coverage —
  the common case, and the only case until they diverge — collapses back
  into one ask, so a filter that produced two legs still produces two
  rather than two per kind. Only a kind whose evidence genuinely differs
  earns its own.
- A finished reconcile needs no per-kind evidence and is given none:
  negentropy compares the filter's whole id set in one pass, so it
  covers every kind in the filter or none. Only the PAGED path changed.
- Filters naming no kinds keep a single span under ALL_KINDS, which is
  the same claim as before, correctly scoped to the case where it is the
  only claim available.

record() takes observedByKind, and SyncCoverage.observe() accumulates it
as events arrive — replacing the pair of hand-rolled vars each caller
kept, and moving the per-event isPlausible guard in with it. A paged
walk over a MULTI-kind filter that supplies none earns no band at all,
loudly, once: attributing one interval to every kind is exactly the
over-claim this removes, and a band that over-claims skips events
silently, which is worse than re-reading them. Single-kind filters are
untouched — there the aggregate always was the per-kind answer.

The state file gains a per-kind `spans` object and keeps `min`/`max` as
the outer edges, so a rollback to a binary from before this reads the
file and behaves as it always did. A file written BEFORE this loads its
one interval under ALL_KINDS — the old, wider claim, kept rather than
discarded because discarding it would re-download every upstream's
corpus once on upgrade. The first per-kind walk replaces it.

All 26 existing SyncCoverage tests pass unchanged, which is the evidence
that single-kind behaviour did not move. The five new ones were checked
against the pre-fix rule reinstated in place: the two behavioural ones
fail there and pass here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:02:26 +00: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
79 changed files with 5443 additions and 836 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
@@ -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
@@ -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()
@@ -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()
@@ -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
@@ -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)
}
}
@@ -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()
}
@@ -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
}
@@ -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"))
}
}
+11 -8
View File
@@ -90,6 +90,16 @@
"Hungarian"
]
},
{
"user": "vitorpamplona",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Swedish"
]
},
{
"user": "maxblake2015",
"languages": [
@@ -103,12 +113,9 @@
]
},
{
"user": "vitorpamplona",
"user": "davotoula",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Swedish"
]
},
@@ -180,10 +187,6 @@
"user": "adhrasreoshiathoi",
"languages": []
},
{
"user": "davotoula",
"languages": []
},
{
"user": "crackadoo",
"languages": []
+26 -4
View File
@@ -236,7 +236,9 @@ val jlinkRuntime =
// Flat app-image: bin/geode launcher + lib/*.jar + runtime/ (the jlink'd JRE) +
// share/geode/ (config.example.toml + the systemd unit). Cross-platform — the
// release workflow tars this up on every OS.
// release workflow archives it on every OS (tar.gz on unix, zip on Windows).
// Both a POSIX shell launcher and a Windows .bat launcher are written so the
// tree layout is uniform regardless of build host.
val geodeImage =
tasks.register<Sync>("geodeImage") {
group = "distribution"
@@ -271,13 +273,33 @@ val geodeImage =
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
// Windows launcher: %~dp0 anchors on the .bat's own directory (drive+
// path, always trailing backslash) so geode.bat works no matter where
// it's invoked from. CRLF for cmd.exe. We do NOT force UTF-8 here — the
// relay is a network daemon that logs and speaks JSON over sockets, and
// its stdout is machine-readable; leaving the console code page alone
// matches the geode launcher on POSIX which similarly doesn't touch
// LANG.
//
// 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. See the matching comment on
// the amy launcher for the rationale (log cleanliness, not correctness).
val windowsLauncher =
"@echo off\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\" -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
doLast {
val binDir = geodeImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val launcher = binDir.resolve("geode")
launcher.writeText(unixLauncher)
launcher.setExecutable(true, false)
val unix = binDir.resolve("geode")
unix.writeText(unixLauncher)
unix.setExecutable(true, false)
val windows = binDir.resolve("geode.bat")
windows.writeText(windowsLauncher)
}
}
@@ -498,6 +498,12 @@ class MirrorWorker(
val syncStartedAt = TimeUtils.now()
var seenMin: Long? = null
var seenMax: Long? = null
// Per KIND as well as in aggregate: one interval for a
// multi-kind filter lets a long-lived kind vouch for a
// short-lived one, and the band then skips the interior for
// both. The aggregate is still tracked because the reconcile
// path records against the leg's floor, not per kind.
val seenByKind = mutableMapOf<Int, SyncCoverage.Span>()
fun observe(event: Event) {
// Same containment as the live path: even a trusted
@@ -509,6 +515,7 @@ class MirrorWorker(
seenMin = minOf(seenMin ?: event.createdAt, event.createdAt)
seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt)
}
SyncCoverage.observe(seenByKind, event.kind, event.createdAt)
handoff.trySendBlocking(event)
} else {
filtered.incrementAndGet()
@@ -537,6 +544,7 @@ class MirrorWorker(
} catch (e: NegentropySyncException) {
seenMin = null
seenMax = null
seenByKind.clear()
// The watchdog matches negentropySync's default rather
// than fetchAllPages' shorter one: a paged catch-up
// sits behind the same slow upstreams.
@@ -558,6 +566,13 @@ class MirrorWorker(
seenMin,
seenMax?.coerceAtMost(syncStartedAt),
paged = true,
// Capped the same way the aggregate is: one
// future-dated event must not lift a kind's ceiling
// past what was actually asked for.
observedByKind =
seenByKind.mapValues { (_, span) ->
SyncCoverage.Span(span.min, span.max.coerceAtMost(syncStartedAt))
},
)
} else {
val legFloor = leg.since ?: initialSince
@@ -100,8 +100,7 @@ class SyncCoverageFile(
root.mapValues { (_, v) ->
val o = v.jsonObject
SyncCoverage.Band(
o.getValue("min").jsonPrimitive.long,
o.getValue("max").jsonPrimitive.long,
spansOf(o),
o["complete"]?.jsonPrimitive?.boolean ?: false,
o["fullAt"]?.jsonPrimitive?.long ?: 0L,
)
@@ -112,6 +111,30 @@ class SyncCoverageFile(
}
}
/**
* The per-kind spans, or the single pre-split span read as covering every
* kind under [SyncCoverage.ALL_KINDS].
*
* A file written before coverage was tracked per kind carries only
* `min`/`max`, and that is exactly the over-wide claim per-kind spans
* exist to stop — so it is loaded as what it always meant rather than
* discarded, and the first paged walk that reports per kind replaces it.
* Dropping it instead would re-download every upstream's corpus once on
* upgrade, which is the cost bands exist to avoid.
*/
private fun spansOf(o: JsonObject): Map<Int, SyncCoverage.Span> {
o["spans"]?.jsonObject?.let { spans ->
return spans.entries.associate { (kind, v) ->
val span = v.jsonObject
kind.toInt() to SyncCoverage.Span(span.getValue("min").jsonPrimitive.long, span.getValue("max").jsonPrimitive.long)
}
}
return mapOf(
SyncCoverage.ALL_KINDS to
SyncCoverage.Span(o.getValue("min").jsonPrimitive.long, o.getValue("max").jsonPrimitive.long),
)
}
@Synchronized
private fun save() {
runCatching {
@@ -121,10 +144,30 @@ class SyncCoverageFile(
put(
key,
buildJsonObject {
// min/max are the outer edges across every
// kind, and are written for two readers: a
// human debugging why an upstream re-synced,
// and a ROLLBACK — a binary from before spans
// were per kind reads these and behaves as it
// always did, rather than failing to parse.
put("min", band.minCreatedAt)
put("max", band.maxCreatedAt)
put("complete", band.complete)
put("fullAt", band.fullAt)
put(
"spans",
buildJsonObject {
band.spans.forEach { (kind, span) ->
put(
kind.toString(),
buildJsonObject {
put("min", span.min)
put("max", span.max)
},
)
}
},
)
},
)
}
@@ -20,8 +20,17 @@
*/
package com.vitorpamplona.geode.mirror
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -127,4 +136,127 @@ class SyncCoverageFileTest {
assertEquals(1_500L, clamped.since, "the band's ceiling wins over the window floor")
assertEquals(2_000L, clamped.until)
}
@Test
fun `per-kind spans survive a restart, including the kindless sentinel`() {
// The file is the one place a per-kind band can be silently flattened
// back into the single interval it replaced, so the round trip is
// pinned rather than assumed — negative sentinel key included, since
// ALL_KINDS goes through toString()/toInt() like any other kind.
val mixed = Filter(kinds = listOf(0, 30382))
val anyKind = Filter(authors = listOf("a".repeat(64)))
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(
relay,
mixed,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
)
it.coverage.record(
relay,
anyKind,
null,
null,
paged = true,
observedByKind = mapOf(1 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)),
)
}
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, mixed)!!
assertEquals(
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
band.spans,
"each kind keeps its own evidence across the restart",
)
// …and the restored band still narrows per kind, which is the half
// that would go unnoticed if only the fields round-tripped.
val legs = reopened.coverage.legs(relay, mixed)
assertEquals(4, legs.size, "the two kinds want different windows")
assertEquals(
mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)),
reopened.coverage.band(relay, anyKind)!!.spans,
"the kindless sentinel survives its negative key",
)
}
}
@Test
fun `a file written before per-kind spans loads as the claim it always was`() {
// Only min/max, no `spans` — what every deployed state file holds today.
// Discarding it would re-download each upstream's corpus once on
// upgrade, so it loads under ALL_KINDS and narrows every kind exactly
// as it did before, until the first per-kind walk replaces it.
val mixed = Filter(kinds = listOf(0, 30382))
val f = tempFile()
val key = "${relay.url} ${mixed.toJson()}"
f.writeText(
Json.encodeToString(
JsonObject.serializer(),
buildJsonObject {
put(
key,
buildJsonObject {
put("min", 1_690_000_000L)
put("max", 1_700_000_000L)
put("complete", false)
put("fullAt", TimeUtils.now())
},
)
},
),
)
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, mixed)!!
assertEquals(mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), band.spans)
val legs = reopened.coverage.legs(relay, mixed)
assertEquals(2, legs.size, "one shared pair of legs — the old behaviour, exactly")
assertEquals(listOf(0, 30382), legs[0].kinds)
}
}
@Test
fun `a rolled-back reader still finds the outer edges it understands`() {
// A binary from before per-kind spans reads `min`/`max` and ignores
// `spans`. Those fields must therefore still be written, and must be
// the OUTER edges — anything narrower would make the old reader skip
// ground it has not covered.
val mixed = Filter(kinds = listOf(0, 30382))
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(
relay,
mixed,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_695_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
)
}
val written =
Json
.parseToJsonElement(f.readText())
.jsonObject.values
.single()
.jsonObject
assertEquals(1_600_000_000L, written.getValue("min").jsonPrimitive.long, "the oldest of any kind")
assertEquals(1_700_000_000L, written.getValue("max").jsonPrimitive.long, "the newest of any kind")
}
}
@@ -46,6 +46,7 @@ import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
object MessageKSerializer : KSerializer<Message> {
override val descriptor: SerialDescriptor =
@@ -112,6 +113,10 @@ object MessageKSerializer : KSerializer<Message> {
is NegErrMessage -> {
add(JsonPrimitive(value.subId))
add(JsonPrimitive(value.reason))
// Only written when there is one: a three-element
// NEG-ERR is what NIP-77 describes, and that is what a
// refusal with nothing to state stays.
value.cap?.let { add(JsonPrimitive(it)) }
}
}
}
@@ -184,6 +189,12 @@ object MessageKSerializer : KSerializer<Message> {
NegErrMessage(
subId = array[1].jsonPrimitive.content,
reason = if (array.size > 2) array[2].jsonPrimitive.content else "",
// Optional, and only a number: a relay that puts something
// else there is telling us nothing rather than breaking the
// frame. `as?` rather than `.jsonPrimitive`, which THROWS on
// an object or array — that would fail the whole message and
// lose the reason, where before this element was ignored.
cap = if (array.size > 3) (array[3] as? JsonPrimitive)?.longOrNull else null,
)
}
@@ -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.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
/**
* The caller's own matching set, read one `created_at` window at a time.
*
* The list overloads of [negentropySync] / [negentropyReconcile] need every
* matching `(created_at, id)` pair before the first NEG-OPEN goes out, which
* makes peak memory a property of the corpus: a multi-million-event filter is
* a multi-million-entry list held for the whole sync, whether or not the sync
* ends up splitting into windows that each touch a fraction of it.
*
* A caller whose store can answer by range doesn't need that. Passing an index
* instead lets the window engine ask for a window's worth at a time, so the
* high-water mark becomes the size of one window — which the engine also sizes,
* from [count], before spending a round trip on it.
*
* Both methods are called on the reconciler coroutines, possibly concurrently
* when `reconcileConcurrency > 1`, and possibly more than once for the same
* window (a window that overflows is re-asked as halves). Implementations
* should be cheap and side-effect free; a store-backed one usually is, since
* these are index scans.
*/
interface NegentropyLocalIndex {
/**
* How many local events fall inside [window], or null when the store
* cannot answer cheaply.
*
* This is what lets the engine split a window BEFORE asking the relay for
* it — the only signal available about our own side, and the one that
* bounds what [entriesFor] will have to materialise. Null disables that
* pre-split for the window; the relay's own refusal is then the only thing
* that shrinks it, exactly as before this method existed.
*/
suspend fun count(window: Filter): Int?
/** The `(created_at, id)` pairs inside [window]. Order does not matter. */
suspend fun entriesFor(window: Filter): List<IdAndTime>
companion object {
/** Nothing held locally: the sync downloads the relay's whole matched set. */
val Empty: NegentropyLocalIndex =
object : NegentropyLocalIndex {
override suspend fun count(window: Filter) = 0
override suspend fun entriesFor(window: Filter) = emptyList<IdAndTime>()
}
/**
* An index over a list already in memory — what the list overloads use,
* so they behave exactly as they did: sorted once, then binary-searched
* per window.
*/
fun of(entries: List<IdAndTime>): NegentropyLocalIndex = if (entries.isEmpty()) Empty else SortedListIndex(entries.sortedBy { it.createdAt })
}
}
private class SortedListIndex(
private val sorted: List<IdAndTime>,
) : NegentropyLocalIndex {
override suspend fun count(window: Filter): Int = slice(window).size
override suspend fun entriesFor(window: Filter): List<IdAndTime> = slice(window)
/**
* The `createdAt`-range slice of [sorted] (ascending by `createdAt`) that
* belongs to `[since, until]` (both inclusive, NIP-01 semantics).
* Binary-searched so window splits stay O(log n) over multi-million sets.
*/
private fun slice(window: Filter): List<IdAndTime> {
val since = window.since
val until = window.until
if (sorted.isEmpty() || (since == null && until == null)) return sorted
val lo = since ?: 0L
val hi = until ?: Long.MAX_VALUE
// first index with createdAt >= lo
var start = 0
var e = sorted.size
while (start < e) {
val mid = (start + e) ushr 1
if (sorted[mid].createdAt < lo) start = mid + 1 else e = mid
}
// first index with createdAt > hi
var end = start
e = sorted.size
while (end < e) {
val mid = (end + e) ushr 1
if (sorted[mid].createdAt <= hi) end = mid + 1 else e = mid
}
return if (start >= end) emptyList() else sorted.subList(start, end)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -92,6 +93,15 @@ class NegentropyStoreSync(
* @param concurrency relays synced at once by [sync] (a relay's own filters stay sequential).
* @param idleTimeoutMs idle watchdog for reconciles / fetches / pages.
* @param publishTimeoutSecs OK-confirmation wait per uploaded event.
* @param targetWindow events per reconcile window, or `0` to snapshot the
* whole filter up front (the default, and what this class always did).
*
* Above zero, the store is read one `created_at` window at a time through
* a [NegentropyLocalIndex] instead: the id snapshot stops being O(matched
* set) — it is the largest thing this class holds — at the price of an
* indexed count + range read per window. Worth turning on exactly when the
* filter matches more than fits comfortably in memory; pointless below
* that, where one snapshot shared by the whole group is cheaper.
*/
class Config(
val down: Boolean = true,
@@ -105,6 +115,7 @@ class NegentropyStoreSync(
val concurrency: Int = 4,
val idleTimeoutMs: Long = 30_000L,
val publishTimeoutSecs: Long = 15,
val targetWindow: Int = 0,
)
/** Outcome of one `(relay, filter)` group. `error` is null on success. */
@@ -166,7 +177,14 @@ class NegentropyStoreSync(
// events (~40 B/entry vs ~1 KB), which matters when a relay hosts a large
// matched set. The events the reconcile decides to UP-publish (the small
// residual haves) are fetched by id on demand in the uploader below.
val localEntries = store.snapshotIdsForNegentropy(listOf(filter))
//
// With a targetWindow, even those 40 B/entry are read per window rather
// than for the whole filter — on a large store that snapshot is the
// biggest thing this class allocates, and it is allocated before the
// first frame goes out.
val windowed = config.targetWindow > 0
val localIndex = if (windowed) StoreWindowIndex(store) else null
val localEntries = if (windowed) emptyList() else store.snapshotIdsForNegentropy(listOf(filter))
val downloaded = AtomicInt(0)
val uploaded = AtomicInt(0)
@@ -210,6 +228,8 @@ class NegentropyStoreSync(
relay = relay,
filter = filter,
localEntries = localEntries,
localIndex = localIndex,
targetWindow = config.targetWindow,
batchSize = config.idChunk,
idleTimeoutMs = config.idleTimeoutMs,
reconcileConcurrency = config.reconcileConcurrency,
@@ -311,3 +331,28 @@ class NegentropyStoreSync(
return stored.load()
}
}
/**
* [NegentropyLocalIndex] over an [IEventStore]: the window engine's per-window
* reads answered straight from the store's `created_at` index.
*
* The windows handed here are the caller's own filter with `since`/`until`
* narrowed, so they can go to the store as-is. A count the store cannot answer
* comes back null rather than throwing — the engine then simply stops
* pre-splitting that window and lets the relay's refusal decide, which is the
* behaviour without an index at all.
*/
private class StoreWindowIndex(
private val store: IEventStore,
) : NegentropyLocalIndex {
override suspend fun count(window: Filter): Int? =
try {
store.count(window)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
override suspend fun entriesFor(window: Filter): List<IdAndTime> = store.snapshotIdsForNegentropy(listOf(window))
}
@@ -43,12 +43,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
* @property window the filter slice that failed.
* @property reason machine-readable category — branch on this to recover.
* @property detail the underlying specifics (a relay's `NEG-ERR` text, `timeout`, …).
* @property cap the relay's own `max_sync_events` when its refusal stated one
* (see [com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage.statedCap]).
* Worth persisting per relay: it is what sizes the first window next time.
*/
class NegentropySyncException(
val relay: NormalizedRelayUrl,
val window: Filter,
val reason: Reason,
val detail: String,
val cap: Long? = null,
) : Exception("NIP-77 sync of $relay failed ($reason): $detail") {
enum class Reason {
/**
@@ -77,6 +77,8 @@ suspend fun negentropySyncFanOut(
relay: NormalizedRelayUrl,
filter: Filter,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
maxEvents: Int = 0,
reqsPerClient: Int = 10,
fetchBatch: Int = 250,
@@ -135,8 +137,6 @@ suspend fun negentropySyncFanOut(
val producer =
launch {
try {
val sorted =
if (localEntries.size > 1) localEntries.sortedBy { it.createdAt } else localEntries
// Windows reconcile round-robin ACROSS the clients so
// server-side snapshot builds parallelize per connection
// (a single connection produced ids at only ~9k/s and
@@ -145,17 +145,18 @@ suspend fun negentropySyncFanOut(
clients = clients,
relay = relay,
filter = filter,
localEntries = sorted,
local = localIndex ?: NegentropyLocalIndex.of(localEntries),
idleTimeoutMs = idleTimeoutMs,
batchSize = fetchBatch,
reconcileConcurrency = reconcileConcurrency,
targetWindow = targetWindow,
onWindow = { windows.incrementAndFetch() },
onNeed = {
need.addAndFetch(it)
},
onHave = { have.addAndFetch(it) },
sendNeedBatch = { batch -> idBatches.send(batch) },
sendHaveBatch = if (localEntries.isEmpty()) null else { _ -> },
sendHaveBatch = if (localEntries.isEmpty() && localIndex == null) null else { _ -> },
)
} finally {
idBatches.close()
@@ -66,12 +66,17 @@ import kotlin.math.min
* @property downloaded distinct events actually delivered through `onEvent`.
* @property windows number of `created_at` windows the matched set was split
* into (`1` when the relay reconciled the whole filter in one shot).
* @property peerCap the relay's own `max_sync_events`, when a refusal during
* this sync stated one. Worth persisting per relay: it is the number that
* sizes the first window of the NEXT sync, and it is not discoverable any
* other way.
*/
class NegentropySyncResult(
val needCount: Int,
val haveCount: Int,
val downloaded: Int,
val windows: Int,
val peerCap: Long? = null,
)
/**
@@ -162,12 +167,16 @@ suspend fun INostrClient.negentropySync(
reconcileConcurrency: Int = 1,
idBufferBatches: Int = maxConcurrentReqs * 4,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: suspend (Event) -> Unit,
): NegentropySyncResult {
val need = AtomicInt(0)
val windows = AtomicInt(0)
var downloaded = 0
var peerCap: Long? = null
// Pin the relay in the pool's "desired" set for the whole sync. A NEG-OPEN is not
// a REQ, so during a reconcile round (before that window's first download REQ
@@ -195,8 +204,11 @@ suspend fun INostrClient.negentropySync(
maxConcurrentReqs = maxConcurrentReqs,
reconcileConcurrency = reconcileConcurrency,
idBufferBatches = idBufferBatches,
localEntries = localEntries,
local = localIndex ?: NegentropyLocalIndex.of(localEntries),
targetWindow = targetWindow,
onUnreconcilableWindow = onUnreconcilableWindow,
onWindow = { windows.incrementAndFetch() },
onPeerCap = { peerCap = it },
// Only accumulate here; progress is reported from the
// single consumer loop below so the user callback is never
// invoked from two coroutines at once.
@@ -228,6 +240,7 @@ suspend fun INostrClient.negentropySync(
haveCount = 0,
downloaded = downloaded,
windows = windows.load(),
peerCap = peerCap,
)
}
@@ -241,6 +254,9 @@ suspend fun INostrClient.negentropySync(
reconcileConcurrency: Int = 1,
idBufferBatches: Int = maxConcurrentReqs * 4,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: suspend (Event) -> Unit,
): NegentropySyncResult =
@@ -254,6 +270,9 @@ suspend fun INostrClient.negentropySync(
reconcileConcurrency = reconcileConcurrency,
idBufferBatches = idBufferBatches,
localEntries = localEntries,
localIndex = localIndex,
targetWindow = targetWindow,
onUnreconcilableWindow = onUnreconcilableWindow,
onProgress = onProgress,
onEvent = onEvent,
)
@@ -263,16 +282,28 @@ suspend fun INostrClient.negentropySync(
*
* @property downloaded distinct events delivered through `onEvent` (across whichever
* path ran).
* @property pagedFallback `true` if negentropy could not reconcile and the events
* came from [fetchAllPages] instead.
* @property pagedFallback `true` if ANY part of the range came from
* [fetchAllPages] rather than a reconcile — either the whole filter (the
* relay could not reconcile at all) or the individual windows counted by
* [pagedWindows]. Deliberately conservative: a caller recording what it has
* covered must not book a paged walk as a completed reconcile, and one
* un-reconcilable second in the range is enough to make that claim untrue.
* @property negentropy the negentropy outcome when it succeeded; `null` on fallback.
* @property fallbackCause why negentropy was abandoned; `null` when it succeeded.
* @property fallbackCause why negentropy was abandoned for the WHOLE filter;
* `null` when it was not — including when individual windows were paged, which
* have no single cause between them.
* @property pagedWindows how many individual `created_at` windows were paged
* inside an otherwise-successful negentropy sync — seconds so dense the relay
* would not reconcile them at any window size. `0` for almost every sync;
* non-zero means part of the range came over REQ and is subject to a paged
* walk's limits rather than a reconcile's guarantees.
*/
class NegentropyOrFetchResult(
val downloaded: Int,
val pagedFallback: Boolean,
val negentropy: NegentropySyncResult?,
val fallbackCause: NegentropySyncException?,
val pagedWindows: Int = 0,
)
/**
@@ -298,6 +329,7 @@ class NegentropyOrFetchResult(
* Use [negentropySync] directly if you want to decide the fallback yourself (try
* another relay, narrow the filter, abort, …) instead of always paging.
*/
@OptIn(ExperimentalAtomicApi::class)
suspend fun INostrClient.negentropySyncOrFetch(
relay: NormalizedRelayUrl,
filter: Filter,
@@ -308,22 +340,36 @@ suspend fun INostrClient.negentropySyncOrFetch(
reconcileConcurrency: Int = 1,
idBufferBatches: Int = maxConcurrentReqs * 4,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: suspend (Event) -> Unit,
): NegentropyOrFetchResult {
val seen = HashSet<HexKey>()
var delivered = 0
val pagedWindows = AtomicInt(0)
// Shared dedup + cap across both phases. Returns true if the event was new and
// delivered. Both phases run sequentially, so no concurrent access.
suspend fun accept(event: Event): Boolean {
if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) {
delivered++
onEvent(event)
return true
// Shared dedup + cap across every path that delivers.
//
// The lock is not optional. The two phases used to run strictly one after
// the other, but a paged window now runs DURING the negentropy phase, on a
// reconciler coroutine, while the sync's own delivery consumer is calling
// this too — an unguarded HashSet between them can corrupt, and the count
// can lose updates. onEvent stays INSIDE the lock deliberately: callers are
// promised it never runs concurrently with itself, and some of them keep
// unsynchronised state in it.
val gate = Mutex()
suspend fun accept(event: Event): Boolean =
gate.withLock {
if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) {
delivered++
onEvent(event)
true
} else {
false
}
}
return false
}
return try {
val result =
@@ -337,9 +383,32 @@ suspend fun INostrClient.negentropySyncOrFetch(
reconcileConcurrency = reconcileConcurrency,
idBufferBatches = idBufferBatches,
localEntries = localEntries,
localIndex = localIndex,
targetWindow = targetWindow,
// One second the relay will not reconcile at any size costs
// that second, not the sync. Without this the exception below
// catches it and re-pages the WHOLE filter — every window that
// already reconciled cleanly walked again over REQ, which on a
// large corpus is the entire cost negentropy was there to save.
onUnreconcilableWindow = { window ->
pagedWindows.incrementAndFetch()
val pageTimeoutMs = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
fetchAllPages(relay, listOf(window), pageTimeoutMs) { event ->
if (accept(event)) onProgress?.invoke(delivered, delivered)
}
},
onProgress = onProgress,
) { accept(it) }
NegentropyOrFetchResult(delivered, pagedFallback = false, negentropy = result, fallbackCause = null)
NegentropyOrFetchResult(
delivered,
// Any paged window makes this not a clean reconcile — see the
// property doc: under-reporting it would let a caller record
// coverage it never compared.
pagedFallback = pagedWindows.load() > 0,
negentropy = result,
fallbackCause = null,
pagedWindows = pagedWindows.load(),
)
} catch (e: NegentropySyncException) {
// Negentropy couldn't enumerate the set — page the whole filter instead,
// skipping anything the negentropy attempt already delivered. fetchAllPages
@@ -349,7 +418,13 @@ suspend fun INostrClient.negentropySyncOrFetch(
fetchAllPages(relay, listOf(pageFilter), pageTimeoutMs) { event ->
if (accept(event)) onProgress?.invoke(delivered, delivered)
}
NegentropyOrFetchResult(delivered, pagedFallback = true, negentropy = null, fallbackCause = e)
NegentropyOrFetchResult(
delivered,
pagedFallback = true,
negentropy = null,
fallbackCause = e,
pagedWindows = pagedWindows.load(),
)
}
}
@@ -363,6 +438,8 @@ suspend fun INostrClient.negentropySyncOrFetch(
reconcileConcurrency: Int = 1,
idBufferBatches: Int = maxConcurrentReqs * 4,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: suspend (Event) -> Unit,
): NegentropyOrFetchResult =
@@ -376,6 +453,8 @@ suspend fun INostrClient.negentropySyncOrFetch(
reconcileConcurrency = reconcileConcurrency,
idBufferBatches = idBufferBatches,
localEntries = localEntries,
localIndex = localIndex,
targetWindow = targetWindow,
onProgress = onProgress,
onEvent = onEvent,
)
@@ -411,9 +490,12 @@ private suspend fun INostrClient.syncPipeline(
maxConcurrentReqs: Int,
reconcileConcurrency: Int,
idBufferBatches: Int,
localEntries: List<IdAndTime>,
local: NegentropyLocalIndex,
targetWindow: Int,
onWindow: () -> Unit,
onNeed: (Int) -> Unit,
onPeerCap: ((Long) -> Unit)?,
onUnreconcilableWindow: (suspend (Filter) -> Unit)?,
deliver: suspend (Event) -> Unit,
) = coroutineScope {
val idBatches = Channel<List<HexKey>>(idBufferBatches.coerceAtLeast(1))
@@ -430,21 +512,20 @@ private suspend fun INostrClient.syncPipeline(
}
}
// reconcileWindows needs the local set sorted by createdAt (it binary-searches
// each window's slice). Empty/singleton sets are already trivially sorted.
val sortedLocal = if (localEntries.size > 1) localEntries.sortedBy { it.createdAt } else localEntries
reconcileWindows(
clients = listOf(this@syncPipeline),
relay = relay,
filter = filter,
localEntries = sortedLocal,
local = local,
idleTimeoutMs = idleTimeoutMs,
batchSize = fetchBatch,
reconcileConcurrency = reconcileConcurrency,
targetWindow = targetWindow,
onWindow = onWindow,
onNeed = onNeed,
onHave = {},
onPeerCap = onPeerCap,
onUnreconcilableWindow = onUnreconcilableWindow,
sendNeedBatch = { batch -> idBatches.send(batch) },
sendHaveBatch = null,
)
@@ -455,16 +536,29 @@ private suspend fun INostrClient.syncPipeline(
/**
* The shared window engine behind [negentropySync] and [negentropyReconcile]:
* reconciles [filter] against [localEntries], splitting into `created_at`
* windows whenever the relay rejects the set as too large, with up to
* [reconcileConcurrency] windows reconciling at once from a shared work
* queue. Each window's local subset is sliced out of [localEntries] (which
* MUST be sorted by `createdAt`) so both sides always reconcile the same
* slice of the timeline.
* reconciles [filter] against [local], splitting into `created_at` windows,
* with up to [reconcileConcurrency] windows reconciling at once from a shared
* work queue. Each window reconciles against that window's slice of [local], so
* both sides always compare the same slice of the timeline.
*
* Two independent things split a window, and the same queue absorbs both:
*
* - **The relay refuses it** (strfry's `max_sync_events`). Known only after a
* round trip, and the only signal available about THEIR size.
* - **We hold more than [targetWindow] in it**, per [NegentropyLocalIndex.count],
* which is known before the round trip and is what bounds the entries this
* engine asks [local] to materialise. Off when [targetWindow] is `0` (the
* default), which is the pre-existing behaviour: one window until refused.
*
* Neither side can see the other's size, so [targetWindow] adapts within the
* sync: a refusal shrinks it — straight to the relay's own cap when the refusal
* states one ([NegErrMessage.statedCap]), halved when it does not — and windows
* that reconcile in one piece grow it back toward, never past, the caller's
* number.
*
* Throws [NegentropySyncException] for any window negentropy cannot reconcile
* (a minimal window still over the cap, or an unavailable/erroring relay); the
* failure cancels the whole scope.
* (a minimal window still over the cap with no [onUnreconcilableWindow] to hand
* it to, or an unavailable/erroring relay); the failure cancels the whole scope.
*/
@OptIn(ExperimentalAtomicApi::class)
internal suspend fun reconcileWindows(
@@ -474,13 +568,21 @@ internal suspend fun reconcileWindows(
clients: List<INostrClient>,
relay: NormalizedRelayUrl,
filter: Filter,
localEntries: List<IdAndTime>,
local: NegentropyLocalIndex,
idleTimeoutMs: Long,
batchSize: Int,
reconcileConcurrency: Int,
targetWindow: Int = 0,
onWindow: () -> Unit,
onNeed: (Int) -> Unit,
onHave: (Int) -> Unit,
onPeerCap: ((Long) -> Unit)? = null,
// Given a minimal window the relay will not reconcile at any size, instead
// of throwing. The caller drains it however it can (paging it over REQ) and
// the sweep carries on with the rest of the filter. It runs ON the reconciler
// that hit the window, so a slow drain holds that reconciler — with
// reconcileConcurrency = 1 the rest of the sweep waits for it.
onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null,
sendNeedBatch: suspend (List<HexKey>) -> Unit,
sendHaveBatch: (suspend (List<HexKey>) -> Unit)?,
) = coroutineScope {
@@ -503,6 +605,65 @@ internal suspend fun reconcileWindows(
// the tenshundreds, so the cap is orders of magnitude above any real sync.
val totalWindows = AtomicInt(1)
// The largest window this sync will ask for, in events. Shrinks on a
// refusal, recovers toward the caller's number on clean windows, and is
// read only where a local count exists to compare it against — with
// targetWindow at 0 nothing below this line does anything.
val budget = AtomicInt(targetWindow)
// Every budget move goes through here. With reconcileConcurrency > 1 two
// reconcilers adjust it at once, and read-then-store can drop one of them —
// a lost SHRINK being the one that costs something real, since the next
// window is then asked at a size the relay has already refused.
fun budgetTo(next: (Int) -> Int) {
while (true) {
val now = budget.load()
val want = next(now)
if (want == now || budget.compareAndSet(now, want)) return
}
}
/**
* Cuts `[lo, hi]` into [pieces] equal spans of time and queues them all. A
* window already at the floor is left alone — `created_at` is in seconds, so
* that is where splitting ends, not a tuning choice. Both callers check that
* themselves; the guard here is so a third one cannot silently lose a window.
*
* [pieces] > 2 exists for the count-driven split, where we know HOW FAR over
* the budget a window is and can land near the right size in one step.
* Halving instead costs a store count per level of a tree that can be ~15
* deep on a large corpus, and — since the queue is FIFO — every one of those
* counts happens before the first window is reconciled at all.
*/
suspend fun splitInto(
pendingWindow: Filter,
lo: Long,
hi: Long,
pieces: Int = 2,
) {
if (hi - lo <= MIN_WINDOW_SECONDS) return
val span = hi - lo + 1
// Never more pieces than there are seconds to give them.
val n = pieces.toLong().coerceIn(2L, minOf(span, MAX_SPLIT_FANOUT.toLong())).toInt()
val step = span / n
remaining.addAndFetch(n - 1)
var start = lo
repeat(n) { i ->
val last = i == n - 1
// The top piece KEEPS this window's original `until` (which may be
// null = unbounded). Replacing null with `now()` here would drop
// every event dated after now() (clock skew) once any split happens,
// while the un-split path would have included them.
if (last) {
pending.send(pendingWindow.copy(since = start, until = pendingWindow.until))
} else {
val end = start + step - 1
pending.send(pendingWindow.copy(since = start, until = end))
start = end + 1
}
}
}
val reconcilers =
List(reconcileConcurrency.coerceAtLeast(1)) { reconcilerIndex ->
launch {
@@ -510,11 +671,34 @@ internal suspend fun reconcileWindows(
for (window in pending) {
coroutineContext.ensureActive()
val lo = window.since ?: 0L
val hi = window.until ?: TimeUtils.now()
// Our own side, before the round trip. Deliberately NOT
// counted against MAX_WINDOWS: that backstop guards against
// an overflow loop that never converges, while this split is
// driven by a number that provably halves with the range.
val ceiling = budget.load()
if (ceiling > 0 && hi - lo > MIN_WINDOW_SECONDS) {
val mine = local.count(window)
if (mine != null && mine > ceiling) {
// How many windows this one is worth, not just "two":
// the count says how far over budget we are, and
// uneven density is corrected by the same check on
// each piece.
// Long arithmetic: `mine` can be near Int.MAX on a
// corpus this size, and the +ceiling would wrap.
val over = (mine.toLong() + ceiling - 1) / ceiling
splitInto(window, lo, hi, pieces = over.coerceAtMost(MAX_SPLIT_FANOUT.toLong()).toInt())
continue
}
}
val outcome =
client.reconcileStreaming(
relay = relay,
filter = window,
localEntries = entriesForWindow(localEntries, window.since, window.until),
localEntries = local.entriesFor(window),
idleTimeoutMs = idleTimeoutMs,
fetchBatch = batchSize,
onNeed = onNeed,
@@ -526,21 +710,59 @@ internal suspend fun reconcileWindows(
when (outcome) {
is ReconcileOutcome.Complete -> {
onWindow()
// A window that fitted is evidence the budget can
// recover — gently, and never past what the caller
// asked for, so a sync that met one dense stretch
// does not stay small for the rest of the timeline.
if (targetWindow > 0) {
budgetTo { now ->
if (now >= targetWindow) {
now
} else {
minOf(targetWindow, (now * BUDGET_GROWTH).toInt().coerceAtLeast(now + 1))
}
}
}
if (remaining.decrementAndFetch() == 0) pending.close()
}
is ReconcileOutcome.Overflow -> {
val lo = window.since ?: 0L
val hi = window.until ?: TimeUtils.now()
// What they will take, when they said so: one step
// instead of a halving ladder, for this sync and —
// via onPeerCap — for whatever the caller persists.
outcome.cap?.let { cap ->
onPeerCap?.invoke(cap)
if (targetWindow > 0) {
val fitted =
(cap * CAP_MARGIN)
.coerceIn(1.0, Int.MAX_VALUE.toDouble())
.toInt()
budgetTo { now -> minOf(now, fitted) }
}
}
if (outcome.cap == null && targetWindow > 0) {
// No number to go on: halve and find out.
budgetTo { now -> (now / 2).coerceAtLeast(1) }
}
if (hi - lo <= MIN_WINDOW_SECONDS) {
// A minimal window that still overflows: negentropy
// genuinely can't enumerate this slice. Surface it —
// paging is the caller's call.
// A minimal window that still overflows:
// negentropy genuinely can't enumerate this
// slice. Hand it to the caller if it has a way
// to drain it, otherwise surface it — paging is
// the caller's call either way.
val fallback = onUnreconcilableWindow
if (fallback != null) {
fallback(window)
onWindow()
if (remaining.decrementAndFetch() == 0) pending.close()
continue
}
throw NegentropySyncException(
relay = relay,
window = window,
reason = NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS,
detail = "created_at window [$lo, $hi] still exceeds the relay's max_sync_events",
cap = outcome.cap,
)
}
if (totalWindows.addAndFetch(2) > MAX_WINDOWS) {
@@ -554,15 +776,7 @@ internal suspend fun reconcileWindows(
detail = "created_at window split exceeded $MAX_WINDOWS windows without converging; the relay likely rejects negentropy with an overflow-looking error",
)
}
val mid = lo + (hi - lo) / 2
remaining.incrementAndFetch()
// The lower child gets the finite midpoint; the upper child
// KEEPS this window's original `until` (which may be null =
// unbounded). Replacing null with `now()` here would drop
// every event dated after now() (clock skew) once any split
// happens, while the un-split path would have included them.
pending.send(window.copy(since = lo, until = mid))
pending.send(window.copy(since = mid + 1, until = window.until))
splitInto(window, lo, hi)
}
is ReconcileOutcome.Failed ->
@@ -580,46 +794,17 @@ internal suspend fun reconcileWindows(
reconcilers.joinAll()
}
/**
* The `createdAt`-range slice of [sorted] (ascending by `createdAt`) that
* belongs to the window `[since, until]` (both inclusive, NIP-01 semantics).
* Binary-searched so window splits stay O(log n) over multi-million local sets.
*/
private fun entriesForWindow(
sorted: List<IdAndTime>,
since: Long?,
until: Long?,
): List<IdAndTime> {
if (sorted.isEmpty() || (since == null && until == null)) return sorted
val lo = since ?: 0L
val hi = until ?: Long.MAX_VALUE
// first index with createdAt >= lo
var start = 0
var e = sorted.size
while (start < e) {
val mid = (start + e) ushr 1
if (sorted[mid].createdAt < lo) start = mid + 1 else e = mid
}
// first index with createdAt > hi
var end = start
e = sorted.size
while (end < e) {
val mid = (end + e) ushr 1
if (sorted[mid].createdAt <= hi) end = mid + 1 else e = mid
}
return if (start >= end) emptyList() else sorted.subList(start, end)
}
private sealed interface ReconcileOutcome {
/** Reconciliation completed; every id was streamed to the downloader. */
object Complete : ReconcileOutcome
/** Relay rejected the set as too large (strfry `max_sync_events`). */
object Overflow : ReconcileOutcome
/**
* Relay rejected the set as too large (strfry `max_sync_events`).
* [cap] is the relay's own limit when the refusal stated one.
*/
class Overflow(
val cap: Long?,
) : ReconcileOutcome
/** Reconciliation could not complete; [detail] says why. */
class Failed(
@@ -633,11 +818,14 @@ private sealed interface ReconcileOutcome {
* @property needCount ids the relay has that the local set lacks (streamed to `onNeedIds`).
* @property haveCount ids the local set has that the relay lacks (streamed to `onHaveIds`).
* @property windows number of `created_at` windows the reconcile split into.
* @property peerCap the relay's own `max_sync_events`, when a refusal during
* this reconcile stated one.
*/
class NegentropyReconcileResult(
val needCount: Int,
val haveCount: Int,
val windows: Int,
val peerCap: Long? = null,
)
/**
@@ -682,15 +870,19 @@ suspend fun INostrClient.negentropyReconcile(
relay: NormalizedRelayUrl,
filter: Filter,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
batchSize: Int = 500,
idleTimeoutMs: Long = 120_000L,
reconcileConcurrency: Int = 1,
onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null,
onHaveIds: (suspend (List<HexKey>) -> Unit)? = null,
onNeedIds: suspend (List<HexKey>) -> Unit,
): NegentropyReconcileResult {
val need = AtomicInt(0)
val have = AtomicInt(0)
val windows = AtomicInt(0)
var peerCap: Long? = null
// Same connection-pinning trick as negentropySync: a NEG-OPEN is not a REQ,
// so without a live subscription the pool would consider the relay unwanted
@@ -698,24 +890,20 @@ suspend fun INostrClient.negentropyReconcile(
val keepAliveSubId = newSubId()
subscribe(keepAliveSubId, mapOf(relay to listOf(Filter(ids = listOf(KEEP_ALIVE_ID)))), null)
try {
val sorted =
if (localEntries.size > 1) {
localEntries.sortedBy { it.createdAt }
} else {
localEntries
}
reconcileWindows(
clients = listOf(this),
relay = relay,
filter = filter,
localEntries = sorted,
local = localIndex ?: NegentropyLocalIndex.of(localEntries),
idleTimeoutMs = idleTimeoutMs,
batchSize = batchSize,
reconcileConcurrency = reconcileConcurrency,
targetWindow = targetWindow,
onWindow = { windows.incrementAndFetch() },
onNeed = { need.addAndFetch(it) },
onHave = { have.addAndFetch(it) },
onPeerCap = { peerCap = it },
onUnreconcilableWindow = onUnreconcilableWindow,
sendNeedBatch = onNeedIds,
sendHaveBatch = onHaveIds,
)
@@ -727,6 +915,7 @@ suspend fun INostrClient.negentropyReconcile(
needCount = need.load(),
haveCount = have.load(),
windows = windows.load(),
peerCap = peerCap,
)
}
@@ -734,9 +923,12 @@ suspend fun INostrClient.negentropyReconcile(
relay: String,
filter: Filter,
localEntries: List<IdAndTime> = emptyList(),
localIndex: NegentropyLocalIndex? = null,
targetWindow: Int = 0,
batchSize: Int = 500,
idleTimeoutMs: Long = 120_000L,
reconcileConcurrency: Int = 1,
onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null,
onHaveIds: (suspend (List<HexKey>) -> Unit)? = null,
onNeedIds: suspend (List<HexKey>) -> Unit,
): NegentropyReconcileResult =
@@ -744,9 +936,12 @@ suspend fun INostrClient.negentropyReconcile(
relay = RelayUrlNormalizer.normalize(relay),
filter = filter,
localEntries = localEntries,
localIndex = localIndex,
targetWindow = targetWindow,
batchSize = batchSize,
idleTimeoutMs = idleTimeoutMs,
reconcileConcurrency = reconcileConcurrency,
onUnreconcilableWindow = onUnreconcilableWindow,
onHaveIds = onHaveIds,
onNeedIds = onNeedIds,
)
@@ -895,7 +1090,7 @@ private suspend fun INostrClient.reconcileStreaming(
clock.bump()
if (msg.subId == subId) {
sawNegFrame = true
incoming.trySend(NegFrame.Err(msg.reason))
incoming.trySend(NegFrame.Err(msg.reason, msg.statedCap))
}
}
@@ -965,7 +1160,11 @@ private suspend fun INostrClient.reconcileStreaming(
when (frame) {
is NegFrame.Err ->
return if (isOverflow(frame.reason)) ReconcileOutcome.Overflow else ReconcileOutcome.Failed(frame.reason)
return if (isOverflow(frame.reason)) {
ReconcileOutcome.Overflow(frame.cap)
} else {
ReconcileOutcome.Failed(frame.reason)
}
is NegFrame.Msg -> {
val result = session.processMessage(frame.payload)
@@ -1014,6 +1213,8 @@ private sealed interface NegFrame {
class Err(
val reason: String,
// The relay's own max_sync_events, when the refusal stated one.
val cap: Long? = null,
) : NegFrame
}
@@ -1041,13 +1242,7 @@ private sealed interface NegFrame {
* [reconcileWindows] also caps the total window count as a wording-independent
* backstop, so a novel overflow-looking-but-not-shrinking error can never storm.
*/
internal fun isOverflow(reason: String): Boolean =
reason.contains("too many records", ignoreCase = true) ||
reason.contains("too many results", ignoreCase = true) ||
reason.contains("too many query results", ignoreCase = true) ||
reason.contains("result set too large", ignoreCase = true) ||
reason.contains("results too large", ignoreCase = true) ||
reason.contains("max_sync_events", ignoreCase = true)
internal fun isOverflow(reason: String): Boolean = NegErrMessage.isOverflow(reason)
/**
* A relay that advertises NIP-77 but refuses it at runtime signals the refusal with
@@ -1159,6 +1354,30 @@ private const val MIN_WINDOW_SECONDS = 1L
*/
private const val MAX_WINDOWS = 100_000
/**
* Most pieces one count-driven split may cut a window into. Bounds both the
* queue and the depth: with 32, a corpus 30,000 windows wide is reached in
* three levels instead of fifteen, and the pieces that guessed wrong are
* re-split by the same rule.
*/
private const val MAX_SPLIT_FANOUT = 32
/**
* How much of a relay's stated `max_sync_events` a window actually aims for.
* The margin absorbs what the relay gains between stating that number and
* answering the next NEG-OPEN — asking for exactly the cap would be refused
* again by anything still being written to.
*/
private const val CAP_MARGIN = 0.8
/**
* How fast a shrunk window grows back toward the caller's target, per window
* that reconciled in one piece. Multiplicative and gentle on purpose: too small
* costs an extra round trip, too big costs a refused NEG-OPEN plus the snapshot
* scan the relay did before refusing it.
*/
private const val BUDGET_GROWTH = 1.25
/** Bounded buffer between the download workers and the single delivery consumer. */
private const val DELIVERY_BUFFER = 256
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
@@ -64,23 +65,55 @@ class SyncCoverage(
private val now: () -> Long = { TimeUtils.now() },
private val onChange: () -> Unit = {},
) {
/** A covered `created_at` interval, inclusive at both ends. */
data class Span(
val min: Long,
val max: Long,
) {
fun widen(other: Span) = Span(minOf(min, other.min), maxOf(max, other.max))
}
/**
* What is already covered for one (relay, filter) pair.
*
* [spans] is PER KIND, and that is the whole point of it. A band used to
* hold one interval for the entire filter, which is a claim no multi-kind
* walk can support: ask for `kinds: [0, 30382]`, see profiles back to 2020
* and score cards only from 2025, and the band reads 2020..2026 — so the
* next run skips 2020..2025 for BOTH, and the score cards in that interior
* are never asked for again. A long-lived kind vouched for a short-lived
* one. Per kind, each carries only the evidence actually collected for it.
*
* Filters that name no kinds at all cannot be split, so they keep a single
* span under [ALL_KINDS] — the same claim as before, correctly scoped to
* the case where it is the only claim available.
*
* [complete] is the difference between "we walked this span" (a paged
* fetch) and "we are in sync below this point" (a finished negentropy
* reconcile, which compared the whole range). Only a complete band may
* skip its older leg.
* skip its older leg. It is a property of the BAND rather than of a span:
* a reconcile compares the filter's whole id set at once, so it either
* covers every kind in it or none.
*
* [fullAt] is when the last pass that started from nothing finished — the
* clock for the periodic re-walk.
*/
data class Band(
val minCreatedAt: Long,
val maxCreatedAt: Long,
val spans: Map<Int, Span>,
val complete: Boolean = false,
val fullAt: Long = 0,
)
) {
/** The outer edges across every kind — for logging and for the file's compatibility fields. */
val minCreatedAt: Long get() = spans.values.minOfOrNull { it.min } ?: 0
val maxCreatedAt: Long get() = spans.values.maxOfOrNull { it.max } ?: 0
/** Widen each kind by its counterpart, keeping kinds only one side knows. */
fun widen(other: Band): Band {
val merged = spans.toMutableMap()
for ((kind, span) in other.spans) merged[kind] = merged[kind]?.widen(span) ?: span
return Band(merged, complete || other.complete, fullAt)
}
}
private val bands = ConcurrentMap<String, Band>()
@@ -114,31 +147,73 @@ class SyncCoverage(
// Time for another full pass: relays gain old events, and without
// this the band's claim is never re-tested.
if (isStale(band)) return listOf(filter)
val legs = mutableListOf<Filter>()
if (band.spans.isEmpty()) return listOf(filter)
// Older: up to and including the band's floor, but not past the
// filter's (or, when the filter has no `since`, the caller's
// [floor] — a sync window the filter itself must not carry, or it
// would change the band's key every run). A complete band compared
// its whole range already, but only down to the floor it ran
// against: a caller now reaching deeper — a raised backfill window
// — re-opens the span below the band.
val kinds = filter.kinds
if (kinds.isNullOrEmpty()) {
// Nothing to split by. One span, exactly as before.
return windows(filter, band.spans[ALL_KINDS], band.complete, floor)
.map { (since, until) -> filter.copy(since = since, until = until) }
}
// Per kind, then REGROUPED by the windows each one wants. Kinds whose
// coverage agrees — the overwhelmingly common case, and the only case
// at all until they diverge — collapse back into one ask, so a filter
// that used to produce two legs still produces two rather than two per
// kind. Only a kind whose evidence genuinely differs earns its own.
val byWindows = LinkedHashMap<List<Pair<Long?, Long?>>, MutableList<Int>>()
for (kind in kinds) {
// ALL_KINDS as the fallback: a band written before coverage was
// tracked per kind, restored from such a file. It carries the old,
// wider claim for every kind — the behaviour this replaces — and
// self-corrects on the first paged walk that reports per kind.
val span = band.spans[kind] ?: band.spans[ALL_KINDS]
byWindows.getOrPut(windows(filter, span, band.complete, floor)) { mutableListOf() }.add(kind)
}
return byWindows.flatMap { (windows, group) ->
// toList(): `group` is the mutable accumulator above, and handing
// the same instance to every Filter in the group would publish it
// through a public return value. Filters are treated as immutable
// everywhere else; this keeps that true by construction.
val kindsForGroup = group.toList()
windows.map { (since, until) -> filter.copy(kinds = kindsForGroup, since = since, until = until) }
}
}
/**
* The `(since, until)` pairs still outstanding for ONE span — the leg
* arithmetic, with the filter's own bounds applied and nothing else.
* A null [span] means no evidence at all, so the whole filter is wanted.
*/
private fun windows(
filter: Filter,
span: Span?,
complete: Boolean,
floor: Long?,
): List<Pair<Long?, Long?>> {
if (span == null) return listOf(filter.since to filter.until)
val out = mutableListOf<Pair<Long?, Long?>>()
// Older: up to and including the span's floor, but not past the
// filter's (or, when the filter has no `since`, the caller's [floor] —
// a sync window the filter itself must not carry, or it would change
// the band's key every run). A complete band compared its whole range
// already, but only down to the floor it ran against: a caller now
// reaching deeper — a raised backfill window — re-opens the span below.
val since = filter.since ?: floor
val wantsOlder =
if (band.complete) {
since != null && since < band.minCreatedAt
if (complete) {
since != null && since < span.min
} else {
since == null || band.minCreatedAt >= since
since == null || span.min >= since
}
if (wantsOlder) {
legs.add(filter.copy(until = minOf(band.minCreatedAt, filter.until ?: Long.MAX_VALUE)))
}
if (wantsOlder) out.add(filter.since to minOf(span.min, filter.until ?: Long.MAX_VALUE))
// Newer: from the band's ceiling on, but not past the filter's.
if (filter.until == null || band.maxCreatedAt <= filter.until) {
legs.add(filter.copy(since = maxOf(band.maxCreatedAt, filter.since ?: Long.MIN_VALUE)))
// Newer: from the span's ceiling on, but not past the filter's.
if (filter.until == null || span.max <= filter.until) {
out.add(maxOf(span.max, filter.since ?: Long.MIN_VALUE) to filter.until)
}
return legs
return out
}
/**
@@ -162,21 +237,87 @@ class SyncCoverage(
observedMax: Long?,
paged: Boolean,
reconciledThrough: Long? = null,
observedByKind: Map<Int, Span>? = null,
) {
if (reconciledThrough != null) {
put(url, filter, observedMin ?: reconciledThrough, reconciledThrough, complete = true)
// A reconcile compares the filter's whole id set in one pass, so
// the span it earns is the same for every kind the filter names —
// no per-kind evidence needed or possible.
val span = Span(observedMin ?: reconciledThrough, reconciledThrough)
put(url, filter, kindsOf(filter).associateWith { span }, complete = true)
return
}
if (!paged) return
// Read ONCE. `now` is a clock call, and this was invoking it twice per
// entry — so a 40-kind map took 80 readings, and worse, a span's floor
// and ceiling were judged against two different instants.
val at = now()
if (observedByKind != null) {
// Guarded per span for the same reason the aggregate is below.
val plausible =
observedByKind.filterValues {
isPlausible(it.min, at) && isPlausible(it.max, at)
}
if (plausible.isEmpty()) return
val named = filter.kinds
val spans =
if (named.isNullOrEmpty()) {
// A filter naming no kinds cannot be split, so [legs] reads
// ALL_KINDS and nothing else. Storing what the walk saw per
// kind would record a band no lookup can ever reach — it
// would exist and do nothing. Collapse to the union, which
// is the only claim such a filter can make.
mapOf(ALL_KINDS to plausible.values.reduce { a, b -> a.widen(b) })
} else {
// Only kinds the filter NAMES. A relay may answer with more
// than it was asked for, and a caller whose containment
// check runs against a different filter than the band is
// keyed by passes those straight through. Keeping them
// would be inert for [legs] — which looks up the filter's
// own kinds — but NOT for [Band.minCreatedAt], which the
// state file writes as its rollback-compat `min`/`max`. An
// off-filter kind seen further back would widen those past
// anything the filter's kinds support, so a binary from
// before per-kind spans would read that file and
// over-claim: this fix undone through the compat path.
plausible.filterKeys { it in named }
}
if (spans.isEmpty()) return
put(url, filter, spans, complete = false)
return
}
// No per-kind evidence. For a filter naming one kind (or none) the
// aggregate IS the per-kind answer and nothing is lost. For a filter
// naming several it is not: attributing one interval to all of them is
// exactly the over-claim [Band.spans] exists to stop, and a band that
// over-claims skips events silently — strictly worse than re-reading
// them. So record nothing and say why, once. The caller resumes as if
// it had no band, which is where it was before bands existed.
val kinds = kindsOf(filter)
if (kinds.size > 1) {
if (!warnedAboutUnattributed) {
warnedAboutUnattributed = true
Log.w("SyncCoverage") {
"paged record for a ${kinds.size}-kind filter with no per-kind spans — no band recorded, so this " +
"walk will not resume. Pass observedByKind (see SyncCoverage.observe) to earn one."
}
}
return
}
// Guarded even though callers should filter with [isPlausible] per
// event: a 1970 floor or a far-future ceiling would make the band
// claim the whole timeline, and the leg outside it would ask for a
// range nothing can be in, forever.
if (observedMin == null || observedMax == null) return
if (!isPlausible(observedMin, now()) || !isPlausible(observedMax, now())) return
put(url, filter, observedMin, observedMax, complete = false)
if (!isPlausible(observedMin, at) || !isPlausible(observedMax, at)) return
put(url, filter, kinds.associateWith { Span(observedMin, observedMax) }, complete = false)
}
/** The kinds a band is keyed by: the filter's, or [ALL_KINDS] when it names none. */
private fun kindsOf(filter: Filter): List<Int> = filter.kinds?.takeIf { it.isNotEmpty() } ?: listOf(ALL_KINDS)
/**
* Widen (or reset) the band. A pass that ran because the previous band
* had gone stale REPLACES it: it re-walked the whole filter, so its own
@@ -185,22 +326,12 @@ class SyncCoverage(
private fun put(
url: NormalizedRelayUrl,
filter: Filter,
min: Long,
max: Long,
spans: Map<Int, Span>,
complete: Boolean,
) {
val fresh = Band(min, max, complete, now())
val fresh = Band(spans, complete, now())
bands.merge(key(url, filter), fresh) { old, new ->
if (isStale(old)) {
new
} else {
Band(
minOf(old.minCreatedAt, new.minCreatedAt),
maxOf(old.maxCreatedAt, new.maxCreatedAt),
old.complete || new.complete,
old.fullAt,
)
}
if (isStale(old)) new else old.widen(new)
}
onChange()
}
@@ -276,7 +407,45 @@ class SyncCoverage(
return "${url.url} $fingerprint"
}
// One line per process, not per walk: the point is to tell a caller it has
// not been migrated, and repeating it every leg would bury the log it is
// trying to be read in.
private var warnedAboutUnattributed = false
companion object {
/**
* The span key for a filter that names no kinds, and the fallback for
* a band restored from a file written before spans were per kind.
* Negative because NIP-01 kinds are not.
*/
const val ALL_KINDS = -1
/**
* Widen [into] with one event's stamp, so a caller can accumulate the
* per-kind evidence [record] wants as events arrive:
*
* val seen = mutableMapOf<Int, SyncCoverage.Span>()
* ... onEvent { SyncCoverage.observe(seen, it.kind, it.createdAt) }
* coverage.record(url, filter, …, paged = true, observedByKind = seen)
*
* Implausible stamps are dropped here rather than by each caller —
* per EVENT, never over a leg's aggregate, because one misdated event
* among hundreds of thousands would otherwise discard the whole band.
*
* Not synchronized: it replaces a pair of plain `var`s at each call
* site and is meant for the same single-consumer callback.
*/
fun observe(
into: MutableMap<Int, Span>,
kind: Int,
createdAt: Long,
now: Long = TimeUtils.now(),
) {
if (!isPlausible(createdAt, now)) return
val one = Span(createdAt, createdAt)
into[kind] = into[kind]?.widen(one) ?: one
}
// More filter instances than any deliberate configuration holds; only
// a caller rebuilding filters per cycle ever reaches it.
private const val MAX_FINGERPRINTS = 1_000
@@ -27,32 +27,61 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlin.concurrent.Volatile
class PoolEventOutbox {
// @Volatile so the polling path (INostrClient.pendingPublishRelaysFor)
// sees current state from threads that didn't write the map. Mutations
// still happen on NostrClient's IO scope; this only closes the
// visibility gap for cross-thread readers.
@Volatile
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
/**
* Pending publishes, keyed by event id.
*
* A concurrent map, NOT a copy-on-write immutable one. It used to be
* `@Volatile var eventOutbox = mapOf(...)` reassigned with
* `eventOutbox + Pair(...)`, which copies EVERY entry on EVERY publish —
* so publishing N events cost O(N^2). Measured on a bulk push with ~970k
* entries resident: 22.7ms per event, of which ~20.5ms was this map, and
* the rate decayed as the outbox grew (45.6 -> 44.6 -> 43.2 ev/s across
* three windows). The store fetch behind the same loop cost 1.2ms.
*
* [LargeCache] is ConcurrentHashMap on JVM/Android, so put/get/remove are
* O(1) and cross-thread visibility no longer needs the volatile republish.
*/
private val eventOutbox = LargeCache<HexKey, PoolEventOutboxState>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
/**
* Removals since the relay set was last rebuilt.
*
* Deciding whether a relay may leave [relays] means asking whether ANY
* remaining entry still wants it — O(outbox), and doing that per publish
* is the second half of the quadratic. Additions stay exact and cheap (a
* union of the event's own relays); removals are swept in batches, because
* keeping a relay in the set slightly too long only means holding a
* connection a little longer, while scanning a million entries to retire
* it promptly costs the whole push.
*/
@Volatile
private var pendingSweep = 0
companion object {
/** Removals between full relay-set rebuilds — see [pendingSweep]. */
private const val SWEEP_EVERY = 256
}
fun needsToUpdateRelays(): Boolean {
val currentRelays = relays.value
var relaysToRemoveCounter = 0
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
if (eventOutbox.values().none { currentRelay in it.relaysRemaining }) {
relaysToRemoveCounter++
}
}
var relaysToAddCounter = 0
eventOutbox.values.forEach { outboxState ->
eventOutbox.values().forEach { outboxState ->
if (outboxState.relaysRemaining.any { it !in currentRelays }) {
relaysToAddCounter++
}
@@ -67,13 +96,13 @@ class PoolEventOutbox {
val relaysToRemove = mutableSetOf<NormalizedRelayUrl>()
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
if (eventOutbox.values().none { currentRelay in it.relaysRemaining }) {
relaysToRemove.add(currentRelay)
}
}
val relaysToAdd = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach { outboxState ->
eventOutbox.values().forEach { outboxState ->
outboxState.relaysRemaining.forEach { relay ->
if (relay !in relaysToAdd && relay !in currentRelays) {
relaysToAdd.add(relay)
@@ -88,7 +117,7 @@ class PoolEventOutbox {
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
val myEvents = mutableSetOf<HexKey>()
eventOutbox.forEach { (eventId, outboxCache) ->
eventOutbox.forEach { eventId, outboxCache ->
if (url in outboxCache.relaysRemaining) {
myEvents.add(eventId)
}
@@ -103,7 +132,7 @@ class PoolEventOutbox {
*/
fun activeOutboxEventsFor(url: NormalizedRelayUrl): List<Event> {
val myEvents = mutableListOf<Event>()
eventOutbox.forEach { (_, outboxCache) ->
eventOutbox.forEach { _, outboxCache ->
if (url in outboxCache.relaysRemaining) {
myEvents.add(outboxCache.event)
}
@@ -117,20 +146,41 @@ class PoolEventOutbox {
* Callers can poll this after publish to detect when relays ack: the set shrinks
* as OKs arrive, then the entry is removed from the outbox (returns null).
*/
fun pendingRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = eventOutbox[eventId]?.relaysLeft()
fun pendingRelaysFor(eventId: HexKey): Set<NormalizedRelayUrl>? = eventOutbox.get(eventId)?.relaysLeft()
fun markAsSending(
event: Event,
relays: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> {
val currentOutbox = eventOutbox[event.id]
val currentOutbox = eventOutbox.get(event.id)
if (currentOutbox == null) {
eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(event, relays))
eventOutbox.put(event.id, PoolEventOutboxState(event, relays))
} else {
currentOutbox.updateRelays(relays)
}
updateRelays()
return eventOutbox[event.id]?.remainingRelays() ?: emptySet()
// Additions only, and only what is genuinely new: the union is over
// this event's relays, never over the whole outbox.
addRelays(relays)
return eventOutbox.get(event.id)?.remainingRelays() ?: emptySet()
}
/** Union [wanted] into [relays], touching the flow only when it actually changes. */
private fun addRelays(wanted: Set<NormalizedRelayUrl>) {
val missing = wanted - relays.value
if (missing.isNotEmpty()) relays.update { it + missing }
}
/**
* An entry left the outbox. Retiring its relays needs a full scan, so that
* is amortised across [SWEEP_EVERY] removals — and always run once the
* outbox empties, which is the case that must not linger.
*/
private fun onRemoved() {
pendingSweep++
if (pendingSweep >= SWEEP_EVERY || eventOutbox.isEmpty()) {
pendingSweep = 0
updateRelays()
}
}
/** Records a send attempt. Returns the event if this attempt exhausted its retry budget for
@@ -139,11 +189,11 @@ class PoolEventOutbox {
id: HexKey,
url: NormalizedRelayUrl,
): Event? {
val waiting = eventOutbox[id] ?: return null
val waiting = eventOutbox.get(id) ?: return null
val gaveUp = waiting.newTry(url)
if (waiting.isDone()) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
eventOutbox.remove(waiting.event.id)
onRemoved()
}
return if (gaveUp) waiting.event else null
}
@@ -154,12 +204,12 @@ class PoolEventOutbox {
success: Boolean,
message: String,
) {
val waiting = eventOutbox[id]
val waiting = eventOutbox.get(id)
if (waiting != null) {
waiting.newResponse(url, success, message)
if (waiting.isDone()) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
eventOutbox.remove(waiting.event.id)
onRemoved()
}
}
}
@@ -171,8 +221,8 @@ class PoolEventOutbox {
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
eventOutbox.forEach {
it.value.forEachUnsentEvent(relay) {
eventOutbox.forEach { _, outboxCache ->
outboxCache.forEachUnsentEvent(relay) {
sync(EventCmd(it))
}
}
@@ -210,15 +260,16 @@ class PoolEventOutbox {
relay: NormalizedRelayUrl,
errorMessage: String,
) {
eventOutbox.forEach {
if (relay in it.value.relaysRemaining) {
newResponse(it.key, relay, false, errorMessage)
eventOutbox.forEach { id, outboxCache ->
if (relay in outboxCache.relaysRemaining) {
newResponse(id, relay, false, errorMessage)
}
}
}
fun destroy() {
eventOutbox = emptyMap()
eventOutbox.clear()
pendingSweep = 0
relays.tryEmit(emptySet())
}
}
@@ -104,7 +104,13 @@ class NegSessionRegistry(
// `null` = matching set exceeds the cap (strfry-parity error).
val sealedStorage = store.sealedNegentropyStorage(filters, maxEntries = settings.maxSyncEvents)
if (sealedStorage == null) {
send(NegErrMessage(cmd.subId, "blocked: too many query results"))
// The cap rides along with the refusal. A client cannot discover
// this number any other way — NIP-11 has no field for it — so
// without it the only route to a window we WILL answer is guessing,
// halving, one refused NEG-OPEN at a time. Every one of those costs
// us the snapshot scan that produced this rejection, which makes
// stating it cheaper for the relay than staying quiet.
send(NegErrMessage(cmd.subId, "blocked: too many query results", settings.maxSyncEvents.toLong()))
return
}
@@ -22,13 +22,64 @@ package com.vitorpamplona.quartz.nip77Negentropy
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
/**
* `["NEG-ERR", <subId>, <reason>]`, optionally followed by the relay's own
* `max_sync_events` when the refusal is about result-set size.
*
* That fourth element is not in NIP-77, but it is the only way a client learns
* the one number that decides how to ask again — no NIP-11 field carries it —
* and it is free for the relay to send, since it must know its own cap to have
* refused. strfry states it in the prose (`… too many records (2431002 >
* 1000000)`); [statedCap] reads either form.
*
* @property cap the fourth wire element, when present.
*/
class NegErrMessage(
val subId: String,
val reason: String,
val cap: Long? = null,
) : Message {
override fun label() = LABEL
/**
* The relay's negentropy cap if this refusal states one, from the wire
* field or from the prose, in that order.
*
* Only read for a refusal that is about SIZE ([isOverflow]). A quota or
* rate-limit refusal can carry numbers too, and sizing future windows
* against one of those would shrink every ask against a relay that has no
* size limit at all — while the limit that actually refused does not move
* however small the window gets.
*/
val statedCap: Long?
get() = if (!isOverflow(reason)) null else cap?.takeIf { it > 0 } ?: capInReason(reason)
companion object {
const val LABEL = "NEG-ERR"
/** `(2431002 > 1000000)` — the cap is the right-hand side. */
private val COMPARISON = Regex("""\(\s*\d+\s*>\s*(\d+)\s*\)""")
/**
* Does this reason mean "your query matched more than I will
* reconcile"? — as opposed to any other refusal, which no amount of
* window splitting will get past.
*/
fun isOverflow(reason: String): Boolean =
reason.contains("too many records", ignoreCase = true) ||
reason.contains("too many results", ignoreCase = true) ||
reason.contains("too many query results", ignoreCase = true) ||
reason.contains("result set too large", ignoreCase = true) ||
reason.contains("results too large", ignoreCase = true) ||
reason.contains("max_sync_events", ignoreCase = true)
/** The cap strfry writes into the refusal text, when it is there. */
fun capInReason(reason: String): Long? =
COMPARISON
.find(reason)
?.groupValues
?.get(1)
?.toLongOrNull()
?.takeIf { it > 0 }
}
}
@@ -32,7 +32,9 @@ package com.vitorpamplona.quartz.nip77Negentropy
* unlimited).
* @param maxSyncEvents Hard cap on the snapshot size for a single
* NEG-OPEN. Mirrors strfry's `relay__negentropy__maxSyncEvents`.
* Overflow returns NEG-ERR `"blocked: too many query results"`.
* Overflow returns NEG-ERR `"blocked: too many query results"`
* carrying this number as its fourth element, so a client can size
* its next window instead of halving its way down to one.
* @param maxSessionsPerConnection Cap on concurrent NEG sessions
* held by one connection. strfry shares 200 with REQ subs; we
* count NEG independently. Overflow sends NOTICE
@@ -0,0 +1,90 @@
/*
* 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.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The list-backed index is what the `localEntries` overloads become, so its
* slicing has to keep NIP-01's inclusive `since`/`until` exactly: a window that
* dropped its boundary second would leave events neither side ever compares,
* and the two sides of a reconcile would disagree about what the window holds.
*/
class NegentropyLocalIndexTest {
private fun idAt(second: Long) = IdAndTime(second, second.toString().padStart(64, '0'))
private val index = NegentropyLocalIndex.of((1000L..1009L).map { idAt(it) })
private fun window(
since: Long?,
until: Long?,
) = Filter(kinds = listOf(1), since = since, until = until)
@Test
fun bothBoundsAreInclusive() =
runTest {
assertEquals(3, index.count(window(1002, 1004)))
assertEquals(listOf(1002L, 1003L, 1004L), index.entriesFor(window(1002, 1004)).map { it.createdAt })
}
@Test
fun anUnboundedSideReachesTheEnd() =
runTest {
assertEquals(5, index.count(window(1005, null)))
assertEquals(6, index.count(window(null, 1005)))
assertEquals(10, index.count(window(null, null)))
}
@Test
fun aWindowOutsideEverythingIsEmpty() =
runTest {
assertEquals(0, index.count(window(2000, 3000)))
assertTrue(index.entriesFor(window(2000, 3000)).isEmpty())
}
@Test
fun aSingleSecondWindowHoldsThatSecond() =
runTest {
assertEquals(1, index.count(window(1007, 1007)))
assertEquals(listOf(1007L), index.entriesFor(window(1007, 1007)).map { it.createdAt })
}
@Test
fun entriesNeedNotArriveSorted() =
runTest {
val shuffled = NegentropyLocalIndex.of(listOf(idAt(1005), idAt(1001), idAt(1009), idAt(1003)))
assertEquals(2, shuffled.count(window(1001, 1003)))
assertEquals(listOf(1001L, 1003L), shuffled.entriesFor(window(1001, 1003)).map { it.createdAt })
}
@Test
fun theEmptyIndexAnswersZeroForEveryWindow() =
runTest {
assertEquals(0, NegentropyLocalIndex.Empty.count(window(1000, 2000)))
assertTrue(NegentropyLocalIndex.Empty.entriesFor(window(1000, 2000)).isEmpty())
assertEquals(0, NegentropyLocalIndex.of(emptyList()).count(window(null, null)))
}
}
@@ -382,4 +382,181 @@ class SyncCoverageTest {
val copy = Filter(kinds = listOf(30382), authors = (1..500).map { it.toString(16).padStart(64, '0') })
assertEquals(1_700_001_000L, c.band(relay, copy)?.minCreatedAt, "identity caching must not change the key")
}
// ---- per-kind spans: one interval cannot speak for several kinds -------
private val mixed = Filter(kinds = listOf(0, 30382))
/** Does any leg still ask [kind] about the instant [at]? */
private fun reaches(
legs: List<Filter>,
kind: Int,
at: Long,
) = legs.any {
(it.kinds?.contains(kind) ?: true) &&
(it.since ?: Long.MIN_VALUE) <= at &&
at <= (it.until ?: Long.MAX_VALUE)
}
@Test
fun `a long-lived kind no longer vouches for a short-lived one`() {
// THE BUG. Ask for profiles and score cards together: the relay has
// profiles going back years and score cards only from last month. One
// interval per band recorded 2020..now for the pair, and the next run
// skipped that whole interior for BOTH — so score cards written inside
// it were never asked for again, and nothing anywhere said so.
val c = SyncCoverage()
c.record(
relay,
mixed,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L),
30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
),
)
val legs = c.legs(relay, mixed)
assertTrue(!reaches(legs, 0, 1_650_000_000L), "kind 0 really was walked there — do not re-read it")
assertTrue(reaches(legs, 30382, 1_650_000_000L), "kind 30382 never was, and must still be asked")
// Both keep the ground they actually earned.
assertTrue(!reaches(legs, 30382, 1_695_000_000L), "…but not its own covered interior")
assertTrue(reaches(legs, 0, 1_500_000_000L), "and both still reach below everything walked")
}
@Test
fun `kinds whose coverage agrees stay a single ask`() {
// The cost control. Splitting per kind would turn two legs into two
// per kind on every filter, which is the common case made worse to fix
// the rare one. Kinds are regrouped by the windows they want, so
// identical coverage collapses back to exactly what it was before.
val c = SyncCoverage()
val span = SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)
c.record(relay, mixed, null, null, paged = true, observedByKind = mapOf(0 to span, 30382 to span))
val legs = c.legs(relay, mixed)
assertEquals(2, legs.size, "two legs, not two per kind")
assertEquals(listOf(0, 30382), legs[0].kinds, "and both kinds ride in one ask")
}
@Test
fun `a multi-kind paged walk with no per-kind evidence earns no band`() {
// The caller did not say which kind it saw where, so the only band
// available is the over-wide one. Refused: a band that over-claims
// skips events silently, which is worse than re-reading them. The
// walk resumes from nothing, exactly as it did before bands existed.
val c = SyncCoverage()
c.record(relay, mixed, 1_690_000_000L, 1_700_000_000L, paged = true)
assertNull(c.band(relay, mixed))
assertEquals(listOf(mixed), c.legs(relay, mixed))
// A filter naming ONE kind is unaffected: there, the aggregate IS the
// per-kind answer and nothing was ever ambiguous about it.
c.record(relay, profiles, 1_690_000_000L, 1_700_000_000L, paged = true)
assertEquals(2, c.legs(relay, profiles).size)
}
@Test
fun `a finished reconcile covers every kind the filter names`() {
// Negentropy compares the filter's whole id set in one pass, so it
// either covers every kind in it or none — no per-kind evidence needed,
// and none invented.
val c = SyncCoverage()
c.record(relay, mixed, null, null, paged = false, reconciledThrough = 1_700_000_000L)
assertEquals(setOf(0, 30382), c.band(relay, mixed)!!.spans.keys)
val legs = c.legs(relay, mixed)
assertEquals(1, legs.size, "complete: no older leg, and one shared newer one")
assertEquals(1_700_000_000L, legs[0].since)
}
@Test
fun `a band restored from a pre-split file still narrows every kind`() {
// Files written before spans were per kind carry one interval. It is
// the old, wider claim — loaded as what it always meant rather than
// discarded, because discarding it would re-download every upstream's
// corpus once on upgrade. The first per-kind walk replaces it.
val seed = SyncCoverage()
// A plausible span, or record() correctly drops it and there is no key to read.
seed.record(relay, mixed, null, null, paged = true, observedByKind = mapOf(0 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)))
val key = seed.export().keys.single()
val restored = SyncCoverage()
restored.restore(
mapOf(
key to
SyncCoverage.Band(
mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)),
complete = false,
fullAt = now(),
),
),
)
val legs = restored.legs(relay, mixed)
assertEquals(2, legs.size, "one shared pair of legs, which is the old behaviour exactly")
assertEquals(listOf(0, 30382), legs[0].kinds)
assertEquals(1_690_000_000L, legs[0].until)
}
@Test
fun `a kind the filter never asked for cannot widen the band`() {
// A relay may answer with more than it was asked for. Those spans are
// inert for legs(), which only looks up the filter's own kinds — but
// NOT for Band.minCreatedAt, which the state file writes as its
// rollback-compat min/max. Left in, a stray kind seen further back
// would widen that past anything the filter's kinds support, and a
// binary from before per-kind spans would read the file and over-claim.
val c = SyncCoverage()
c.record(
relay,
profiles,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L),
// never asked for, and much older
1 to SyncCoverage.Span(1_600_000_000L, 1_610_000_000L),
),
)
val band = c.band(relay, profiles)!!
assertEquals(setOf(0), band.spans.keys, "only the kind the filter names")
assertEquals(1_690_000_000L, band.minCreatedAt, "…so the compat floor stays honest")
}
@Test
fun `per-kind evidence on a filter naming no kinds collapses to one span`() {
// Such a filter cannot be split, so legs() reads ALL_KINDS and nothing
// else. Storing per-kind spans here would record a band no lookup can
// reach — present in the file, doing nothing.
val anyKind = Filter(authors = listOf("a".repeat(64)))
val c = SyncCoverage()
c.record(
relay,
anyKind,
null,
null,
paged = true,
observedByKind =
mapOf(
0 to SyncCoverage.Span(1_690_000_000L, 1_695_000_000L),
30382 to SyncCoverage.Span(1_697_000_000L, 1_700_000_000L),
),
)
val band = c.band(relay, anyKind)!!
assertEquals(setOf(SyncCoverage.ALL_KINDS), band.spans.keys)
assertEquals(1_690_000_000L, band.spans.getValue(SyncCoverage.ALL_KINDS).min, "the union, not one of them")
assertEquals(1_700_000_000L, band.spans.getValue(SyncCoverage.ALL_KINDS).max)
// …and it is actually USED, which is the half that was silently missing.
assertEquals(2, c.legs(relay, anyKind).size)
assertEquals(1_690_000_000L, c.legs(relay, anyKind)[0].until)
}
}
@@ -0,0 +1,121 @@
/*
* 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.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.TimeSource
/**
* The outbox must not get slower as it fills.
*
* It used to: the map was immutable and every `markAsSending` rebuilt it with
* `eventOutbox + Pair(...)`, so publishing N events copied 1 + 2 + … + N
* entries. Measured on a real bulk push at ~970k entries resident, that was
* ~20.5ms of the 22.7ms each event cost, and the rate visibly decayed as the
* backlog grew (45.6 -> 44.6 -> 43.2 ev/s over three windows). The relay-set
* bookkeeping was the other half — two full scans of every entry, per publish.
*
* This asserts the SHAPE of the cost rather than a wall-clock budget: a
* quadratic makes the second half of a run dramatically slower than the first,
* whatever the machine. A constant factor cannot be pinned in a unit test, but
* a growth curve can.
*/
class PoolEventOutboxScaleTest {
private val relay = NormalizedRelayUrl("wss://scale.relay.test")
private fun event(i: Int) =
Event(
id = i.toString(16).padStart(64, '0'),
pubKey = "00".repeat(32),
createdAt = 1_700_000_000L,
kind = 1,
tags = emptyArray(),
content = "hello",
sig = "00".repeat(64),
)
@Test
fun `publishing stays flat as the outbox fills`() {
val outbox = PoolEventOutbox()
val relays = setOf(relay)
val clock = TimeSource.Monotonic
val sample = 2_000
val total = 60_000
fun publishRange(
from: Int,
until: Int,
) {
for (i in from until until) outbox.markAsSending(event(i), relays)
}
// Equal-sized windows at the START and the END of a long run. Halves
// would not do: over 20k publishes the average backlog only grows from
// ~7k to ~17k, a 2.4x expected ratio that hides inside JIT noise. Here
// the late window carries ~29x the backlog of the early one, so a
// per-entry cost shows up as a per-entry cost.
repeat(sample) { outbox.markAsSending(event(it), relays) } // warm up
val early =
clock.markNow().let { start ->
publishRange(sample, sample * 2)
start.elapsedNow()
}
publishRange(sample * 2, total - sample)
val late =
clock.markNow().let { start ->
publishRange(total - sample, total)
start.elapsedNow()
}
assertEquals(total, outbox.activeOutboxCacheFor(relay).size, "every publish is tracked")
val ratio = late.inWholeMicroseconds.toDouble() / early.inWholeMicroseconds.coerceAtLeast(1)
assertTrue(
ratio < 5.0,
"cost per publish must not grow with the backlog: first $sample took ${early.inWholeMilliseconds}ms at " +
"~$sample entries, last $sample took ${late.inWholeMilliseconds}ms at ~$total entries (ratio $ratio)",
)
}
@Test
fun `the relay set still reflects what is pending`() {
val outbox = PoolEventOutbox()
val a = NormalizedRelayUrl("wss://a.relay.test")
val b = NormalizedRelayUrl("wss://b.relay.test")
outbox.markAsSending(event(1), setOf(a))
assertEquals(setOf(a), outbox.relays.value, "a publish adds its relay immediately")
outbox.markAsSending(event(2), setOf(b))
assertEquals(setOf(a, b), outbox.relays.value, "a second relay joins without a rebuild")
// Draining every entry must clear the set — the sweep is batched, but
// emptying the outbox forces it, so a finished push does not strand a
// connection open forever.
outbox.newResponse(event(1).id, a, true, "")
outbox.newResponse(event(2).id, b, true, "")
assertEquals(emptySet(), outbox.relays.value, "an empty outbox wants no relays")
}
}
@@ -0,0 +1,96 @@
/*
* 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.quartz.nip77Negentropy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A stated cap is acted on — it sizes the next NEG-OPEN — so reading one out of
* a refusal that is not about size is worse than reading none at all: a quota or
* rate limit does not shrink when the window shrinks, so a client that mistook
* one for a cap would shrink its windows forever against a relay that has no
* size limit.
*/
class NegErrMessageTest {
@Test
fun capComesFromTheWireField() {
assertEquals(1_000_000L, NegErrMessage("s", "blocked: too many query results", 1_000_000L).statedCap)
}
@Test
fun capComesFromStrfrysProseWhenTheFieldIsAbsent() {
val msg = NegErrMessage("s", "blocked: query matches too many records (2431002 > 1000000)")
assertEquals(1_000_000L, msg.statedCap)
}
@Test
fun theWireFieldWinsOverTheProse() {
val msg = NegErrMessage("s", "blocked: too many records (5 > 10)", 1_000L)
assertEquals(1_000L, msg.statedCap)
}
@Test
fun anOverflowWithNoNumberStatesNothing() {
assertNull(NegErrMessage("s", "blocked: too many query results").statedCap)
}
@Test
fun aRateLimitIsNotACapHoweverManyNumbersItCarries() {
assertFalse(NegErrMessage.isOverflow("rate-limited: too many requests (30 > 10)"))
assertNull(NegErrMessage("s", "rate-limited: too many requests (30 > 10)", 10L).statedCap)
}
@Test
fun refusalsThatAreNotAboutSizeStateNothing() {
listOf(
"auth-required: we only serve negentropy to authenticated users",
"blocked: pubkey is banned",
"error: negentropy disabled",
"closed: unknown subscription handle",
).forEach {
assertFalse(NegErrMessage.isOverflow(it), "read as an overflow: $it")
assertNull(NegErrMessage("s", it, 42L).statedCap, "read a cap from: $it")
}
}
@Test
fun theWordingsThatDoMeanOverflow() {
listOf(
"blocked: query matches too many records (5 > 1)",
"blocked: too many query results",
"error: result set too large",
"blocked: results too large",
"blocked: max_sync_events exceeded",
).forEach { assertTrue(NegErrMessage.isOverflow(it), "not read as an overflow: $it") }
}
@Test
fun aNonsensicalCapIsRefused() {
// Zero would wedge a client at a window that can never fit.
assertNull(NegErrMessage("s", "blocked: too many query results", 0L).statedCap)
assertNull(NegErrMessage("s", "blocked: too many records (5 > 0)").statedCap)
assertNull(NegErrMessage("s", "blocked: too many query results", -1L).statedCap)
}
}
@@ -121,10 +121,18 @@ class MessageDeserializer : StdDeserializer<Message>(Message::class.java) {
}
NegErrMessage.LABEL -> {
NegErrMessage(
subId = jp.nextTextValue(),
reason = jp.nextTextValue() ?: "",
)
val subId = jp.nextTextValue()
val reason = jp.nextTextValue() ?: ""
// The optional fourth element, the relay's own cap. Read by
// stepping one token: anything that is not a number leaves
// the loop below to drain the frame, as before.
val cap =
if (jp.nextToken() == JsonToken.VALUE_NUMBER_INT) {
jp.longValue
} else {
null
}
NegErrMessage(subId, reason, cap)
}
else -> {
@@ -129,6 +129,7 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
is NegErrMessage -> {
gen.writeString(msg.subId)
gen.writeString(msg.reason)
msg.cap?.let { gen.writeNumber(it) }
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyLocalIndex
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.store.IdAndTime
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -48,6 +50,8 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NostrClientNegentropySyncTest : RelayClientTest() {
@@ -252,6 +256,11 @@ class NostrClientNegentropySyncTest : RelayClientTest() {
* The "try negentropy, else page" combinator: against the same over-cap relay
* where raw [negentropySync] throws, [negentropySyncOrFetch] transparently pages
* and delivers every event, reporting that it fell back.
*
* Every event here shares one `created_at`, so the whole filter IS the
* un-reconcilable window: it is drained as one paged window rather than by
* abandoning the sync, which is why `fallbackCause` is null. On a filter
* spanning more than this second, everything outside it still reconciles.
*/
@Test
fun orFetchPagesWhenNegentropyCannotReconcile() =
@@ -275,11 +284,9 @@ class NostrClientNegentropySyncTest : RelayClientTest() {
assertEquals(10, got.map { it.id }.toSet().size, "all events delivered via the paging fallback")
assertEquals(10, result.downloaded)
assertTrue(result.pagedFallback, "it should have fallen back to paging")
assertEquals(
NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS,
result.fallbackCause?.reason,
)
assertTrue(result.pagedFallback, "part of the range came over REQ, so this was not a clean reconcile")
assertEquals(1, result.pagedWindows, "exactly the one un-reconcilable window was paged")
assertNull(result.fallbackCause, "the sync was not abandoned — one window was drained by paging")
} finally {
client.disconnect()
scope.cancel()
@@ -375,4 +382,156 @@ class NostrClientNegentropySyncTest : RelayClientTest() {
assertFalse(result.pagedFallback, "negentropy should have handled it")
assertEquals(8, result.negentropy?.downloaded)
}
/**
* The caller's own count splits a window BEFORE the relay is asked for it.
*
* Nothing here overflows the relay would have reconciled the whole filter
* in one NEG-OPEN so every split is driven by [NegentropyLocalIndex.count]
* against `targetWindow`. That is what bounds the entries a caller has to
* materialise: without it the first (and only) window is the whole filter,
* and the local set for it is the whole corpus.
*/
@Test
fun targetWindowSplitsFromTheLocalCountAlone() =
runBlocking {
// 40 seconds of history, one event each; we already hold the even ones.
val all = (0 until 40).map { SyntheticEvents.fakeEvent(idSeed = it + 1, kind = 1, createdAt = 1000L + it) }
defaultRelay.preload(all)
val ours = all.filterIndexed { i, _ -> i % 2 == 0 }.map { IdAndTime(it.createdAt, it.id) }
val asked = mutableListOf<Filter>()
val index =
object : NegentropyLocalIndex {
val inner = NegentropyLocalIndex.of(ours)
override suspend fun count(window: Filter): Int {
asked += window
return inner.count(window) ?: 0
}
override suspend fun entriesFor(window: Filter) = inner.entriesFor(window)
}
val got = mutableListOf<Event>()
val result =
withTimeout(60_000) {
client.negentropySync(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
localIndex = index,
targetWindow = 5,
) { got.add(it) }
}
assertEquals(20, got.map { it.id }.toSet().size, "only the half we lacked comes down")
assertTrue(result.windows > 1, "the local count alone must have split the filter")
assertTrue(asked.isNotEmpty(), "windows must be counted before they are asked for")
assertNull(result.peerCap, "nothing was refused, so there is no cap to report")
}
/** Passing no target keeps the old shape: one window until the relay objects. */
@Test
fun withoutATargetTheLocalCountIsNeverConsulted() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(20, kind = 1))
var counted = 0
val index =
object : NegentropyLocalIndex {
override suspend fun count(window: Filter): Int {
counted++
return 1_000_000
}
override suspend fun entriesFor(window: Filter) = emptyList<IdAndTime>()
}
val result =
withTimeout(20_000) {
client.negentropySync(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
localIndex = index,
) { }
}
assertEquals(0, counted, "targetWindow = 0 must not ask the store anything")
assertEquals(1, result.windows)
assertEquals(20, result.downloaded)
}
/**
* A relay that refuses for size states its cap, and the client reports it
* so the next sync can start at a window that fits instead of rediscovering
* it by halving.
*/
@Test
fun theRelaysCapIsReportedBack() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7786/")
hub.getOrCreate(url).preload((0 until 12).map { SyntheticEvents.fakeEvent(idSeed = it + 1, kind = 1, createdAt = 1000L + it) })
val result =
withTimeout(60_000) {
client.negentropySync(relay = url, filter = Filter(kinds = listOf(1))) { }
}
assertEquals(12, result.downloaded)
assertTrue(result.windows > 1)
assertEquals(3L, result.peerCap, "the relay stated its own max_sync_events")
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
/**
* One second the relay will not reconcile at any window size costs that
* second, not the sync.
*
* The whole point of the [NegentropyOrFetchResult.pagedWindows] path: the
* dense second is drained over REQ while everything around it still
* reconciles. Before, the exception from that one window abandoned the whole
* sync and re-paged the entire filter on a large corpus, exactly the cost
* negentropy was there to avoid.
*/
@Test
fun oneUnreconcilableSecondDoesNotCostTheRestOfTheFilter() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7787/")
// Ten events crammed into one second — no created_at window can
// separate them — plus five ordinary seconds around them.
val dense = (1..10).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1, createdAt = 1000L) }
val sparse = (0 until 5).map { SyntheticEvents.fakeEvent(idSeed = 100 + it, kind = 1, createdAt = 2000L + it) }
hub.getOrCreate(url).preload(dense + sparse)
val got = mutableListOf<Event>()
val result =
withTimeout(60_000) {
client.negentropySyncOrFetch(
relay = url,
filter = Filter(kinds = listOf(1)),
) { got.add(it) }
}
assertEquals(15, got.map { it.id }.toSet().size, "everything is delivered, by whichever route")
assertEquals(1, result.pagedWindows, "only the dense second is paged")
assertNull(result.fallbackCause, "the sync itself was never abandoned")
val negentropy = assertNotNull(result.negentropy, "the rest of the range still reconciled")
assertTrue(negentropy.windows > 1)
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
}
@@ -124,6 +124,88 @@ class Nip77SerializationTest {
assertEquals(msg.reason, jacksonDeserialized.reason)
}
@Test
fun serializeNegErrMessageWithCap_matchesJackson() {
val msg = NegErrMessage("neg-sub1", "blocked: too many query results", 1_000_000L)
val jacksonJson = JacksonMapper.toJson(msg)
val kotlinJson = KotlinSerializationMapper.toJson(msg)
assertEquals(jacksonJson, kotlinJson)
assertEquals("""["NEG-ERR","neg-sub1","blocked: too many query results",1000000]""", kotlinJson)
}
@Test
fun serializeNegErrMessageWithoutCap_staysThreeElements() {
// NIP-77 describes a three-element NEG-ERR. A refusal with no cap to
// state must stay exactly that, rather than growing a fourth element
// every existing reader then has to tolerate.
val msg = NegErrMessage("neg-sub1", "closed: timeout")
assertEquals("""["NEG-ERR","neg-sub1","closed: timeout"]""", KotlinSerializationMapper.toJson(msg))
assertEquals("""["NEG-ERR","neg-sub1","closed: timeout"]""", JacksonMapper.toJson(msg))
}
@Test
fun deserializeNegErrMessageWithCap_bothMappers() {
val json = """["NEG-ERR","neg-sub1","blocked: too many query results",1000000]"""
val jackson = JacksonMapper.fromJsonToMessage(json)
assertTrue(jackson is NegErrMessage)
assertEquals(1_000_000L, jackson.cap)
assertEquals(1_000_000L, jackson.statedCap)
val kotlin = KotlinSerializationMapper.fromJsonToMessage(json)
assertTrue(kotlin is NegErrMessage)
assertEquals(1_000_000L, kotlin.cap)
}
@Test
fun deserializeNegErrMessageWithGarbageFourthElement_bothMappers() {
// A relay that puts something else there is telling us nothing; it must
// not break the frame that carries the reason.
val json = """["NEG-ERR","neg-sub1","blocked: too many query results","soon"]"""
val jackson = JacksonMapper.fromJsonToMessage(json)
assertTrue(jackson is NegErrMessage)
assertEquals("blocked: too many query results", jackson.reason)
assertEquals(null, jackson.cap)
val kotlin = KotlinSerializationMapper.fromJsonToMessage(json)
assertTrue(kotlin is NegErrMessage)
assertEquals("blocked: too many query results", kotlin.reason)
assertEquals(null, kotlin.cap)
}
@Test
fun deserializeNegErrMessageWithStructuredFourthElement_bothMappers() {
// A fourth element that is an object or array must degrade to no cap,
// NOT fail the frame — the reason is the part that matters, and before
// this element existed any extra was simply ignored.
val json = """["NEG-ERR","neg-sub1","blocked: too many query results",{"max":10}]"""
val jackson = JacksonMapper.fromJsonToMessage(json)
assertTrue(jackson is NegErrMessage)
assertEquals("blocked: too many query results", jackson.reason)
assertEquals(null, jackson.cap)
val kotlin = KotlinSerializationMapper.fromJsonToMessage(json)
assertTrue(kotlin is NegErrMessage)
assertEquals("blocked: too many query results", kotlin.reason)
assertEquals(null, kotlin.cap)
}
@Test
fun negErrMessageWithCap_crossDeserialization() {
val msg = NegErrMessage("neg-sub1", "blocked: too many records", 500_000L)
val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(JacksonMapper.toJson(msg))
assertTrue(kotlinDeserialized is NegErrMessage)
assertEquals(500_000L, kotlinDeserialized.cap)
val jacksonDeserialized = JacksonMapper.fromJsonToMessage(KotlinSerializationMapper.toJson(msg))
assertTrue(jacksonDeserialized is NegErrMessage)
assertEquals(500_000L, jacksonDeserialized.cap)
}
// =========================================================================
// NEG-OPEN Command (client-to-relay) Tests
// =========================================================================
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Ensure a jpackage-built desktop .deb declares libegl1 as a runtime dep on
# arm64.
#
# Why this is needed: the compose-desktop skiko native shipped in
# ${app}/lib/app/libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (the
# aarch64 build uses EGL alongside GLX, unlike the x86_64 skiko which only
# links libGL.so.1). jpackage's --type deb only auto-generates Depends from
# dpkg-shlibdeps against the bundled JRE under ${app}/lib/runtime/, NOT the
# ${app}/lib/app/ tree — so libegl1 never makes it into the arm64 .deb.
#
# On most desktop Linux systems libegl1 is already installed as a transitive
# of the desktop environment. But minimal aarch64 installs (Armbian Server +
# a lightweight WM, Raspberry Pi OS Lite + LXDE, etc.) can miss it. Without
# libegl1 the app dies 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
# override the auto-generated Depends, so we rewrite the .deb after the fact
# (same approach as scripts/relax-deb-libicu.sh).
#
# Usage: add-deb-libegl-dep.sh <path-to-deb> [<path-to-deb> ...]
set -euo pipefail
for deb in "$@"; do
if [[ ! -f "$deb" ]]; then
echo "skip: not a file: $deb" >&2
continue
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
dpkg-deb -R "$deb" "$work/pkg"
control="$work/pkg/DEBIAN/control"
# Only touch .debs whose payload actually contains the arm64 skiko native.
# Applying this to x64 .debs is harmless but the whole point is to be
# surgical.
if ! find "$work/pkg" -type f -name 'libskiko-linux-arm64.so' | grep -q .; then
echo "No arm64 skiko in payload, leaving as-is: $deb"
rm -rf "$work"
trap - EXIT
continue
fi
if grep -qE '(^| )libegl1( |,|$)' "$control"; then
echo "libegl1 already in Depends, leaving as-is: $deb"
rm -rf "$work"
trap - EXIT
continue
fi
# Append libegl1 to the Depends line. jpackage-generated lines are single
# physical lines, e.g.
# Depends: libasound2t64, ..., zlib1g
# We insert `, libegl1` before the trailing newline.
sed -i -E 's/^(Depends: .*[^,[:space:]])[[:space:]]*$/\1, libegl1/' "$control"
dpkg-deb --root-owner-group -Zxz -b "$work/pkg" "$deb" >/dev/null
echo "Added libegl1 dep: $deb"
rm -rf "$work"
trap - EXIT
done
+75 -14
View File
@@ -24,6 +24,8 @@
# amethyst-desktop-1.08.0-macos-arm64.dmg
# amethyst-desktop-1.08.0-windows-x64.msi
# amethyst-desktop-1.08.0-windows-x64.zip
# amethyst-desktop-1.08.0-windows-arm64.msi
# amethyst-desktop-1.08.0-windows-arm64.zip
# amethyst-desktop-1.08.0-linux-x64.deb
# amethyst-desktop-1.08.0-linux-x64.rpm
# amethyst-desktop-1.08.0-linux-x64.AppImage
@@ -42,6 +44,8 @@
# amy-1.08.0-linux-arm64.tar.gz
# amy-1.08.0-linux-arm64.deb
# amy-1.08.0-linux-arm64.rpm
# amy-1.08.0-windows-x64.zip
# amy-1.08.0-windows-arm64.zip
# geode-1.08.0-macos-arm64.tar.gz
# geode-1.08.0-linux-x64.tar.gz
# geode-1.08.0-linux-x64.deb
@@ -49,6 +53,8 @@
# geode-1.08.0-linux-arm64.tar.gz
# geode-1.08.0-linux-arm64.deb
# geode-1.08.0-linux-arm64.rpm
# geode-1.08.0-windows-x64.zip
# geode-1.08.0-windows-arm64.zip
#
# Two assets break the family/arch shape on purpose: the no-JRE jar bundles for
# Homebrew-core are pure JVM bytecode (no bundled runtime), so a single
@@ -121,10 +127,14 @@ collect_assets() {
#
# Expected inputs:
# cli/build/amy-image/amy/ flat app-image built by :cli:amyImage
# (bin/amy + lib/*.jar + runtime/)
# (bin/amy + bin/amy.bat + lib/*.jar + runtime/)
# cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only)
# cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only)
#
# On Windows the flat image is packaged as .zip (native archive format,
# preserves file layout without requiring a tar tool at install time). Every
# other OS uses tar.gz.
#
# Usage: collect_cli_assets <family> <arch> <version> <dest_dir>
collect_cli_assets() {
local family="$1" arch="$2" version="$3" dest="$4"
@@ -133,14 +143,39 @@ collect_cli_assets() {
abs_dest="$(cd "$dest" && pwd)"
shopt -s nullglob
# 1. Tar the flat app-image into amy-<version>-<family>-<arch>.tar.gz.
# This is the portable-across-OS asset — macOS runners produce only
# this one.
# 1. Archive the flat app-image into amy-<version>-<family>-<arch>.<ext>.
# macOS runners produce only this one. Windows uses .zip; every other
# OS uses tar.gz.
local app_image="cli/build/amy-image/amy"
if [ -d "$app_image" ]; then
local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)"
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
echo "Collected: $tarball"
if [ "$family" = "windows" ]; then
local zipfile="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" zip)"
# Prefer 7z when available (bash+7zip is standard on GH windows runners),
# else fall back to a portable python3 zipfile. `zip` itself is not always
# present on GH windows runners.
if command -v 7z >/dev/null 2>&1; then
( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null )
elif command -v zip >/dev/null 2>&1; then
( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" )
else
python3 - "$app_image" "$zipfile" <<'PY'
import os, sys, zipfile
src, dst = sys.argv[1], sys.argv[2]
root = os.path.dirname(src)
base = os.path.basename(src)
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, files in os.walk(src):
for f in files:
p = os.path.join(dirpath, f)
zf.write(p, os.path.relpath(p, root))
PY
fi
echo "Collected: $zipfile"
else
local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)"
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
echo "Collected: $tarball"
fi
fi
# 2. Linux native installers (.deb, .rpm). jpackage writes them directly
@@ -166,10 +201,14 @@ collect_cli_assets() {
#
# Expected inputs:
# geode/build/geode-image/geode/ flat app-image built by :geode:geodeImage
# (bin/geode + lib/*.jar + runtime/ + share/)
# (bin/geode + bin/geode.bat + lib/*.jar
# + runtime/ + share/)
# geode/build/jpackage/*.deb from :geode:jpackageDeb (Linux only)
# geode/build/jpackage/*.rpm from :geode:jpackageRpm (Linux only)
#
# On Windows the flat image is packaged as .zip; every other OS uses tar.gz.
# See the matching comment in collect_cli_assets for the tool-selection order.
#
# Usage: collect_geode_assets <family> <arch> <version> <dest_dir>
collect_geode_assets() {
local family="$1" arch="$2" version="$3" dest="$4"
@@ -178,14 +217,36 @@ collect_geode_assets() {
abs_dest="$(cd "$dest" && pwd)"
shopt -s nullglob
# 1. Tar the flat app-image into geode-<version>-<family>-<arch>.tar.gz.
# This is the portable-across-OS asset — macOS runners produce only
# this one.
# 1. Archive the flat app-image into geode-<version>-<family>-<arch>.<ext>.
# macOS runners produce only this one. Windows uses .zip; every other
# OS uses tar.gz.
local app_image="geode/build/geode-image/geode"
if [ -d "$app_image" ]; then
local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)"
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
echo "Collected: $tarball"
if [ "$family" = "windows" ]; then
local zipfile="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" zip)"
if command -v 7z >/dev/null 2>&1; then
( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null )
elif command -v zip >/dev/null 2>&1; then
( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" )
else
python3 - "$app_image" "$zipfile" <<'PY'
import os, sys, zipfile
src, dst = sys.argv[1], sys.argv[2]
root = os.path.dirname(src)
base = os.path.basename(src)
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, files in os.walk(src):
for f in files:
p = os.path.join(dirpath, f)
zf.write(p, os.path.relpath(p, root))
PY
fi
echo "Collected: $zipfile"
else
local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)"
( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" )
echo "Collected: $tarball"
fi
fi
# 2. Linux native installers (.deb, .rpm). jpackage writes them directly