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 a3fac4fc08 Suspend the incoming-message chain down to SubscriptionListener.onEvent
A consumer that cannot suspend has to block, and blocking here deadlocks
the whole client.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:45:02 -04:00
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
Vitor PamplonaandGitHub e822911d09 Merge pull request #3860 from vitorpamplona/claude/bridge-relay-url-parsing-okxtyw
Fix URL detection to exclude quotes from parsed URLs
2026-08-05 10:44:28 -04:00
Claude 5c56444054 fix: strip the whole punctuation tail from a detected url
Audit follow-up to the quote fix. The path, query and fragment readers only
stop on a space, and readEnd dropped a single trailing delimiter, so a quoted
link that closed a sentence kept its quote:

  He linked "https://example.com/some/path".  ->  https://example.com/some/path"
  (see "https://example.com/some/path")       ->  https://example.com/some/path"

readEnd now strips the tail in a loop. The balance check runs on every round,
so a url that legitimately ends in a matched closer still stops the strip:
`[link](…/Bitcoin_(disambiguation)).` keeps `(disambiguation)` and drops the
`).` that belongs to the sentence.

Differential run over a 4000-string corpus against the previous commit: 8 rows
change, every one of them the removal of extra trailing punctuation. No url is
gained, lost or truncated mid-string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:28:24 +00:00
Claude dc45477deb fix: don't glue quotes onto detected urls
A bare host wrapped in quotes ("relay.momostr.pink") was detected with the
opening quote attached, so the rendered link read `"relay.momostr.pink` and
pointed at a host that does not exist. The mirror case was also wrong: a
quoted url with a path/query/fragment kept the closing quote, because those
readers only stop on a space.

Quotes are not host characters, so they now end the current token exactly
like a space does in readDefault (covering the leading quote and a quote
glued to a previous word, e.g. `href="www.google.com"`), and they were added
to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as
part of a path, query or fragment is stripped on readEnd. The set covers the
ascii quotes plus the typographic family, including the guillemets below the
international-character threshold that the ascii boundary rule never cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:08:01 +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
Vitor PamplonaandClaude Opus 5 f9bef87160 style(desktop): import Compose symbols in NotificationSettingsScreen
Follow-up to 8f8713d8 (nostr proposal 259a0bb1). CLAUDE.md forbids
fully-qualified class names inline in function bodies; the merged
proposal introduced one (androidx.compose.runtime.LaunchedEffect) and
the file already carried four more that predate it. Import them all and
reference them by simple name: LaunchedEffect, snapshotFlow,
rememberCoroutineScope, LocalWindowInfo.

Also rewrites two comments the proposal added:
- the auto-enable comment was written in the first person and described
  the author's own earlier mistake; restate it as what the code does and
  which two paths it covers.
- the "Turn on desktop notifications" comment claimed the button renders
  only when the user explicitly disabled notifications, but the guard is
  `!enabled` alone. Describe the actual condition and why it is enough.

Drops a redundant `enabled = true` on that OutlinedButton (the default).

No behaviour change. :desktopApp:compileKotlin and :commons:jvmTest green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:49:30 -04:00
Vitor PamplonaandGitHub a58289b62c Merge pull request #3859 from vitorpamplona/claude/yggdrasil-ipv6-compat-03mctx
Support IPv6 overlay relays (Yggdrasil) with RFC 5952 canonicalization
2026-08-05 00:36:13 -04:00
Claude 70d51cc98b fix(relay): parse the authority instead of substring-matching the url
Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most
predating this branch, all with one root cause: the predicates ran `contains`
over the whole url rather than parsing the authority. These decide whether a
relay is exempt from Tor, and relay urls arrive from other people (NIP-65
lists, relay hints, r tags), so they are attacker-controlled input.

- A path could impersonate the host. `wss://evil.example.com/127.0.0.1`
  answered isLocalHost() == true, so any relay list could hand the app a url
  that silently dropped its own Tor routing. The IPv6 lookup added earlier on
  this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization
  could rewrite a path outright, corrupting the url.

- `.onion:8080` never matched the `.onion/` test, so an onion relay on an
  explicit port was not treated as onion at all: never forced onto Tor, and its
  hostname went to the clearnet DNS resolver. The fully-qualified `.onion.`
  spelling missed the same way.

- Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass
  folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme
  neither host can serve.

- Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and
  127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while
  matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that
  could therefore exempt themselves from Tor. Same for notlocalhost.example.com
  against `contains("localhost")`.

- A `://` inside a path was read as a scheme separator, so
  `relay.com/x://127.0.0.1` read its path as the authority.

Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to
the authority, strips :port and trailing dots and validates the scheme; private
ranges are parsed via a new Ipv4 util rather than substring-matched;
comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion()
delegates instead of keeping a second, weaker copy of the test.

No performance regression: the old form ran six full-string scans, the new one
bounds its work to the authority and rejects a DNS host from an IP parse on one
character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port
case answers without allocating the parser's buffer.

Ipv6 is now pinned by a differential test: 4000 random addresses round-trip
against java.net.InetAddress in both directions, and the canonical form is
asserted equal to OkHttp's host for the same address, so the relay identity the
app stores provably matches the host it dials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-05 04:22:59 +00:00
Claude 067d68b89c feat(relay): canonicalize IPv6 relay urls and support overlay meshes
Closes the four gaps the previous commit characterized for relays on an
Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served
over plain ws:// (no DNS, no CA-issuable certificate).

New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical
formatting and range classification. No java.net, so it works on every KMP
target.

- Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets
  one address be spelled many ways and the RFC 3986 pass only folded hex case,
  so two spellings survived as two NormalizedRelayUrl values for one host —
  and that value keys the connection pool, the relay-list sets, the NIP-11
  cache and the per-relay stats, so the app dialed one relay twice. The
  canonical form matches what OkHttp renders when it dials; the tests assert
  that agreement differentially. Relay lists rehydrate through normalizeOrNull,
  so stored entries fold on load and no migration is needed.

- Add isOverlayNetwork() for 0200::/7 and default those relays to ws://:
  nothing can issue a certificate for the range, so wss:// could only fail its
  handshake, and the overlay already encrypts end to end.

- Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1,
  fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of
  published relay lists, as its IPv4 equivalent already did.

- Never route an overlay relay through Tor: the range is unroutable there, so
  proxying guaranteed failure rather than privacy. TorRelayEvaluation covers
  both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers
  non-relay HTTP.

- Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints),
  but only when the whole string parses as an address, so host:port and
  addressable pointers still fall through. RelayUrlEditField now shows an error
  instead of no-opping, fixing the silent Add button for all invalid input.

Mesh relays are still published in NIP-65 and offered by the outbox model; the
plan doc explains why that is left as a maintainer's call, and records that no
live socket test was possible here (the container has no IPv6 stack).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 22:28:07 +00:00
Claude 129401bdaf test(relay): characterize Yggdrasil/IPv6 relay handling
Assesses how the app fares when relays live on an Yggdrasil overlay, where
every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws://
(no DNS, no CA-issuable certificate).

The happy path works: a hand-typed ws://[...]:port normalizes, survives the
RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and
cleartext is already permitted globally.

Four gaps are pinned by the new characterization tests:

1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal
   spellings of one address yield two NormalizedRelayUrl values while OkHttp
   collapses them to one host — duplicate sockets, REQs and stat entries.
2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to
   wss:// and can only fail its TLS handshake.
3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and
   RelayUrlEditField.submitRelay has no else branch — the Add button silently
   does nothing.
4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are
   dialed through the SOCKS proxy, which cannot route 0200::/7.

No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records
the full assessment, the NIP-65/outbox propagation consequences of publishing a
key-derived mesh address, and what could not be verified here (the analysis
container has no IPv6 stack, so nothing below the socket was exercised).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 21:36:22 +00:00
Vitor PamplonaandClaude Opus 5 8f8713d825 Merge PR: fix(desktop): auto-enable master notif switch when OS permission already granted
Merges nostr proposal 259a0bb1 into main:
- fix(desktop): auto-enable master notif switch when OS permission already granted

Follow-up to e9475dd0, which only flipped the master notification switch on
the NotRequested -> Granted path. Adds NotificationSettings.wasExplicitlyDisabled()
(backed by a new java.util.prefs "explicitly_disabled" key) so the Settings
screen can tell "off because it defaults off on first launch" from "off
because the user turned it off", and a LaunchedEffect that auto-enables the
switch in the former case when the OS permission is Granted or NotApplicable.
Adds a "Turn on desktop notifications" recovery button for the deliberate
opt-out path.

Note: on Windows/Linux permissionState is NotApplicable from startup, so the
master switch now auto-enables the first time the user opens Notification
Settings, overriding the off-by-default first-launch state.

Verified before merge: :commons:jvmTest (4 new hermetic tests) and
:desktopApp:compileKotlin both green on top of current main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:34:02 -04:00
Vitor PamplonaandClaude Opus 5 21b02d780f Merge PR: ci: publish linux-arm64 desktop, amy, and geode release assets
Merges nostr proposal 1d98285c into main:
- ci: publish linux-arm64 desktop, amy, and geode release assets

Adds ubuntu-24.04-arm legs to the build-desktop, build-cli and build-geode
release matrices so aarch64 Linux users get .deb/.rpm/.AppImage/.flatpak/
.tar.gz for the desktop app plus amy and geode bundles. Parametrizes the
appimagetool fetch, the portable archive names and the Flatpak bundle name
by arch, computes the AppImage multiarch lib path from uname -m at launch,
and extends the desktop release-deb smoke test to arm64.

Verified before merge: the appimagetool 1.9.0 aarch64 SHA256 pin matches
the upstream release, and secp256k1-kmp-jni-jvm-linux ships a
linux-aarch64 libsecp256k1-jni.so so signing works on arm64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:32:30 -04:00
Vitor PamplonaandGitHub d3bd7a45b9 Merge pull request #3852 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-04 11:56:22 -04:00
vitorpamplonaandgithub-actions[bot] a5d2d153c8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-04 15:40:56 +00:00
Vitor PamplonaandGitHub 3a702add57 Merge pull request #3857 from vitorpamplona/claude/relay-url-normalizer-mggnkh
NIP-66 relay monitoring: streaming probes, read/write checks, URL fixes
2026-08-04 11:38:07 -04:00
Claude 5ec35c7772 feat(quartz): default the read test to kind 0, limit 1
A kind-0, limit-1 REQ works everywhere: purpose relays (purplepag.es)
reject kind-less filters outright, and practically every relay stores
some profile. Verified against production — purplepag.es's read side now
measures instead of going unobserved. Pass a different kinds list to
probe a specific shelf, or null for a kind-less query on relays known to
allow one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:35:22 +00:00
Claude 9a4f6c6cdd feat(quartz): optional kinds on the read-test filter (production finding)
Verified the new probe surface end-to-end against production relays
(probeFlow streaming, readWriteCheck, signed 30166 templates). One
compatibility finding: purpose relays like purplepag.es reject any REQ
that names no kind ('blocked: filters must specify at least one kind'),
leaving their read side unobserved. readTestFilter/readWriteCheck now
take an optional kinds list for those; the default stays kind-less
because naming kinds also narrows the query on every other relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:26:54 +00:00
Claude c767298368 fix(quartz): audit fixes — foreign-OK confirmation bug, normalizer hot-path allocation
Audit findings across the branch, each verified with a failing test or
measurement before the fix:

- publishAndCollectResults counted an OK from a relay OUTSIDE relayList
  (same event id — a probe-wave straggler, or any republish of the same
  event to a different relay set) toward its confirmation window, ending
  the wait loop early and misreporting still-pending listed relays as
  NO_RESPONSE. The OK branch now carries the same relayList guard the
  onCannotConnect/onDisconnected branches always had. Regression test
  proves the failure without the guard. readWriteCheck additionally
  varies the probe event content per wave so wave N's confirmation window
  can never match wave N-1's event id at all.

- RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally,
  allocating a full string copy for ANY url merely ending in '%', '2' or
  '0' — which includes every relay port ending in zero (wss://host:3030).
  Now gated on endsWith("%20"), keeping the hot path allocation-free;
  semantics unchanged (test pins both the trim and the untouched-port
  cases).

- amy relay probe --file: unreadable file is now a clean bad_args error
  instead of a stack trace, and skipped onion urls are counted and
  reported (file_onion_skipped) instead of vanishing from the tally.

- probeFlow KDoc now states that a slow collector eats into the current
  wave's absolute deadline (answers are still recorded; silent relays get
  less listening time), not just that it delays the next wave.

Verified non-issues: androidx.collection LruCache is internally locked
(safe for CachedNip11Fetcher/normalizer concurrency); probeWave's
per-terminal emission cannot lose or double-emit verdicts (remaining-set
guard, data maps read at emission time); existing publish callers all
benefit from the OK guard rather than depending on the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:14:17 +00:00
Vitor PamplonaandGitHub ad3ce4d1a1 Merge pull request #3856 from vitorpamplona/fix/filters-changed-compares-only-first-filter
fix(relay): compare every filter in FiltersChanged, not just the first
2026-08-04 11:11:53 -04:00
Vitor PamplonaandClaude Opus 5 0d0117061a fix(relay): compare every filter in FiltersChanged, not just the first
`needsToResendRequest(List, List)` used a non-local `return` inside
`forEachIndexed`, so the loop always returned on iteration 0 and only
`filters[0]` was ever compared. A subscription whose first filter happened
to be unchanged reported "no resend needed" however much the rest had
changed, leaving the relay serving a stale filter set and the app silently
missing events. Only the size check offered any protection, so the bug was
invisible whenever the filter count stayed constant.

Replaces the loop with an indexed scan over all filters, which also drops
the lambda allocation and matches the hot-path style in this package.

Adds FiltersChangedTest. 3 of its 9 cases fail on the unfixed code — all of
them changes beyond index 0 — while the other 6 pass both before and after,
pinning the blast radius to exactly the buggy behaviour. Coverage includes
the deliberate `since`-moves-forward exemption, which must not trigger a
resend on any index.

Note for reviewers: PoolRequests.kt:490 and :528 use this inverted as a
"same as last" refusal check, so those become stricter — filter sets that
differ only beyond index 0 were previously treated as identical and will
now correctly be treated as changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:05:45 -04:00
Claude fd5bd994a1 feat(quartz): read+write relay checks — observed facts only, no NIP-11 claims
RelayProber.readWriteCheck(relays, signer) is the deeper check pair for
relays already proven live (warm sockets from a probe that just ran):

- READ: a real limit-1 REQ the relay must query its store for, timed
  REQ→first answer (honest rtt-read on an open socket).
- WRITE: one ephemeral RelayProbeWriteTest event signed by the monitor
  key, timed publish→OK (honest rtt-write). An OK false is a measured
  policy answer, kept with its NIP-01 machine-readable reason; only
  silence leaves the write side unobserved (writeAccepted = null).

publishAndCollectResults now stamps each OK with its elapsedMs (a
rejection is still a round trip; -1 when the relay never answered), so
any caller gets write latency for free.

toDiscoveryEventTemplate(readWrite = ...) folds the pair into the 30166
template: rtt-read/rtt-write when measured, R auth / R pow when the
write was refused with auth-required:/pow:. NIP-11-derived tags (N
supported NIPs, k kinds, T type) are deliberately NOT emitted — those
are relay self-claims, and publishing them under a monitor signature
without per-NIP compliance tests would launder claims into
measurements. Per-NIP/per-kind compliance suites can come later as
opt-in checks; open/read/write is the default surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 14:41:15 +00:00
Claude a7eec1d605 feat(quartz): NIP-66 check options — read-test filters, write-test event, cached NIP-11 fetcher
RelayProber.probe()/probeFlow() take a filters option choosing the check:
LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no
payload) or readTestFilter(limit = 1) — a REQ the relay must actually work
for, querying and streaming real events, making Verdict.rttEoseMs a genuine
read test.

RelayProbeWriteTest.build() creates the write-check event: ephemeral kind
20166 (never stored by compliant relays) carrying a NIP-40 expiration tag
60s out as belt-and-braces for relays that store unknown ephemeral kinds.
Publish it under the monitor key, time the OK for rtt-write, map rejection
prefixes to R requirement tags — an OK false still proves the write path.

Nip11Fetcher is the missing fetch seam for relay information documents,
mirroring Nip05Fetcher: the interface lives in commonMain,
OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json
GET, and CachedNip11Fetcher wraps any implementation with a TTL cache —
successes trusted for a day, failures remembered for five minutes so a
census doesn't hammer hosts that just refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 14:18:05 +00:00
Claude d9a58950ec feat(quartz): stream NIP-66 probe verdicts and expose them as signable 30166 templates
RelayProber.probeFlow(urls) is a cold Flow that emits each relay's Verdict
the moment the relay resolves (EOSE, CLOSED or connect failure) instead of
at the end of the whole census; only silent relays wait for their wave's
deadline. probeWave now resolves verdicts per-terminal, so the batch
probe() shares the same path.

Verdict.toDiscoveryEventTemplate() renders a verdict as an UNSIGNED
kind:30166 template (d = normalized url, n network type, rtt-open when
reachable, R auth when the probe hit a NIP-42 auth-required CLOSED) so an
external consumer signs with its own monitor key:

    prober.probeFlow(urls).map { it.toDiscoveryEventTemplate() }
        .collect { publish(signer.sign(it)) }

rtt-eose is deliberately never published as rtt-read: it is measured from
the wave start (dial + TLS + queueing + read), and aggregators rank on
rtt values. The RelayObserver/RelayMonitor path supplies honest
rtt-read/rtt-write from real traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:57:00 +00:00
Claude ba7cc72dd2 feat(cli): amy relay probe --file to census external relay-url candidates
Feeds a file of raw candidate urls (one per line) through the same
RelayUrlNormalizer the app uses, then probes the surviving clearnet set
alongside the store's known universe. Rejected and onion counts are
reported (file_urls/file_normalized/file_rejected in the JSON output),
and results land in the NIP-66 kind:30166 reachability cache keyed by
the normalized url as d-tag, as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:23:16 +00:00
Claude 156176f5fe fix(quartz): stop RelayUrlNormalizer from accepting urls that can never be relays
Validated against a 45k-entry corpus of relay-url hints exported from real
events (317k tag occurrences). The normalizer was converting ~30k distinct
https:// urls with paths (Mastodon/bridge actor urls from proxy tags, web
pages, images) into wss:// addresses that can never answer, wasting
connection attempts and relay-pool slots.

- http(s) → ws(s) scheme swap now only applies to bare hosts
  (host[:port] plus optional trailing slash); an http url with a path,
  query or fragment is a web resource, not a mistyped relay.
- Authority validation for all schemes: rejects empty hosts, userinfo
  (@), percent-encoding and commas in the host, and paths that start
  with // (the signature of a second pasted url, e.g. wss://https//host).
- Interior whitespace and backslashes reject the whole string (multiple
  urls or prose in one field).
- Zero-width characters (U+200B..D, U+2060, BOM) are stripped instead of
  corrupting the parse (wss://\u200Bnos.lol previously normalized to the
  scheme-less //nos.lol/).
- Schemeless candidates must look like host[:port] (single colon, numeric
  port), rejecting addressable pointers (31990:pubkey:dtag) and bare
  scheme leftovers (wss:) before the expensive RFC 3986 parse.
- Protocol-relative //host/ inputs normalize as wss:// instead of
  resolving to https://.
- normalizeOrNull now double-checks the parser output still starts with
  ws(s):// and rejects otherwise.

Corpus impact: 30,014 garbage urls (30,333 events) now rejected, 0 real
relays lost (all 15,162 kept urls normalize byte-identically), 6 broken
outputs fixed. fix() itself stays allocation-free on the happy path
(~357ns vs ~318ns per call on the garbage-heavy corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 13:23:06 +00:00
Vitor PamplonaandGitHub 1a25557bdb Merge pull request #3855 from vitorpamplona/sync-accessories-from-vespa-relay
quartz/geode: sync accessories from vespa-relay, InsertOutcome.Failed contract, mirror resume coverage
2026-08-04 01:58:29 -04:00
Claude b48b87a60b Merge remote-tracking branch 'origin/main' into sync-accessories-from-vespa-relay
# Conflicts:
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt
2026-08-04 05:44:17 +00:00
Claude cdbd550405 Fix audit findings across the sync accessories and their consumers
quartz:
- SQLiteEventStore: classify per-row savepoint errors — policy refusals
  (blocked:/constraint/not allowed) stay Rejected, everything else is now
  Failed, so disk-full no longer masquerades as 2M duplicate rejections
- IEventStore.batchInsert default: rethrow CancellationException and map
  unknown throws to Failed (re-offering a duplicate is idempotent;
  dropping a good event on a transient store error is not)
- IngestQueue: rethrow CancellationException instead of stamping a
  cancelled batch Failed and continuing
- HostStrikes: make the eviction verdict exactly-once under concurrency
  (deadHosts.add is the atomic gate) and re-check produced before
  publishing
- SyncCoverage: bound the identity fingerprint cache (a caller minting
  fresh Filter instances per cycle could grow it forever); legs() gains a
  floor parameter so a complete band re-opens its older span when the
  caller's window deepens; coveringWindow no longer treats a fully
  covered relay as needing the whole filter
- PagingWindowProgress: accept single-second windows (a band's re-read
  edge leg is exactly that shape)

geode:
- MirrorWorker: cap reconciledThrough at the leg's own ceiling — the
  older leg of a resumed catch-up no longer stamps the band complete
  through 'now' before the newer leg has run (silent event loss for up
  to fullResyncSeconds if that leg failed)
- MirrorWorker: run negentropy and the paged fallback by hand instead of
  negentropySyncOrFetch: drops the O(delivered-ids) dedup set from the
  mirror path, and a fallback resets the observed span so a band never
  claims interior ranges only a half-finished reconcile scattered over
- MirrorWorker: clamp a paged band's ceiling to the snapshot instant so
  one future-dated event cannot suppress the next boot's newer leg
- MirrorWorker.close(): join the workers (bounded) so the final coverage
  flush carries the last records
- Main: gate the coverage file on the store actually being persistent —
  database.file with in_memory=true (the default) persisted bands over a
  volatile store, and the next boot skipped the backfill over an empty
  database; honor --db overrides
- SyncCoverageFile: request ATOMIC_MOVE explicitly; fix the restore/dirty
  comment
- Import summary now prints the failed count; document
  mirror_sync_state_file in config.example.toml
2026-08-04 05:22:40 +00:00
Vitor PamplonaandGitHub c029b271e7 Merge pull request #3854 from vitorpamplona/claude/nip50-grammar-extractor-rejections
nip50Search: search grammar + weighted field extraction; nip01Core: shared store-semantics rules
2026-08-04 01:06:36 -04:00
Claude 4081ef1681 nip01Core: one owner rule, one supersession rule, one tag-name rule
Three semantics rules each existed as multiple independent copies:

- Event.owner() (gift-wrap recipient controls the wrap, else the
  author — NIP-09/62 authority) was derived inline in
  EventIndexesModule and again in EventStoreProjection.ownerOf.
- The NIP-01 replaceable tiebreak (newest created_at, ties to the
  lexically smallest id) lived in EventStoreProjection.supersedes,
  in SQL, and downstream.
- "Indexable tag name" was spelled `length == 1` in four places,
  which admits "5" and "#" — names the NIP-01 #x filter space
  (single a-zA-Z letters) cannot address, letting stores disagree
  about which tags filters reach. isIndexableTagName encodes the
  NIP-01 rule; converging FilterIndex and the SQLite
  IndexingStrategy on it deliberately tightens single-char
  non-letter tag names out of the index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 05:00:40 +00:00
Claude 804b850095 Audit fixes: empty exclusions, missed fallback, one vocabulary
An all-dash search token ("--") stripped to an empty exclusion that
toSearchString/stripExtensions round-tripped into a REQUIRED "-" term
reaching SQLite FTS; it is now dropped at parse. IngestQueue's
batchInsert fallback was the one site still hand-writing "insert
failed" (unprefixed) while every other path emits
RejectionReason.INSERT_FAILED. RejectionReason no longer duplicates
the NIP-01 prefixes MachineReadablePrefix already owns, and the
expiration trigger now rejects with the same words as the Kotlin
pre-check instead of its own spelling. Tests pin the "--" drop,
consecutive quoted spans, text after a closing quote, the extractor's
fallback tier, blank-content normalization, and the
unparseable-buzz-content hashtag seam; extractor KDoc now states
where the trimmed/non-empty and never-empty-Profile guarantees
actually live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 05:00:40 +00:00
Claude 42fc73f6cb nip50Search: per-kind weighted search-field extraction
SearchableEvent.indexableContent() flattens a kind's searchable text
into one blob, so a weighted full-text backend (title above summary
above body) had to re-derive the decomposition itself — and drift
every time a kind's parsing changed here. SearchFieldExtractor now
lives beside the kinds it decomposes: title-like accessors primary,
summary/description secondary, body tertiary, kind-0-shaped metadata
in profile roles, with an indexableContent() fallback so every
searchable kind, current or future, is covered.

IndexableFields is a sealed shape — Profile or Tiered — so a kind
cannot mix identity fields with content tiers, and each shape
declares its own website role (a profile's homepage; a content kind's
affiliation URLs). Multi-valued roles are carried UNJOINED, as lists:
hashtag and location tags ride raw beside the tiers (filled by the
one tiers() funnel every content branch uses, so no branch can forget
them and profile shapes never see them), and separator or weighting
choices — hashtags at summary weight, "\n" vs " ", arrays vs joined
columns — belong to the backend, not the library. Empty extractions
always normalize to None.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 04:38:36 +00:00
Claude 19d8e3069d Align the sync accessories with quartz vocabulary; geode catch-up resumes
Renames from review: SyncBands -> SyncCoverage ("sync" reads negentropy-ish
in quartz, and coverage is the role — the bands are the records), and
PagingProgress moves into relay.client.paging as PagingWindowProgress,
beside RelayLoadingCursors and RelayPagingProgress, with its docs swept from
"walk" dialect to quartz's pagination vocabulary and cross-references
delineating the three: cursors are in-memory positions for demand-driven UI
paging, the window progress is fraction/ETA for a bulk pagination over a
known window, coverage is persistent intervals that license skipping work.

geode adopts both halves of the new contract. MirrorWorker counts
InsertOutcome.Failed in its own `failed` counter instead of folding it into
`rejected`, and the down catch-up gains resume memory: SyncCoverageFile
persists SyncCoverage next to the event database (admin state-file
convention, temp-file + atomic move, daemon flush), and runCatchUpDown asks
only for the legs outside the covered band. Bands are keyed on the stable
scoped filter — never the boot window, whose since/until change every start
— and clamped to the window, which only slides forward, so an old band can
never license skipping a range an earlier boot could not ask about. A clean
reconcile records completeness through its snapshot instant; a paged
fallback earns only the span it saw. For an upstream without NIP-77 this
turns the every-boot full re-download of the backfill window into a
resumed walk.

Off unless wired: MirrorWorker's coverage parameter defaults to null and
in-memory stores keep no state file, so existing tests and setups are
unchanged. Full :quartz:jvmTest and :geode:test pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 04:24:19 +00:00
Vitor PamplonaandGitHub 9231195890 Merge pull request #3853 from vitorpamplona/claude/quartz-codebase-skills-mrzz5e
docs: Add three store-implementer skills (event-store-semantics, nip85, searchable-events)
2026-08-04 00:12:36 -04:00
Claude 564cafedc4 Replace insertBisecting with a Failed outcome on the batchInsert contract
Bisecting existed to reconstruct per-event attribution after a store threw a
batch-wide exception. Better to never lose the attribution: batchInsert's
contract now requires per-row isolation, with a third outcome telling whose
fault a miss was. Rejected is the EVENT's fault (duplicate, expired, invalid,
blocked) and is final; Failed is the STORE's fault (schema drift, a failed
feed, a resource error) — the event was good, it is lost unless re-offered,
and a rising Failed count means the store is broken rather than that
upstreams send junk. Throwing is reserved for failures with no per-event
answer (engine unreachable, transaction never started), readable as "nothing
in this batch was written".

Consumers updated: RelaySession maps Failed to OK false with NIP-01's
"error:" prefix; IngestQueue converts a thrown batch and a missing outcome to
Failed instead of Rejected; NdjsonImportExport counts failed apart from
rejected; geode's MirrorWorker logs store failures at warn instead of
folding them into debug-level rejections. BisectingInsert and its test are
removed — with attribution guaranteed by the contract, retry-by-splitting
has nothing left to do.

Full :quartz:jvmTest passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 03:53:39 +00:00
Claude e54a546d10 SearchQuery: parse quoted phrases and -word exclusions
NIP-50 search strings in the wild carry Google-style syntax the
extension tokenizer could not see: "exact phrase" requirements,
-"phrase" and -word exclusions. SearchQuery now lifts quoted spans
BEFORE the extension pass — the order is load-bearing: the extension
pass is quote-blind, so a span ending in an extension-shaped token
would lose its closing quote, and lifting first also lets quotes
protect extension-shaped tokens ("include:spam" is a phrase, not an
extension). toSearchString() and stripExtensions() reassemble the
full grammar; existing parses are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 03:48:04 +00:00
Claude b46063713e Centralize the insert-rejection vocabulary in RejectionReason
Every store spelled its rejection reasons inline — the expired-event
string was duplicated four times across ObservableEventStore and
SQLiteEventStore. RejectionReason now carries the NIP-01 OK
machine-readable prefixes plus the standard store reasons, so
InsertOutcome.Rejected tallies and OK-frame building see one
vocabulary no matter which store produced the outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
2026-08-04 03:48:04 +00:00
Claude d54e54b48a Add battle-tested sync accessories from vespa-relay's mirror
Four pieces extracted from a production Nostr mirror (NosFabrica/vespa-relay),
generalized to quartz's multiplatform primitives, with their test suites:

- nip01Core.store.insertBisecting: batchInsert fails as a unit, so one bad
  event costs its whole batch (999 good events per bad one at a 1000-event
  batch). Bisecting isolates the offender in ~2*log2(n) extra writes, and a
  fixed write budget keeps a store-wide failure (full disk, dead engine) from
  turning one failed write into ~2n.

- accessories.SyncBands: resume memory for fetchAllPages. Remembers the
  created_at band covered per (relay, filter) and asks only for the legs
  outside it, with inclusive edges so a page boundary cannot strand a run of
  same-second events. A finished negentropy reconcile records completeness
  through its start instant; a periodic full re-walk keeps stale claims from
  narrowing forever. Persistence is the caller's, via export/restore and an
  onChange hook.

- accessories.PagingProgress: progress for paged walks measured on the time
  axis, the only axis whose end is known in advance - count-based percentages
  degenerate to downloaded/downloaded = 100%. Needs no COUNT support.

- nip66RelayMonitor.reachability.HostStrikes: per-authority strike counting
  for outbox-scale fan-outs, where a filtering relay mints one url per user
  and per-url counters never converge. Ever-delivered overrides eviction in
  both race orders, and eviction surfaces exactly once for publishing.
  reachability.Unreachability (jvmAndroid): which failures may be published
  as a signed NIP-66 unreachable record - connection-level only, so a relay
  that answered the handshake and hung up mid-page is never libelled, and a
  caller's own bug is never the relay's fault.

57 tests pass on the JVM target; the common code uses quartz's ConcurrentMap,
ConcurrentSet and TimeUtils only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R
2026-08-04 03:33:48 +00:00
Claude 2b2e47256b docs: add nip85-trusted-assertions and searchable-events skills
Completes the store-implementer skill set requested by the vespa-eventstore
consumer: the NIP-85 trust-assertion model (kind map, tag vocabulary, value
semantics, authorization conventions) and the NIP-50 indexing surface (the
SearchableEvent contract plus an exhaustive kind -> indexableContent table
external search engines can diff at version bumps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
2026-08-04 03:04:17 +00:00
Claude 7b29f57526 docs: add event-store-semantics skill (IEventStore/SQLite store contract)
Documents the store's observable behavior as named rules (STORE-F/W/D/C/S/N)
so external IEventStore implementations can review pin bumps and annotate
divergences against a stated contract instead of reverse-engineering
QueryBuilder. Requested by the vespa-eventstore consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ
2026-08-04 02:55:14 +00:00
mstrofnone a110ce0a30 ci: publish linux-arm64 desktop, amy, and geode release assets
Amethyst v1.13.1 (and every prior release) shipped only linux-x64 desktop
binaries — .deb, .rpm, .AppImage, .flatpak, .tar.gz. Same for the amy
CLI and geode relay. Users on aarch64 hardware (Pinebook, Ampere
Altra, Raspberry Pi 4/5, AWS Graviton, arm64 servers, arm64 Chromebooks
running crostini, etc.) can't install any of them.

This teaches the release matrix about arm64:

- Add `ubuntu-24.04-arm` legs to build-desktop, build-cli, and
  build-geode. This is a standard, free public-repo GitHub-hosted
  runner (4 CPU / 16 GB / 14 GB SSD / arm64) since early 2025. No
  cross-compilation: jpackage / jlink / Compose Multiplatform 1.11
  all produce host-native artifacts.

- Fetch the matching `appimagetool-<arch>.AppImage` from the same
  1.9.0 release with an arch-specific SHA256 pin. `APPIMAGETOOL_URL`
  becomes `APPIMAGETOOL_VERSION` + per-arch SHA256 env vars.

- Parametrize the portable tarball/zip filename by `matrix.arch`
  (`amethyst-desktop-<ver>-linux-arm64.tar.gz` is now produced).

- Parametrize the Flatpak bundle filename and rewrite the manifest's
  `GST_PLUGIN_SYSTEM_PATH` from `x86_64-linux-gnu` to
  `aarch64-linux-gnu` on the arm64 leg. The Flathub-submission manifest
  (`desktopApp/packaging/flatpak/flathub/`) still gates on
  `only-arches: x86_64` — flipping that to include aarch64 is a
  follow-up once a Flathub aarch64 build has been validated end-to-end.

- Make the `createReleaseAppImage` gradle task pick its host arch from
  `System.getProperty("os.arch")` (amd64/x86_64 → `x86_64`, aarch64/
  arm64 → `aarch64`). Same task, same command, drives both legs.

- Fix `desktopApp/packaging/appimage/AppRun` to compute the multiarch
  library path from `uname -m` at launch time instead of hard-coding
  `x86_64-linux-gnu`. One script works in both AppImages on the target
  machine.

- Extend the desktop smoke test to run the release .deb build + launch
  probe on `ubuntu-24.04-arm` too, so arch-specific ProGuard/jlink
  breakage (missing native lib, arch-specific reflection root) is
  caught at PR time.

- Update BUILDING.md and scripts/asset-name.sh docs with the new
  arm64 asset names.

Follow-up assets published for the next tag push (v1.13.2+):

- amethyst-desktop-<ver>-linux-arm64.{deb,rpm,AppImage,flatpak,tar.gz}
- amy-<ver>-linux-arm64.{deb,rpm,tar.gz}
- geode-<ver>-linux-arm64.{deb,rpm,tar.gz}

Verification (local, before submitting):

- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/create-release.yml"))'` — parses clean
- `bash -n scripts/asset-name.sh desktopApp/packaging/appimage/AppRun` — parses clean
- `actionlint` — reports only pre-existing shellcheck style hints; no new errors
- Confirmed `linuxdeploy-aarch64.AppImage` and
  `appimagetool-aarch64.AppImage` exist under the same pinned release
  tags used for x86_64; SHA256 recorded from a fresh download.

Not addressed (out of scope for this PR):

- Homebrew / winget bump workflows (`bump-homebrew*.yml`,
  `bump-winget.yml`) — those consume the assets by name; the new arm64
  filenames don't change any x86_64 name they already reference.
- Android arm64 continues to ship as before (already had it).
2026-08-04 10:51:16 +10:00
Vitor PamplonaandGitHub ba78eb599e Merge pull request #3851 from vitorpamplona/fix/relay-subscription-lock-convoy
fix(relay): stripe the subscription-state lock per relay to end an ANR convoy
2026-08-03 19:38:06 -04:00
Vitor PamplonaandClaude Opus 5 5896ae5eff fix(relay): stripe the subscription-state lock per relay to end an ANR convoy
A production ANR on a Pixel 8 (Amethyst 1.13.1, anr_2026-08-03-12-55-26-256)
showed the app burning 596% CPU — 6 of 9 cores — with the main thread stuck in
WaitingForGcToComplete. 37 of 52 runnable DefaultDispatcher workers sat at ONE
program point inside PoolRequests.onIncomingMessage and 12 more at one point in
syncState, all state=R, while the single thread actually holding the lock was
itself parked in GC.

Root cause: RequestSubscriptionState.withLock was a raw busy-wait
(`while (lock.exchange(true)) { while (lock.load()) {} }`) with no yield or
backoff, and being `inline` it disappeared into its callers' frames. The lock is
per subId, but one subId spans every relay it runs on — 191 live sockets on that
device — so dozens of relay-dispatch threads piled onto a single AtomicBoolean.
Spinning is only correct when the holder cannot be descheduled; on Android it
always can.

The fix stripes the lock per (subId, relay) rather than making waiting cheaper.
All 11 withLock bodies in PoolRequests are already scoped to exactly one relay,
and every field of RequestSubscriptionState is keyed by relay, so the sharing was
purely an artifact of mutableMapOf not being thread-safe. State moves into a
ConcurrentMap<T, RelayState>; locks live in a fixed 32-entry stripe array that is
never mutated, so lock identity stays stable — if locks lived inside the map
values, a thread holding one while another dropped and re-created that entry
would leave both inside the critical section excluding nothing.

A suspending Mutex was measured and rejected: it needs 262 method overrides and
110 call sites to become suspend, and ran at 0.35-0.63x the current throughput.

Measured (LockDesignComparisonBenchmark, 191 relays / 64 dispatcher threads):
  striped vs per-sub lock  1.5-2.8x throughput, bystander p50 halved
On device (SM-T220, playBenchmark, same account, n=3 per design):
  DefaultDispatcher CPU  -35% mean / -30% median vs the spin lock,
  with non-overlapping ranges; GC -18%
Plus 10 min of driven UI stress (feed, profiles, chat, notifications,
communities): no ANRs, no crashes, thread pools stable.

Also here:
- PlatformLock: new expect/actual parking lock (ReentrantLock on jvmAndroid,
  NSRecursiveLock on Apple, spin only on linuxX64 which is a CI target). quartz
  cannot use commons' equivalent KmpLock because commons depends on quartz.
- LiveNegentropyIndex had the identical busy-wait with a full list SORT inside
  the critical section; switched to PlatformLock.
- ConcurrentMap.remove (+ tests), with a caution that a removable value must not
  own a lock callers acquire.
- SpinLockConvoyBenchmark: regression guard asserting contended waiters PARK
  rather than spin (fails-before / passes-after). Pure benchmarks are gated
  behind -PprodRelayBench=1, so CI cost is 0.3s rather than 51.5s.

Analysis and measurements: quartz/plans/2026-08-03-poolrequests-lock-contention.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:19:56 -04:00
Vitor PamplonaandGitHub fdc68e801a Merge pull request #3849 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-03 00:40:58 -04:00
vitorpamplonaandgithub-actions[bot] 30742ab352 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-03 04:35:55 +00:00
Vitor PamplonaandGitHub 1622bd7109 Merge pull request #3850 from vitorpamplona/claude/fetchallpages-timeoutms-d30cl1
Standardize timeout semantics to idle windows across all accessory APIs
2026-08-03 00:33:06 -04:00
mandClaude ebd9a163bc fix(desktop): auto-enable master notif switch when OS permission already granted
Follow-up to e9475dd079. That commit fixed the case where the user
clicked the "Enable OS notifications" button on a fresh install
(permission NotRequested \u2192 Granted) but the master toggle stayed off.
It missed the two closely-related cases the user was still hitting on
v1.13.1:

1. Permission was already granted from a previous session or install
   (e.g. an earlier v1.13.0 build, or the user allowed it via
   System Settings \u2192 Notifications directly). In this state,
   permissionState == Granted, so the "Enable OS notifications"
   button never renders \u2014 the button label promised the whole
   handshake but the code path that flipped the master switch only
   ran under NotRequested.
2. On Windows/Linux `permissionState` defaults to `NotApplicable`
   from the moment the app starts. The master switch is off by
   default (first-launch UX choice) and nothing ever flips it, so
   the auto-dispatcher stayed muted forever unless the user found
   the switch manually.

Fix:

- Add `NotificationSettings.wasExplicitlyDisabled()` so the Settings
  screen can distinguish "master switch is off because it defaults
  off on first launch" (auto-enable is fine) from "master switch is
  off because the user turned it off" (leave alone). Backed by a
  new java.util.prefs key `explicitly_disabled` that flips true on
  `setEnabled(false)` and gets cleared on `setEnabled(true)`.
- In `NotificationSettingsScreen`, a `LaunchedEffect(permissionState,
  enabled)` observes when the OS permission is Granted OR NotApplicable
  and the master switch is off. If the user has never explicitly
  turned it off, it auto-flips on \u2014 matching the "Enable OS
  notifications" contract for the paths the previous fix missed.
- Also render a "Turn on desktop notifications" button in the
  Granted branch when the user has explicitly turned notifications
  off. That's the recovery path for users who deliberately opted
  out and later want to opt back in without hunting for the
  master switch two rows away.

Behaviour on the fresh-install macOS path (permission NotRequested)
is unchanged \u2014 that path still runs the `requestPermission()`
flow inside the button's onClick, and the auto-enable happens via
the same LaunchedEffect once permissionState flips to Granted.

Tests (jvmTest, hermetic \u2014 UUID-scoped prefs nodes so tests never
share state or pollute real user prefs):

  PreferencesNotificationSettingsExplicitDisableTest:
    - fresh install defaults to not-explicitly-disabled
    - turning off marks explicitly disabled
    - turning on clears the explicit-disable flag
    - flag persists across new instances on the same prefs node

\ud83e\udd16 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 16:32:11 +10:00
284 changed files with 15633 additions and 1733 deletions
+27
View File
@@ -85,3 +85,30 @@ skills verified clean):
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
Event Store section corrected from "Android only" to commonMain/all platforms
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
## Phase 4 (2026-08): Store-implementer skills (external consumer request)
Three skills added at the request of an external Quartz consumer
(vespa-eventstore — a server-side `IEventStore` on Vespa that asserts result
parity against the SQLite store in CI). All three document the
**store/relay-implementer's perspective**, which `quartz-integration` and
`nostr-expert` (client-side) did not cover. Requirements doc: the skill-requests
file reviewed 2026-08-04; the requester's items #4 (storage-lifecycle-nips) was
folded into `event-store-semantics` per their own recommendation, and #5
(relay-server/geode policies) was declined as not currently needed.
- **`event-store-semantics/`** — the `IEventStore`/SQLite-store behavioral
contract as named rules (STORE-Fxx/Wxx/Dxx/Cxx/Sxx/Nxx) with a semantics
changelog for pin-bump review. Written from `QueryBuilder`,
`MergeQueryExecutor`, the seven `*Module.kt` files, and `IEventStore` KDoc.
- **`nip85-trusted-assertions/`** — the NIP-85 model (10040/30382/30383/30384/
30385), full tag vocabulary with value semantics, authorization conventions,
worked JSON examples, stability notes.
- **`searchable-events/`** — the `SearchableEvent` contract + maintenance
mandate, with `references/searchable-kinds.md` holding the exhaustive
kind → class → `indexableContent()` table (126 classes / 129 kinds) that
external search engines diff at version bumps.
Follow-ups suggested but not implemented: a shared JSON test-vector corpus for
filter semantics (testFixtures both the SQLite tests and external parity suites
could run), and a snapshot test pinning the searchable-kind set.
@@ -0,0 +1,325 @@
---
name: event-store-semantics
description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely.
---
# Event Store Semantics — the `IEventStore` / SQLite-store contract
The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`)
is the de-facto **reference implementation** of what a Quartz event store must do. Other
implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by
`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed
store) — reimplement its *observable behavior* and assert parity in CI. This skill states that
behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an
archaeology session through `QueryBuilder`/`MergeQueryExecutor`.
Every rule below was verified against the code as of this skill's last update. When you change
store behavior, **update the rule here in the same PR** and add a line to the
[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and
review pin bumps against this file.
## Key files
| Concern | File |
|---|---|
| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` |
| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` |
| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` |
| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` |
| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` |
| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` |
| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` |
| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) |
| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) |
| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) |
Executable spec: the test suites in
`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`,
`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`,
`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`,
`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test,
the test wins — and this file has a bug to fix.
## Kind classes (used throughout)
- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`.
- **Ephemeral**: `20000 ≤ kind < 30000`.
- **Addressable**: `30000 ≤ kind < 40000`.
- Everything else is a regular event.
---
## Filter matching (STORE-F)
**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to
`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses
`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with
`created_at == since == until` matches.
**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the
full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store.
(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they
simply never match.)
**STORE-F03 — tag filter combination.** Within one tag name, values are **OR**
(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND**
(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands
**every listed value** be present on the event — one join + equality per value — and composes by
AND with any plain `tags` in the same filter.
**STORE-F04 — only single-letter tag names are indexed (by default).**
`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`.
A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store.
Deployments can widen `shouldIndex`, but the stock contract is single-letter-only.
**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the
`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences:
`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the
query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via
plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other
tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and
when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored.
(An event has one d-tag, so AND-across-values could never match anyway.)
**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a
64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`,
`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification**
of hash matches, so a hash collision would return a false positive. Probability is negligible in
practice but nonzero — a parity harness comparing against an exact-match engine should know this
is the one place the reference can (theoretically) over-match.
**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter
becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with
SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits
10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45
counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`).
**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC`
tiebreak on equal `created_at` is applied **only when
`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default
**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite
returns them in storage order). Any newest-N is valid; a parity suite must not assert
same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the
`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors
run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte
from the single-SQL ordering.
**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape
"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through
`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind)
index cursors with dedup by id on the tag shape. This is an optimization, not a semantics
change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the
single-SQL plan (ordering caveat per STORE-F08).
**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything**
(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric:
it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented
on `QueryBuilder.delete`).
**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent
handling; don't rely on either outcome.** On the single-filter simple path an empty list
renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as
`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of
*only* empty-list filters degrades to the match-everything query. Known quirk; treat
empty-list filters as invalid input rather than replicating this shape.
**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows.
`limit = null` means unbounded. Negative limits are not defended against (don't send them).
**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.**
`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries;
it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work
targets the SQL semantics above, not `FilterMatcher`.
---
## Write path (STORE-W)
Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration
row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the
messages quoted below (they surface as the NIP-01 `OK false` reason).
**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't
parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique
indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""`
(empty string), which *does* dedupe normally.
**STORE-W03 — ephemeral events are never stored but are acked as accepted.**
`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000`
without writing (the live relay stream still broadcasts them). A DB-level backstop trigger
(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check.
**STORE-W04 — expired events are rejected at insert.** App-level check
(`event.isExpired()`) plus a trigger on the expiration-row insert
(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert`
**throws**; `batchInsert` returns `Rejected`.
**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a
future `expiration` store a row in `event_expirations`. Nothing filters them out of queries
after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep
returns the expired event.** Operators run the sweep periodically (README recommends ~15 min).
Re-inserting an already-expired event after the sweep is rejected per W04.
**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes
`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if
absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and
blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps
addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps
are also excluded from `authorsMissingOutbox()`.)
**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated
(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`,
`event_expirations`, `event_vanish`, and the FTS row follow the header by
`ON DELETE CASCADE` / trigger.
**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls
back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every
entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned
in input order; OK frames pair by event id, not order.
---
## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D)
**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose
owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06).
The id path has **no timestamp condition**: it deletes the target regardless of the relative
`created_at` values.
**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that
`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at`**inclusive**; a
version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's
author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same
`created_at <=` treatment against `(kind, pubkey)`.
**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's
events is inserted like any regular event (it may be useful to other relays/clients) but its
delete pass removes zero rows and creates no blocking.
**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects
(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is
named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`.
Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target)
still deletes on arrival, but would not block a later re-insert.
**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.**
Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows
from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a
considered decision.** NIP-09 leaves it open; at least one external implementation
(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is
the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and
the changelog — parity suites key off it.
**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when
`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`.
(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope
vanish events are stored as regular events with no side effects.
**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose
**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the
vanish event itself survives), and blocks inserts of owned events with
`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is
inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey
(unique on `pubkey_hash`).
**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking
created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10
empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62
events do that.
---
## NIP-45 count (STORE-C)
**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single
filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so
`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed
(dedup) **before** counting — an event matching several filters counts once. FTS-off + search
term → 0 (F-series search rules apply).
---
## NIP-50 search inside the store (STORE-S)
The indexing surface (which kinds are searchable, what text they contribute) is the
`searchable-events` skill; these rules are the store's query-side contract.
**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs
`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are
removed before FTS. Unsupported extensions are **ignored, never matched as literal text and
never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores
that *do* implement extensions receive the raw string through the relay layer and parse it with
`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc).
**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match
first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not
the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a
search term (best/min rank per event across branches); mixing search and non-search filters
falls back to `created_at DESC`.
**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/
since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions.
**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to
FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics.
Tokenization details live in `FullTextSearchModule` (see `searchable-events`).
**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a
non-empty search term matches **nothing** (query/count/delete alike); an empty-string search
imposes no constraint. Everything else is unchanged.
**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does):
tokenization moves off the insert path to a watermark-driven catch-up
(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50
results are exactly as fresh as the synchronous path.
---
## Negentropy / NIP-77 (STORE-N)
**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the
**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order
unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow
sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when
`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact
across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the
non-itemizable cases).
---
## Configuration presets
- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off,
`useAndIndexIdOnOrderBy` off, no live negentropy index.
- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and
tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves
`useAndIndexIdOnOrderBy` off.
- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the
index on next open (`ensureOptionalIndexes`), no migration.
## For parity implementers
- Treat the rule ids above as the vocabulary for divergence notes
(e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion").
- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for
holding a second engine to it.
- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when
diffing results byte-for-byte — both are places where a "divergence" may be the reference's
own slack, not your bug.
## Semantics changelog
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
- 2026-08-04 (baseline) — rules F01F13, W01W08, D01D08, C01, S01S06, N01 written from the
code at the time this skill was introduced. Changes before this date are not itemized;
archaeology starts at `git log` on `nip01Core/store/`.
@@ -0,0 +1,232 @@
---
name: nip85-trusted-assertions
description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store.
---
# NIP-85 Trusted Assertions — the Quartz model
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`.
NIP-85 is still an evolving spec; **this package is the operative definition** of what
Amethyst-family software writes and reads. This skill states the model (who signs what about
whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume.
## The model in one paragraph
An **assertion is signed by the asserting party** (a trust provider service, or the user
themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so
"latest card by provider P about subject S" is just the addressable coordinate
`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's
**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch
their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal
event signatures; the 10040→assertion link is **consumer-side convention** (see
"Authorization" below).
## Kind map
| Kind | Class | Kind class | d-tag = the subject | Content |
|---|---|---|---|---|
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
Addresses: `ContactCardEvent.createAddress(owner, target)``Address(30382, owner, target)`
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
tags plus topics; the encrypted card content is intentionally never indexed.
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
There is **no fixed tag name**: `tag[0]` *is* the service string.
```json
["30382:rank", "<provider pubkey, 64 hex>", "wss://nip85.brainstorm.world"]
```
- `ServiceType(kind, type)` parses/renders `"<kind>:<type>"` — kind must be an int, the first
`:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the
allocation-free prefix check.
- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey
(length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) —
entries failing any check are silently dropped, which is what keeps foreign tags like
`["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`).
- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take
`isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value
equality.
- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types —
`30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/
`30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf →
brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types
Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`,
`30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on.
## Authorization — what a consumer may assume
- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the
observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming
code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`:
`rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the
provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are
simply ignored for trust purposes (they may still be stored; dropping them — as an external
store's orphan sweep does — is a legitimate storage policy, not a protocol rule).
- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
metric (`liveUserRankProvider`, `liveUserFollowerCount` in
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`).
- **Multi-provider combination is unprescribed.** When two listed providers assert different
ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per
metric slot. Consumers choose their own policy — document it.
- The relay URL in the entry is a **fetch hint, and it is honored**:
`amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay
(`kinds=[30382], authors=[provider], #d=[targets]`).
### The dual use of kind 30382
The same kind serves two roles, distinguished **by author**:
1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`,
`followers`, `hops`, …); this is what 10040 discovery points at.
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
provider cards.
## Tag vocabulary and value semantics
All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric
value — a bad tag is *dropped*, never an error) + `assemble(value)``[name, value.toString()]`.
**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range
validation (rank isn't clamped, hours aren't checked against 023, counts may be negative) —
consumers must defend.
**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
| Tag name | Accessor | Type | Semantics |
|---|---|---|---|
| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. |
| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). |
| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. |
| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. |
| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. |
| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. |
| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. |
| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. |
| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. |
| `t` (repeatable) | `topics()` | List\<String> | Subject's topics/interests. |
| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. |
| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). |
**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`,
`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats).
**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`.
## Building and parsing (use the typed helpers, not raw `arrayOf`)
```kotlin
// Provider list: declare a rank provider (this is what `amy graperank register` does)
val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl)
val list = TrustProviderListEvent.create(tag, isPrivate = false, signer)
// or append to an existing one:
val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer)
val providers: List<ServiceProviderTag> = updated.serviceProviders() // public
val private = updated.privateTags(signer)?.serviceProviders() // private side
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
val card = ContactCardEvent.create(
targetUser = subjectPubkey,
signer = providerSigner,
publicInitializer = {
rank(87)
followers(1234)
hops(2)
},
)
card.aboutUser() // d-tag → subject pubkey
card.rank() // 87
// Event assertion: unsigned template only (30383/84/85 have build(), no create())
val template = EventAssertionEvent.build(targetEventId) {
rank(12)
reactionCount(40)
zapAmount(2100)
}
val signed = signer.sign(template)
```
## Worked end-to-end example
Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`):
```json
{ "kind": 10040, "pubkey": "<O>",
"tags": [
["30382:rank", "<P>", "wss://nip85.brainstorm.world"],
["30382:followers", "<P>", "wss://nip85.brainstorm.world"]
],
"content": "" }
```
Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:<P>:<S>`):
```json
{ "kind": 30382, "pubkey": "<P>",
"tags": [
["d", "<S>"],
["rank", "87"], ["followers", "1234"], ["hops", "2"]
],
"content": "" }
```
`P` asserts about an event `E` (kind 30383, addressable at `30383:<P>:<E>`):
```json
{ "kind": 30383, "pubkey": "<P>",
"tags": [["d", "<E>"], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]],
"content": "" }
```
Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe
`{kinds:[30382], authors:["<P>"], "#d":["<S>", …]}` at the hinted relay → newest card per
address wins → `rank()`.
Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real
10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four
assertion kinds with every tag populated).
## Freshness / supersession
Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag
convention and **no prescribed refresh cadence** — staleness policy is the consumer's.
Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when
`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the
card's `a`-tag (`30382:<provider>:<target>`).
## Stability notes (as of 2026-08)
- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape;
`rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint
consumption.
- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/
zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders +
tests exist; no in-repo publisher yet).
- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array<Any>`
dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params
are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/`
(stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d`
tag is reflected by `dTag()` even though the convention is `""`.
## Where it's consumed (reading list)
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
+117
View File
@@ -0,0 +1,117 @@
---
name: searchable-events
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
---
# Searchable Events — the NIP-50 indexing surface
## The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
```kotlin
interface SearchableEvent {
fun indexableContent(): String
}
```
One method; marker and extractor in one. An event kind is searchable **iff** its event class
implements this interface **and** the class is wired into `EventFactory` (the stores probe
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload).
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
## The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
implementor with its kind number, class, and the exact `indexableContent()` expression
(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to
answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so
`title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()`
is dead on the store path.
- Poll kinds (1068, 6969) append each option label on its own line.
## MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
change ships them stale search results).
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
is the contract. App-side, schedule the resumable overload after shipping such a change.
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
minus their comment, relay lists) stay out to keep the index small.
## How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)`
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
`event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
the same tokenizer runs on queries so drift cancels.
## NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
query, never match-nothing. A server-side store that implements its own extensions
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
`commons/.../account/AccountBootstrapEvents.kt`.
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
## Tests (executable spec)
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
FTS cleanup on replaceable rotation.
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
limit-after-score, multi-filter rank union.
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.
@@ -0,0 +1,166 @@
# Searchable kinds — the authoritative implementor table
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| Kind | Class | Package | `indexableContent()` |
|---|---|---|---|
| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) |
| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL |
| 9 | ChatEvent | nipC7Chats | `content` |
| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL |
| 14 | ChatMessageEvent | nip17Dm/messages | `content` |
| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL |
| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 24 | PublicMessageEvent | nipA4PublicMessages | `content` |
| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) |
| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) |
| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` |
| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL |
| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) |
| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL |
| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` |
| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL |
| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL |
| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL |
| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` |
| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL |
| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` |
| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL |
| 1617 | GitPatchEvent | nip34Git/patch | `content` |
| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL |
| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL |
| 1622 | GitReplyEvent | nip34Git/reply | `content` |
| 16301633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) |
| 1808 | AudioHeaderEvent | experimental/audio/header | `content` |
| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL |
| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL |
| 2004 | TorrentCommentEvent | nip35Torrents | `content` |
| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL |
| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` |
| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` |
| 9734 | LnZapRequestEvent | nip57Zaps | `content` |
| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL |
| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL |
| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL |
| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL |
| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL |
| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL |
| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL |
| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL |
| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL |
| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL |
| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` |
| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` |
| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` |
| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` |
| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL |
| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL |
| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL |
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL |
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL |
| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL |
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
| 30315 | StatusEvent | nip38UserStatus | `content` |
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL |
| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL |
| 31871 | AttestationEvent | experimental/attestations/attestation | `content` |
| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` |
| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL |
| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` |
| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL |
| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL |
| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` |
| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) |
| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL |
| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL |
| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL |
| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL |
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
| 45001 | ForumPostEvent | buzz/forum | `content` |
| 45003 | ForumCommentEvent | buzz/forum | `content` |
| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` |
**Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also
declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps
30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes
`title()\ndescription()`. If the factory mapping ever changes, this table changes with it.
## Abstract bases (no kind of their own)
| Base class | Body | Concrete kinds |
|---|---|---|
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
## How to regenerate / verify this table
```bash
# All implementor files:
grep -rln "override fun indexableContent" quartz/src/commonMain
# For each, pair the KIND constant with the indexableContent() body.
# Searchability on the store path additionally requires EventFactory registration:
grep -n "<ClassName>" quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
```
A CI-diffable snapshot test (assert the set of kinds whose `EventFactory` product implements
`SearchableEvent` against a checked-in list) would make this table impossible to go stale —
suggested follow-up, not yet implemented.
+107 -20
View File
@@ -26,8 +26,12 @@ env:
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
# embeds the AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
#
# Both arch binaries come from the same appimagetool release so their SHA256
# values move in lockstep on version bumps.
APPIMAGETOOL_VERSION: '1.9.0'
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
jobs:
# ---------------------------------------------------------------------------
@@ -38,11 +42,32 @@ jobs:
strategy:
fail-fast: false
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). 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: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
- { 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" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
defaults:
@@ -93,13 +118,21 @@ jobs:
set -euo pipefail
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
sudo apt-get update && sudo apt-get install -y desktop-file-utils
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
case "${{ matrix.arch }}" in
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
esac
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
curl -fsSL --retry 3 "$URL" -o "$DEST"
actual=$(sha256sum "$DEST" | awk '{print $1}')
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
exit 1
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
chmod +x "$DEST"
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
# (runtime-version is greped from the manifest so this never drifts).
@@ -203,17 +236,32 @@ 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: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
ARCH="${{ matrix.arch }}"
APP="desktopApp/build/compose/binaries/main-release/app"
mkdir -p desktopApp/build/portable
if [[ "${{ matrix.family }}" == "windows" ]]; then
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
else
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
fi
# Flatpak bundle: wraps the same createReleaseDistributable tree the
@@ -230,6 +278,17 @@ jobs:
PKG="desktopApp/packaging/flatpak"
APP_ID="com.vitorpamplona.amethyst.Desktop"
OUT="desktopApp/build/flatpak"
# AppImage-style arch names for the bundle filename.
case "${{ matrix.arch }}" in
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
esac
# Rewrite the arch-specific GStreamer plugin path in the manifest
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
# matches the original triplet.
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
"${PKG}/${APP_ID}.yml"
# Inject the AppStream <release> entry for this build (the checked-in
# metainfo deliberately carries none — CI is the source of truth).
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
@@ -241,7 +300,7 @@ jobs:
"${OUT}/build-dir" \
"${PKG}/${APP_ID}.yml"
flatpak build-bundle "${OUT}/repo" \
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
"$APP_ID" \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
ls -la "$OUT"
@@ -325,8 +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: 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:
@@ -574,8 +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: 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:
@@ -630,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
+11 -2
View File
@@ -49,9 +49,17 @@ jobs:
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
#
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
# at PR time instead of on the tag build.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Checkout code
@@ -139,5 +147,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
# Artifact names must be unique across a run — disambiguate per arch.
name: Release DEB (smoke-tested, ${{ matrix.os }})
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
+33 -12
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
@@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
ARCH="$(uname -m)"
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
```
---
@@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources.
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
@@ -325,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
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
normalizedUrl: String,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
// interface — Tor cannot route the range, so proxying only breaks the fetch.
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
true
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
isOnionRelaysActive
@@ -80,7 +80,7 @@ class NappletLiveSubscriptions {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
}
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -78,7 +78,7 @@ class NotifyCoordinator(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
newEose(TimeUtils.now(), relay, forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.giftWrapHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -74,7 +74,7 @@ class UserWatcherSubAssembler(
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
private val clientListener =
object : RelayConnectionListener {
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -48,7 +48,7 @@ class RelayUsageListener(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
/**
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
* returns null for.
*
* It exists so those files have somewhere to land other than the video player: an unknown blob
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
* which would try to download the blob just to rediscover the type the event already declared.
*/
@Composable
fun FileAttachmentCard(
url: String,
description: String?,
mimeType: String?,
sizeInBytes: Long?,
) {
val uriHandler = LocalUriHandler.current
val filename = remember(url) { extractFilename(url) }
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.fillMaxWidth()
.clickable { uriHandler.openUri(url) },
) {
FileAttachmentRow(
symbol = MaterialSymbols.AttachFile,
// The alt/content text names the file for a human ("Webxdc app: Quake");
// the hashed URL basename is the fallback when the event omits it.
title = description?.ifBlank { null } ?: filename,
subtitle = subtitle,
titleMaxLines = 2,
)
Spacer(modifier = DoubleVertSpacer)
}
}
/**
* The icon + title + subtitle row shared by every card that stands in for a file it can't
* render inline: this one and the PDF placeholder/skeleton in
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
*/
@Composable
internal fun FileAttachmentRow(
symbol: MaterialSymbol,
title: String,
subtitle: String?,
titleMaxLines: Int = 1,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
private fun fileSubtitle(
mimeType: String?,
sizeInBytes: Long?,
): String? =
listOfNotNull(
mimeType?.ifBlank { null }?.let(::prettyMime),
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
).joinToString(" · ").ifEmpty { null }
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
private fun FilenameRow(
filename: String,
subtitle: String,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
) = FileAttachmentRow(
symbol = MaterialSymbols.PictureAsPdf,
title = filename,
subtitle = subtitle,
)
private fun renderFirstPage(
file: java.io.File,
@@ -263,6 +263,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
@@ -578,6 +579,7 @@ fun BuildNavigation(
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
@@ -20,13 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
}
}
}
/**
* A [BackHandler] that steps aside while the soft keyboard is on screen.
*
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
* When that pop happens while the keyboard is still up, it races the predictive-back window
* animation against the IME's close animation. On release builds — fast enough that the window
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
* screen).
*
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
*/
@Composable
fun KeyboardAwareBackHandler(
enabled: Boolean = true,
onBack: () -> Unit,
) {
val keyboardState by keyboardAsState()
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import android.os.Build
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.serialization.Serializable
/**
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
* Order in this enum has no semantic meaning.
*/
@Serializable
enum class NavBarItem {
@@ -84,6 +84,18 @@ enum class NavBarItem {
FAVORITE_ALGO_FEEDS,
}
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
/**
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
*/
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
data class NavBarItemDef(
val id: NavBarItem,
val labelRes: Int,
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
// Ordered membership lists for each drawer section. The drawer renders these by looking up
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
// matching section list below — not two separate files.
val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
/**
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
*/
data class NavBarCategory(
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
)
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarCategory(
R.string.bottom_bar_category_main,
MaterialSymbols.Home,
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_chats,
MaterialSymbols.Group,
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_you,
MaterialSymbols.AccountCircle,
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_feeds,
MaterialSymbols.Subscriptions,
listOf(
NavBarItem.ARTICLES,
NavBarItem.LONGS,
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_apps,
MaterialSymbols.Apps,
listOf(
NavBarItem.BROWSER,
NavBarItem.FAVORITE_APPS,
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_other,
MaterialSymbols.Settings,
listOf(
NavBarItem.SETTINGS,
),
),
)
val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
@@ -584,42 +582,17 @@ fun ListContent(
accountViewModel: AccountViewModel,
nav: INav,
) {
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
// Side Menu settings screen. Empty (the default) means the full stock drawer.
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
Column(modifier) {
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
CollapsibleSection(title = R.string.drawer_section_create) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
CollapsibleSection(title = R.string.drawer_section_system) {
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
NavBarCatalog[NavBarItem.SETTINGS]?.let {
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
DrawerSections.forEach { section ->
// Keyed by section: hiding the last row of a section removes it from the drawer
// entirely, and without a key the sections below would slide up into its slots and
// inherit its CollapsibleSection expanded/collapsed state.
key(section.id) {
CatalogSection(section, hidden, accountViewModel, nav)
}
}
@@ -634,22 +607,64 @@ fun ListContent(
}
}
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
@Composable
private fun CreateRows(nav: INav) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
/**
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
* Profile gets the primary-colored tint; every other item uses onBackground.
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
*
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
* System carries the relay-status row (not a catalog destination — it shows a live counter).
*/
@Composable
fun CatalogSection(
titleRes: Int,
ids: List<NavBarItem>,
section: DrawerSection,
hidden: Set<NavBarItem>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val primary = MaterialTheme.colorScheme.primary
val onBackground = MaterialTheme.colorScheme.onBackground
CollapsibleSection(title = titleRes) {
ids.forEach { id ->
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
if (visible.isEmpty() && !section.hasFixedRows) return
CollapsibleSection(title = section.titleRes) {
when (section.id) {
DrawerSectionId.CREATE -> CreateRows(nav)
DrawerSectionId.SYSTEM ->
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
else -> {}
}
visible.forEach { id ->
NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
if (def.id == NavBarItem.SCHEDULED_POSTS) {
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* Which drawer rows the user cannot hide.
*
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
* so it is present by construction and never appears in the hidden set.
*/
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
/**
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
*
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
* account's drawer at the moment they first touched the setting, and every later release would have
* to migrate saved lists to introduce a screen.
*/
object DrawerItemVisibility {
fun isVisible(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Boolean = item in MandatoryDrawerItems || item !in hidden
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
fun toggle(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Set<NavBarItem> =
when {
item in MandatoryDrawerItems -> hidden
item in hidden -> hidden - item
else -> hidden + item
}
/**
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
* it runs on decode, on an external sync, and on every write — so a value synced from another
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
*
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
* preserving it means editing the drawer on that device doesn't silently clear the choice the
* user made on another one.
*/
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
/** The rows of [section] to render, in the section's fixed order. */
fun visibleItems(
section: DrawerSection,
hidden: Set<NavBarItem>,
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
fun hiddenCount(
section: DrawerSection,
hidden: Set<NavBarItem>,
): Int = section.items.count { !isVisible(hidden, it) }
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
fun hideAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
/** Shows every row of [section] again. */
fun showAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden - section.items.toSet()
/** Total hidden rows across every section — the count the settings screen shows at the top. */
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import android.os.Build
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* The drawer's layout: which destinations it lists, under which heading, in which order.
*
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
* filed into exactly one section.
*
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
* which rows are visible — see [DrawerItemVisibility].
*/
@Immutable
data class DrawerSection(
val id: DrawerSectionId,
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
/**
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
*/
val hasFixedRows: Boolean = false,
)
/**
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
* compile error and nothing to fail a test.
*/
enum class DrawerSectionId {
YOU,
NAVIGATE,
FEEDS,
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
CREATE,
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
SYSTEM,
}
private val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
private val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
private val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
val DrawerSections: List<DrawerSection> =
listOf(
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
)
/**
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
* missing, and fails on anything else — that's what keeps a newly added destination from silently
* skipping both the drawer and its settings screen.
*/
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** How long to wait for the IME inset to reach zero before navigating anyway. */
const val IME_SETTLE_TIMEOUT_MS = 700L
/**
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
* in the app serializes the IME and window animations instead of overlapping them.
*
* Navigating while the keyboard is up races the window animation against the IME's close animation.
* On release builds — fast enough that the window animation wins — the IME
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
* freezes at the keyboard height until some later inset pass happens to rebalance it.
*
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
* has done anything, and every way out of it is a navigation.
*/
fun interface ImeSettler {
suspend fun settle()
companion object {
/** For [EmptyNav] and previews, where there is no window to read insets from. */
val None = ImeSettler { }
}
}
/**
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
* and the padding can never disagree about whether the keyboard is gone.
*
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
* guards against, navigation still proceeds rather than stranding the user on the screen.
*/
@Composable
fun rememberImeSettler(): ImeSettler {
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
return remember(density, imeInsets, keyboard, focusManager) {
ImeSettler {
if (imeInsets.getBottom(density) > 0) {
focusManager.clearFocus(true)
keyboard?.hide()
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
}
}
}
}
}
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
class Nav(
val controller: NavHostController,
override val navigationScope: CoroutineScope,
/**
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
* through this class, so this is the one place that has to get it right — no screen, top bar
* or back handler needs to think about the keyboard on its way out.
*/
private val ime: ImeSettler = ImeSettler.None,
) : INav {
override val drawerState = DrawerState(DrawerValue.Closed)
@@ -63,6 +70,7 @@ class Nav(
override fun nav(route: Route) {
navigationScope.launch {
ime.settle()
if (getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
}
@@ -71,6 +79,7 @@ class Nav(
override fun nav(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
val route = computeRoute()
if (route != null && getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
@@ -80,6 +89,7 @@ class Nav(
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(route) {
inclusive = true
@@ -91,6 +101,7 @@ class Nav(
override fun navBottomBar(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
// Clear sibling bottom-nav entries but keep Home (the start
// destination) below, so back-swipe from any tab returns to
@@ -149,6 +160,7 @@ class Nav(
override fun popBack() {
navigationScope.launch {
ime.settle()
controller.navigateUp()
}
}
@@ -159,6 +171,7 @@ class Nav(
klass: KClass<T>,
) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(klass) { inclusive = true }
}
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
fun rememberNav(): Nav {
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val ime = rememberImeSettler()
return remember(navController, scope) {
Nav(navController, scope)
return remember(navController, scope, ime) {
Nav(navController, scope, ime)
}
}
@@ -457,6 +457,8 @@ sealed class Route {
@Serializable object BottomBarSettings : Route()
@Serializable object DrawerSettings : Route()
@Serializable object HomeTabsSettings : Route()
@Serializable object ProfileUiSettings : Route()
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val mimeType = remember(note) { event.mimeType() }
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
val content: BaseMediaContent =
remember(note) {
val blurHash = event.blurhash()
val thumbHash = event.thumbhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
}
}
// The sensitivity gate wraps both branches: a content warning is about the file, not about
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
if (content == null) {
FileHeaderAttachmentCard(event, fullUrl, mimeType)
} else {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
}
}
/**
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
*
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
* video event the kind itself asserts nothing about how to render the payload. A null here means
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
*/
internal fun FileHeaderEvent.toMediaContent(
note: Note,
url: String,
mimeType: String?,
): BaseMediaContent? {
val blurHash = blurhash()
val thumbHash = thumbhash()
val hash = hash()
val dimensions = dimensions()
val description = fileDescription()
val uri = note.toNostrUri()
return when (RichTextParser.classifyMedia(url, mimeType)) {
MediaContentKind.IMAGE ->
MediaUrlImage(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.VIDEO ->
MediaUrlVideo(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.PDF ->
MediaUrlPdf(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
null -> null
}
}
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
@Composable
internal fun FileHeaderAttachmentCard(
event: FileHeaderEvent,
url: String,
mimeType: String?,
) {
val description = remember(event) { event.fileDescription() }
val sizeInBytes = remember(event) { event.size()?.toLong() }
FileAttachmentCard(
url = url,
description = description,
mimeType = mimeType,
sizeInBytes = sizeInBytes,
)
}
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
}
}
internal fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
internal fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -88,7 +89,9 @@ fun VideoDisplay(
val content: BaseMediaContent =
remember(note) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
val uri = note.toNostrUri()
if (isImage) {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
val content by
remember(note) {
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -1986,6 +1987,15 @@ class AccountViewModel(
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
if (account.applyHiddenDrawerItems(items)) {
launchSigner { account.sendNewAppSpecificData() }
}
}
fun changeBottomBarItems(items: List<BottomBarEntry>) {
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
@@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler(
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler(
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler(
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,7 +78,7 @@ fun EditFieldRow(
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
@@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.nip04History
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -20,11 +20,36 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -34,8 +59,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
@Composable
fun GitRepositoriesScreen(
@@ -59,10 +93,31 @@ fun GitRepositoriesScreen(
WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel)
GitRepositoriesFilterAssemblerSubscription(accountViewModel)
// Search UI state is remembered across configuration changes so the
// user doesn't lose their query when rotating; scoped to this screen,
// not persisted to disk (unlike the follow-list filter above).
var isSearchOpen by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
GitRepositoriesTopBar(accountViewModel, nav)
GitRepositoriesTopBar(
isSearchOpen = isSearchOpen,
onToggleSearch = {
// Closing collapses the field AND clears the query so
// the feed is fully restored — the icon acts as a
// one-tap "reset" once the user has narrowed the view.
if (isSearchOpen) {
searchQuery = ""
isSearchOpen = false
} else {
isSearchOpen = true
}
},
accountViewModel = accountViewModel,
nav = nav,
)
},
bottomBar = {
AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route ->
@@ -75,20 +130,166 @@ fun GitRepositoriesScreen(
},
accountViewModel = accountViewModel,
) {
RefresheableBox(gitRepositoriesFeedContentState, true) {
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = gitRepositoriesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
Column(Modifier.fillMaxSize()) {
if (isSearchOpen) {
GitRepositorySearchField(
query = searchQuery,
onQueryChange = { searchQuery = it },
onClearQuery = { searchQuery = "" },
)
HorizontalDivider(thickness = DividerThickness)
}
RefresheableBox(gitRepositoriesFeedContentState, true) {
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
val query = searchQuery
if (query.isBlank()) {
RenderFeedContentState(
feedContentState = gitRepositoriesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
)
} else {
// When the filter is active we can't reuse the shared
// scroll state because the filtered list has a different
// set of item keys — using the same LazyListState would
// make Compose try to restore an index that no longer
// exists and jump the user to an unrelated repo. We
// scope a fresh, per-query LazyListState so scrolling
// stays inside the filtered view.
RenderFilteredFeed(
feedContentState = gitRepositoriesFeedContentState,
query = query,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
/**
* Inline text field that drives the client-side ngit-repository search. Sits
* directly under the top bar and above the feed so the user can see the
* result of every keystroke narrow the list beneath it.
*/
@Composable
private fun GitRepositorySearchField(
query: String,
onQueryChange: (String) -> Unit,
onClearQuery: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Focus on first appearance so the keyboard opens without a second
// tap. Subsequent recompositions inside the same session don't re-
// request focus, which would fight with the user pressing "back to
// the feed" via the field's clear-text icon.
focusRequester.requestFocus()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = {
Text(
text = stringRes(R.string.git_repositories_search_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = onClearQuery) {
ClearTextIcon()
}
}
},
singleLine = true,
)
}
}
/**
* Renders the ngit repositories the user is already subscribed to, filtered
* by [query]. Loading and error states are delegated to the shared
* [RenderFeedContentState] via the appropriate branches; the loaded branch
* is intercepted so we can filter the notes without touching the shared
* feed model (which other screens also observe).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RenderFilteredFeed(
feedContentState: FeedContentState,
query: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val filteredListState = rememberLazyListState()
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = filteredListState,
nav = nav,
routeForLastRead = "GitRepositoriesFeed",
onLoaded = { loaded ->
val loadedItems by loaded.feed.collectAsStateWithLifecycle()
val filtered =
remember(loadedItems, query) {
loadedItems.list.filter { note ->
val event = note.event as? GitRepositoryEvent ?: return@filter false
GitRepositorySearchMatcher.matches(event, query)
}
}
if (filtered.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
) {
Text(
text = stringRes(R.string.git_repositories_search_no_results),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = filteredListState,
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(
filtered,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = "GitRepositoriesFeed",
isBoostedNote = false,
isHiddenFeed = loadedItems.showHidden,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
},
)
}
@Composable
fun WatchAccountForGitRepositoriesScreen(
gitRepositoriesFeedContentState: FeedContentState,
@@ -20,35 +20,105 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Top bar for the ngit repositories discovery screen.
*
* Two search affordances live side-by-side in the actions row:
*
* 1. A **repository filter** (magnifier-with-a-plus icon) that toggles
* an inline text field over the loaded feed. This is the ngit-specific
* search — it matches the fields NIP-34 announcements carry: name,
* identifier, description, hashtags, clone/web/relay URLs, and
* maintainer pubkeys. It filters what the user is already looking
* at without touching relays.
*
* 2. The **generic Nostr search** (plain magnifier) that navigates to
* the global [Route.Search] screen, matching the affordance on
* every other top-level screen.
*
* Splitting them this way makes it obvious which magnifier does what: the
* inline one narrows the current list, the outbound one opens the fleet-
* wide search that also queries people, notes, hashtags, etc.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GitRepositoriesTopBar(
isSearchOpen: Boolean,
onToggleSearch: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
.collectAsStateWithLifecycle()
ShorterTopAppBar(
title = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
.collectAsStateWithLifecycle()
GitRepositoriesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
)
}
GitRepositoriesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
)
}
},
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
actions = {
IconButton(onClick = onToggleSearch) {
Icon(
symbol =
if (isSearchOpen) {
MaterialSymbols.Close
} else {
MaterialSymbols.FilterAlt
},
contentDescription =
stringRes(
if (isSearchOpen) {
R.string.git_repositories_search_close
} else {
R.string.git_repositories_search_open
},
),
)
}
IconButton(onClick = { nav.nav(Route.Search) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
}
},
)
}
@Composable
@@ -170,6 +170,7 @@ fun RelayUrlEditField(
nav: INav,
) {
var url by remember { mutableStateOf("") }
var isInvalid by remember { mutableStateOf(false) }
fun submitRelay() {
if (url.isNotBlank()) {
@@ -177,7 +178,13 @@ fun RelayUrlEditField(
if (relay != null) {
onNewRelay(relay)
url = ""
isInvalid = false
relaySuggestions.reset()
} else {
// Without this the Add button is a silent no-op, which reads as a broken button.
// Bare IPv6 literals are the common way to land here: an overlay-mesh address
// pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port.
isInvalid = true
}
}
}
@@ -189,8 +196,23 @@ fun RelayUrlEditField(
value = url,
onValueChange = {
url = it
isInvalid = false
relaySuggestions.processInput(it)
},
isError = isInvalid,
// Null, not an empty lambda: a non-null slot reserves its line height even when it
// draws nothing, which would pad the field permanently for every user.
supportingText =
if (isInvalid) {
{
Text(
text = stringRes(R.string.relay_url_not_valid),
color = MaterialTheme.colorScheme.error,
)
}
} else {
null
},
placeholder = {
Text(
text = "server.com",
@@ -456,7 +456,7 @@ class EventSync(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
@@ -50,7 +45,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -60,7 +54,6 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -97,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
@@ -116,11 +109,6 @@ private val ExpandableItems =
/** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */
private const val RECOMMENDED_SLOTS = 5
// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility
// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left).
private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun BottomBarSettingsScreenPreview() {
@@ -157,14 +145,21 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
// All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and
// forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds
// when the saved list changes elsewhere without clobbering a drag.
//
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
// holder that outlived an account switch would write account A's edits to account B. It cannot:
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } }
LaunchedEffect(savedItems) { state.syncFrom(savedItems) }
val pinned = state.pinned
val pinnedKeys = remember(pinned) { state.pinnedKeys() }
val expandedCategories = remember { mutableStateMapOf<Int, Boolean>() }
val expandedItems = remember { mutableStateMapOf<NavBarItem, Boolean>() }
val expandedCategories = rememberExpandedKeys<Int>()
val expandedItems = rememberExpandedKeys<NavBarItem>()
Column(
modifier =
@@ -177,26 +172,19 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
// --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. ---
EditableBarCard(state, pinned, accountViewModel)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { state.restoreDefault() }) {
Text(stringRes(R.string.bottom_bar_settings_restore_default))
}
}
RestoreDefaultRow(onClick = { state.restoreDefault() })
Spacer(Modifier.height(4.dp))
// --- Available catalogue, grouped into collapsible category cards. ---
SectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
PickerSectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
BottomBarCategories.forEach { category ->
CategoryCard(
category = category,
pinnedKeys = pinnedKeys,
expanded = expandedCategories[category.titleRes] ?: false,
onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) },
expanded = expandedCategories.isExpanded(category.titleRes),
onToggleExpand = { expandedCategories.toggle(category.titleRes) },
expandedItems = expandedItems,
accountViewModel = accountViewModel,
onTogglePin = state::togglePin,
@@ -217,60 +205,43 @@ private fun EditableBarCard(
pinned: List<BottomBarEntry>,
accountViewModel: AccountViewModel,
) {
val accent = MaterialTheme.colorScheme.primary
Surface(
shape = RoundedCornerShape(22.dp),
color = accent.copy(alpha = 0.07f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
) {
Column(Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.bottom_bar_settings_pinned),
style = MaterialTheme.typography.labelMedium,
color = accent,
fontWeight = FontWeight.Bold,
)
Text(
text = "${pinned.size} / $RECOMMENDED_SLOTS",
style = MaterialTheme.typography.labelMedium,
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent,
fontWeight = FontWeight.Bold,
)
}
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth(),
) {
if (pinned.isEmpty()) {
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.bottom_bar_settings_pinned_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
} else {
EditableBar(state, pinned, accountViewModel)
}
}
PickerHeroCard(
title = stringRes(R.string.bottom_bar_settings_pinned),
trailing = {
Text(
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
text = "${pinned.size} / $RECOMMENDED_SLOTS",
style = MaterialTheme.typography.labelMedium,
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
},
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth(),
) {
if (pinned.isEmpty()) {
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.bottom_bar_settings_pinned_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
} else {
EditableBar(state, pinned, accountViewModel)
}
}
Text(
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@@ -466,76 +437,37 @@ private fun CategoryCard(
pinnedKeys: Set<String>,
expanded: Boolean,
onToggleExpand: () -> Unit,
expandedItems: SnapshotStateMap<NavBarItem, Boolean>,
expandedItems: ExpandedKeys<NavBarItem>,
accountViewModel: AccountViewModel,
onTogglePin: (BottomBarEntry) -> Unit,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
CatalogCard(
icon = category.icon,
title = stringRes(category.titleRes),
expanded = expanded,
onToggleExpand = onToggleExpand,
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(34.dp)
.clip(RoundedCornerShape(11.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
category.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val entry = BottomBarEntry.BuiltIn(item)
if (item in ExpandableItems) {
ExpandableAvailableRow(
icon = def.icon,
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
expanded = expandedItems.isExpanded(item),
onTogglePin = { onTogglePin(entry) },
onToggleExpand = { expandedItems.toggle(item) },
) {
Icon(
symbol = categoryIcon(category.titleRes),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
}
Text(
text = stringRes(category.titleRes),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
} else {
AvailableRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
Column(Modifier.padding(bottom = 6.dp)) {
category.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val entry = BottomBarEntry.BuiltIn(item)
if (item in ExpandableItems) {
ExpandableAvailableRow(
icon = def.icon,
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
expanded = expandedItems[item] ?: false,
onTogglePin = { onTogglePin(entry) },
onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) },
) {
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
}
} else {
AvailableRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
pinned = entry.stableKey in pinnedKeys,
onToggle = { onTogglePin(entry) },
)
}
}
}
}
}
}
@@ -757,18 +689,6 @@ private fun ConcordServerPickerGroup(
// Rows & shared bits
// ------------------------------------------------------------------------------------------------
/**
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
* NIP-29 group under its relay, or a Concord channel under its community).
*/
private fun indentPadding(level: Int) =
when (level) {
0 -> 13.dp
1 -> 24.dp
else -> 40.dp
}
@Composable
private fun AvailableRow(
leading: @Composable () -> Unit,
@@ -777,23 +697,12 @@ private fun AvailableRow(
onToggle: () -> Unit,
indentLevel: Int = 0,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
CatalogRow(
leading = leading,
label = label,
onToggle = onToggle,
indentLevel = indentLevel,
) {
leading()
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
AddPill(added = pinned, onClick = onToggle)
}
}
@@ -808,27 +717,15 @@ private fun ExpandableAvailableRow(
onToggleExpand: () -> Unit,
children: @Composable () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpand)
.padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
CatalogRow(
leading = { LeadingGlyph(icon) },
label = label,
onToggle = onToggleExpand,
) {
LeadingGlyph(icon)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = stringRes(R.string.bottom_bar_settings_expand),
modifier = Modifier.size(22.dp),
modifier = Size22Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
AddPill(added = pinned, onClick = onTogglePin)
@@ -838,52 +735,18 @@ private fun ExpandableAvailableRow(
}
}
/**
* Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states
* share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows
* stay aligned whether an item is added or not.
*/
/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */
@Composable
private fun AddPill(
added: Boolean,
onClick: () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
val content = if (added) MaterialTheme.colorScheme.onPrimary else accent
Surface(
shape = CircleShape,
color = if (added) accent else Color.Transparent,
border = if (added) null else BorderStroke(1.dp, accent),
) {
Row(
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = content,
)
Text(
text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
style = MaterialTheme.typography.labelLarge,
color = content,
)
}
}
}
/** A category/destination glyph in a soft accent-tinted circle. */
@Composable
private fun LeadingGlyph(icon: MaterialSymbol) {
Box(
modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary)
}
TogglePill(
on = added,
label = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
icon = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
onClick = onClick,
)
}
/** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */
@@ -902,40 +765,6 @@ private fun FavoriteLeading(app: FavoriteApp) {
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp),
)
}
@Composable
private fun EmptyChildHint(
textRes: Int,
indentLevel: Int = 1,
) {
Text(
text = stringRes(textRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp),
)
}
private fun categoryIcon(titleRes: Int): MaterialSymbol =
when (titleRes) {
R.string.bottom_bar_category_main -> MaterialSymbols.Home
R.string.bottom_bar_category_chats -> MaterialSymbols.Group
R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle
R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions
R.string.bottom_bar_category_apps -> MaterialSymbols.Apps
else -> MaterialSymbols.Settings
}
// ------------------------------------------------------------------------------------------------
// Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar).
// Computed once so a group's channel is subscribed at most once per row.
@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun DrawerSettingsScreenPreview() {
ThemeComparisonRow {
DrawerSettingsScreen(
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun DrawerSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.drawer_settings), nav)
},
) { padding ->
Column(Modifier.padding(padding)) {
DrawerSettingsContent(accountViewModel)
}
}
}
/**
* Show/hide editor for the side menu's rows. It renders [DrawerSections] directly — the very list the
* drawer renders — so a destination added to a section shows up here with no work, and a row that
* exists here always exists there.
*/
@Composable
fun DrawerSettingsContent(accountViewModel: AccountViewModel) {
// Per-account, synced through the NIP-78 app-specific data event.
val savedHidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
// All show/hide logic lives in the holder (unit-tested); the composable only renders and forwards
// events. Each edit republishes the account's NIP-78 settings event. syncFrom re-seeds when the
// saved set changes elsewhere.
//
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
// holder that outlived an account switch would write account A's edits to account B. It cannot:
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
val state = remember { DrawerSettingsState(savedHidden) { accountViewModel.changeHiddenDrawerItems(it) } }
LaunchedEffect(savedHidden) { state.syncFrom(savedHidden) }
// Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden
// counter is what tells the user which one to open.
val expandedSections = rememberExpandedKeys<DrawerSectionId>()
val totalHidden = state.totalHidden()
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(12.dp))
SummaryCard(totalHidden)
RestoreDefaultRow(onClick = { state.restoreDefault() })
Spacer(Modifier.height(4.dp))
PickerSectionHeader(title = stringRes(R.string.drawer_settings_sections))
// A section with no catalog rows has nothing to configure (Create is composer entry points),
// so it isn't listed here even though the drawer renders it.
DrawerSections.forEach { section ->
if (section.items.isEmpty()) return@forEach
SectionCard(
section = section,
state = state,
expanded = expandedSections.isExpanded(section.id),
onToggleExpand = { expandedSections.toggle(section.id) },
)
}
Spacer(Modifier.height(24.dp))
}
}
/** What the setting does and how far from stock the menu currently is. */
@Composable
private fun SummaryCard(totalHidden: Int) {
PickerHeroCard(
title = stringRes(R.string.drawer_settings_title),
trailing = {
Text(
text = stringRes(R.string.drawer_settings_hidden_count, totalHidden),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
},
) {
Text(
text = stringRes(R.string.drawer_settings_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SectionCard(
section: DrawerSection,
state: DrawerSettingsState,
expanded: Boolean,
onToggleExpand: () -> Unit,
) {
// Each card reads the same coarse `hidden` state, so without derivedStateOf a toggle in one
// section would recompose (and re-count) all of them.
val hiddenHere by remember(section) { derivedStateOf { state.hiddenCount(section) } }
CatalogCard(
icon = section.icon,
title = stringRes(section.titleRes),
expanded = expanded,
onToggleExpand = onToggleExpand,
trailing = {
if (hiddenHere > 0) {
Text(
text = stringRes(R.string.drawer_settings_hidden_count, hiddenHere),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
) {
// Bulk actions: turning ~29 feed rows off one at a time is the kind of chore that makes
// people give up halfway and leave the menu in a worse state than they found it.
if (DrawerItemVisibility.hasHideableRows(section)) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { state.showAll(section) }) {
Text(stringRes(R.string.drawer_settings_show_all))
}
TextButton(onClick = { state.hideAll(section) }) {
Text(stringRes(R.string.drawer_settings_hide_all))
}
}
}
section.items.forEach { item ->
val def = NavBarCatalog[item] ?: return@forEach
val mandatory = item in MandatoryDrawerItems
val visible = state.isVisible(item)
CatalogRow(
leading = { LeadingGlyph(def.icon) },
label = stringRes(def.labelRes),
onToggle = if (mandatory) null else ({ state.toggle(item) }),
) {
VisibilityPill(visible = visible, mandatory = mandatory, onClick = { state.toggle(item) })
}
}
}
}
/**
* Filled "Visible" / outlined "Hidden" — the bottom bar's Add/Added pill, saying what this screen
* says instead. A mandatory row gets a locked "Always on" badge: it reads as deliberately fixed
* rather than as a control that ignores taps.
*/
@Composable
private fun VisibilityPill(
visible: Boolean,
mandatory: Boolean,
onClick: () -> Unit,
) {
// One branch decides both halves of the pill, so a label can't drift away from its glyph.
val (labelRes, icon) =
when {
mandatory -> R.string.drawer_settings_always_on to MaterialSymbols.Lock
visible -> R.string.drawer_settings_visible to MaterialSymbols.Visibility
else -> R.string.drawer_settings_hidden to MaterialSymbols.VisibilityOff
}
TogglePill(
on = visible,
label = stringRes(labelRes),
icon = icon,
enabled = !mandatory,
onClick = onClick,
)
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
/**
* State holder for the Side Menu settings screen: owns the set of switched-off drawer rows and the
* show / hide / restore-default operations, so the composable only renders and forwards events.
*
* The rules themselves live in [DrawerItemVisibility] (pure, unit-tested); this adds only the Compose
* state and the write-through to the account's synced settings. Unlike the bottom bar there is no
* transient/commit split — a toggle is a single discrete edit, not a drag, so every change persists
* immediately.
*
* No sanitizing here: every value in is either already sanitized by the persistence layer or produced
* by a [DrawerItemVisibility] operation that can't introduce a mandatory row, and the write side
* sanitizes again anyway. One authority, not three.
*/
@Stable
class DrawerSettingsState(
initial: Set<NavBarItem>,
private val persist: (Set<NavBarItem>) -> Unit,
) {
var hidden by mutableStateOf(initial)
private set
fun isVisible(item: NavBarItem): Boolean = DrawerItemVisibility.isVisible(hidden, item)
fun toggle(item: NavBarItem) = update(DrawerItemVisibility.toggle(hidden, item))
fun hiddenCount(section: DrawerSection): Int = DrawerItemVisibility.hiddenCount(section, hidden)
fun totalHidden(): Int = DrawerItemVisibility.totalHidden(hidden)
fun showAll(section: DrawerSection) = update(DrawerItemVisibility.showAll(hidden, section))
fun hideAll(section: DrawerSection) = update(DrawerItemVisibility.hideAll(hidden, section))
/** Back to the stock drawer: nothing hidden. */
fun restoreDefault() = update(emptySet())
/**
* Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op
* when equal, so the echo of our own [persist] doesn't fight an in-progress edit.
*/
fun syncFrom(items: Set<NavBarItem>) {
if (items != hidden) hidden = items
}
private fun update(newHidden: Set<NavBarItem>) {
if (newHidden == hidden) return
hidden = newHidden
persist(newHidden)
}
}
@@ -0,0 +1,352 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size12dp
import com.vitorpamplona.amethyst.ui.theme.Size13dp
import com.vitorpamplona.amethyst.ui.theme.Size14dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22dp
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
/**
* The shared visual language of the navigation-configuration screens — the Bottom Navigation Bar
* picker and the Side Menu picker. Both present the same shape (collapsible cards of catalog rows,
* each row a glyph + label + a pill stating its current state), so the pieces live here once and
* each screen supplies only its own semantics: the bottom bar pins and reorders entries, the side
* menu switches rows on and off.
*
* [SectionExpand]/[SectionCollapse] reveal expandable sections by unrolling straight down from the
* top edge (the default AnimatedVisibility enter also expands horizontally from the bottom-end,
* which reads as a diagonal slide from the top-left).
*/
val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
/**
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
* NIP-29 group under its relay, or a Concord channel under its community).
*/
private fun indentPadding(level: Int) =
when (level) {
0 -> Size13dp
1 -> Size24dp
else -> Size40dp
}
/**
* Which collapsible rows of a picker are currently open, keyed by whatever identifies a row (a
* section id, a string-resource id, a catalog item). Absent means collapsed, so the initial state
* costs nothing and no list has to be seeded.
*/
@Stable
class ExpandedKeys<K> {
private val open = mutableStateMapOf<K, Boolean>()
fun isExpanded(key: K): Boolean = open[key] == true
fun toggle(key: K) {
open[key] = !isExpanded(key)
}
}
@Composable
fun <K> rememberExpandedKeys(): ExpandedKeys<K> = remember { ExpandedKeys() }
@Composable
fun PickerSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = Size18dp, bottom = Size6dp),
)
}
/** A category/destination glyph in a soft accent-tinted circle. */
@Composable
fun LeadingGlyph(icon: MaterialSymbol) {
Box(
modifier = SimpleImage35Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(symbol = icon, contentDescription = null, modifier = Size19Modifier, tint = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun EmptyChildHint(
textRes: Int,
indentLevel: Int = 1,
) {
Text(
text = stringRes(textRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = indentPadding(indentLevel), end = Size13dp, top = Size6dp, bottom = Size6dp),
)
}
/**
* One catalog row: leading visual, label, and a caller-supplied [trailing] state control. Tapping
* anywhere on the row runs [onToggle]; pass null for a row whose state can't change (a mandatory
* side-menu item), which also drops the ripple so the row doesn't advertise an action it won't take.
*/
@Composable
fun CatalogRow(
leading: @Composable () -> Unit,
label: String,
onToggle: (() -> Unit)?,
indentLevel: Int = 0,
trailing: @Composable () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.let { if (onToggle != null) it.clickable(onClick = onToggle) else it }
.padding(start = indentPadding(indentLevel), end = Size13dp, top = 7.dp, bottom = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size12dp),
) {
leading()
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing()
}
}
/**
* The state pill at the end of a catalog row: outlined in the "off" state, filled in the "on" state —
* so it states both the current state and, by contrast, that it can be changed. Both states share one
* Row body (only color/border/tint differ) so the pill keeps a constant height and rows stay aligned.
*
* [enabled] false renders the pill as a locked, non-interactive badge — used for a row the user isn't
* allowed to switch off.
*/
@Composable
fun TogglePill(
on: Boolean,
label: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
val container = if (enabled) accent else MaterialTheme.colorScheme.surfaceVariant
val content =
when {
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
on -> MaterialTheme.colorScheme.onPrimary
else -> accent
}
Surface(
shape = CircleShape,
color = if (on) container else Color.Transparent,
border = if (on) null else BorderStroke(1.dp, content),
) {
Row(
modifier =
Modifier
.let { if (enabled) it.clickable(onClick = onClick) else it }
.padding(horizontal = Size14dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Size15Modifier,
tint = content,
)
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = content,
)
}
}
}
/**
* A collapsible card holding catalog rows. [trailing] renders between the title and the chevron —
* the side menu puts its "n hidden" counter there; the bottom bar leaves it empty.
*/
@Composable
fun CatalogCard(
icon: MaterialSymbol,
title: String,
expanded: Boolean,
onToggleExpand: () -> Unit,
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(Size13dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size12dp),
) {
Box(
modifier =
Modifier
.size(Size34dp)
.clip(RoundedCornerShape(11.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
trailing()
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
Column(Modifier.padding(bottom = Size6dp)) { content() }
}
}
}
}
/**
* The accent-tinted card each picker opens with: a bold title, an optional [trailing] status, and a
* body. The bottom bar puts its editable preview bar in the body; the side menu puts its description.
*/
@Composable
fun PickerHeroCard(
title: String,
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
Surface(
shape = RoundedCornerShape(Size22dp),
color = accent.copy(alpha = 0.07f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
) {
Column(Modifier.padding(Size14dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = Size10dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = accent,
fontWeight = FontWeight.Bold,
)
trailing()
}
content()
}
}
}
/** The end-aligned "Restore Default" action both pickers put under their hero card. */
@Composable
fun RestoreDefaultRow(onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onClick) {
Text(stringRes(R.string.bottom_bar_settings_restore_default))
}
}
}
@@ -72,6 +72,7 @@ fun buildSettingsCatalog(
symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings),
symEntry(R.string.messages_settings, MaterialSymbols.Mail, R.string.messages_settings_search_keywords, Route.MessagesSettings),
symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings),
symEntry(R.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, R.string.drawer_search_keywords, Route.DrawerSettings),
symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings),
symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -104,7 +105,9 @@ private fun VideoCardImage(
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
val content by
remember(note) {
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop
@@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard
import com.vitorpamplona.amethyst.ui.note.types.toMediaContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -101,45 +98,33 @@ private fun FileHeaderCardImage(
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
val mimeType = remember(note) { event.mimeType() }
val blurHash = remember(note) { event.blurhash() }
val thumbHash = remember(note) { event.thumbhash() }
val dimensions = remember(note) { event.dimensions() }
val content by remember(note) {
val hash = event.hash()
val description = event.content.ifEmpty { null } ?: event.alt()
val uri = note.toNostrUri()
val mimeType = event.mimeType()
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
mutableStateOf<BaseMediaContent>(
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
},
)
// Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on
// `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event
// whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps
// the viewer in FileHeaderDisplay: a content warning is about the file, and the card still
// spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults
// (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash
// to show behind it.
if (content == null) {
ContentWarningGate(
isSensitive = isSensitive,
reasons = reasons,
preloadUrls = emptyList(),
accountViewModel = accountViewModel,
) {
FileHeaderAttachmentCard(event, fullUrl, mimeType)
}
return
}
val isImage = content is MediaUrlImage
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl)
ContentWarningGate(
@@ -211,6 +211,7 @@
<string name="connection_success_rate_description">Procento úspěšných připojení k relé</string>
<string name="search_and_add_a_user">Vyhledat a přidat uživatele</string>
<string name="add_a_relay">Přidat přeposílání</string>
<string name="relay_url_not_valid">Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Moje @tag jméno</string>
<string name="display_name">Zobrazované jméno</string>
<string name="my_display_name">Moje zobrazované jméno</string>
@@ -2024,14 +2025,105 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d relay</item>
<item quantity="few">%1$s \u00b7 %2$d relaye</item>
<item quantity="many">%1$s \u00b7 %2$d relaye</item>
<item quantity="other">%1$s \u00b7 %2$d relayů</item>
</plurals>
<string name="relay_purpose_browsing">Procházení</string>
<string name="relay_purpose_media">Média</string>
<string name="relay_purpose_tags">Hashtagy</string>
<string name="relay_purpose_topics">Témata</string>
<string name="relay_purpose_thread">Konverzace</string>
<string name="relay_purpose_search">Hledání</string>
<string name="relay_purpose_referenced">Hledání chybějících událostí</string>
<string name="relay_purpose_engagement">Sledování událostí</string>
<string name="relay_explain_referenced">Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna.</string>
<string name="relay_explain_engagement">Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení.</string>
<string name="relay_purpose_add_ons">Doplňky</string>
<string name="relay_purpose_relay_info">Informace o relayi</string>
<string name="relay_purpose_other">Ostatní</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Vyhledávač seznamů relayů</string>
<string name="relay_purpose_observing_profiles">Sledování profilů</string>
<string name="relay_purpose_your_account">Data účtu</string>
<string name="relay_purpose_home_feed">Domovský zdroj</string>
<string name="relay_purpose_relay_groups">Relay skupiny</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d skupina</item>
<item quantity="few">%1$d skupiny</item>
<item quantity="many">%1$d skupiny</item>
<item quantity="other">%1$d skupin</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Mizící chaty</string>
<string name="relay_purpose_geohash_chats">Chaty podle místa</string>
<string name="relay_purpose_live_chat">Chat živého vysílání</string>
<string name="relay_explain_relay_groups">Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu.</string>
<string name="relay_explain_ephemeral_chats">Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo.</string>
<string name="relay_explain_geohash_chats">Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou.</string>
<string name="relay_explain_live_chat">Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete.</string>
<string name="relay_purpose_dm_inbox">Schránka DM</string>
<string name="relay_purpose_your_wallet">Peněženka</string>
<string name="relay_purpose_nutzap_inbox">Schránka nutzapů</string>
<string name="relay_purpose_mint_directory">Adresář mintů</string>
<string name="relay_purpose_nwc">Wallet Connect</string>
<string name="relay_purpose_community_chats">Chaty komunit</string>
<string name="relay_purpose_community_feeds">Zdroje komunit</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam.</string>
<string name="relay_explain_direct_messages">Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu.</string>
<string name="relay_explain_public_chats">Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili.</string>
<string name="relay_explain_community_chats">Relaye, na které každá komunita publikuje své roviny.</string>
<string name="relay_explain_encrypted_groups">Skupinové zprávy a balíčky klíčů na relayích každé skupiny.</string>
<string name="relay_explain_live_rooms">Relaye místnosti, dokud je otevřená.</string>
<string name="relay_explain_account_data">Váš vlastní profil, nastavení a koncepty na vašich domovských relayích.</string>
<string name="relay_explain_profiles">Profily lidí právě na obrazovce.</string>
<string name="relay_explain_relay_lists">Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě.</string>
<string name="relay_explain_follows">Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry.</string>
<string name="relay_explain_moderation">Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují.</string>
<string name="relay_purpose_reports_from_follows">Nahlášení od sledovaných</string>
<string name="relay_explain_wallet">Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali.</string>
<string name="relay_explain_nutzap_inbox">Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba.</string>
<string name="relay_explain_mint_directory">Prohledává relaye, které minty existují a které lidé doporučují.</string>
<string name="relay_explain_nwc">Upozornění z vaší připojené peněženky.</string>
<string name="active_subs_title">Aktivní odběry relayů</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filtr</item>
<item quantity="few">%1$d filtry</item>
<item quantity="many">%1$d filtru</item>
<item quantity="other">%1$d filtrů</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d relay</item>
<item quantity="few">%1$d relaye</item>
<item quantity="many">%1$d relaye</item>
<item quantity="other">%1$d relayů</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr zatím není přiřazen</item>
<item quantity="few">%1$d filtry zatím nejsou přiřazeny</item>
<item quantity="many">%1$d filtru zatím není přiřazeno</item>
<item quantity="other">%1$d filtrů zatím není přiřazeno</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Nepřiřazeno k žádnému účtu</string>
<string name="active_subs_no_entity">Vše</string>
<string name="active_subs_scope_global">Všichni</string>
<string name="active_subs_scope_follows">Lidé, které sledujete</string>
<string name="active_subs_scope_authors">Vybraný seznam lidí</string>
<string name="active_subs_scope_muted">Ztlumení lidé</string>
<string name="active_subs_scope_all_communities">Vaše komunity</string>
<string name="active_subs_scope_algo">Oblíbený algoritmický zdroj</string>
<string name="active_subs_share">%1$d %% ze všech</string>
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
<string name="relay_explain_home">Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje.</string>
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
<string name="always_on_notif_setting_description">Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu.</string>
@@ -4726,6 +4818,7 @@
<string name="buzz_workflow_gate_needs_you">Potřebuje vaše schválení</string>
<string name="buzz_workflow_gate_awaiting">Čeká na schválení</string>
<string name="buzz_workflow_no_description">(bez popisu)</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_by">od</string>
<string name="buzz_workflow_waiting_on">čeká na</string>
<string name="buzz_workflow_readonly_approver">Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat.</string>
@@ -4761,6 +4854,7 @@
<string name="buzz_workflow_no_defs_hint">Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte.</string>
<string name="buzz_workflow_task_label">Co má udělat?</string>
<string name="buzz_workflow_trigger_run">Spustit běh</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="buzz_workflow_picker_empty">Zatím nejsou definována žádná workflow</string>
<string name="buzz_workflow_picker_choose">Vyberte workflow</string>
<string name="buzz_workflow_new_definition">Nová definice…</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">Prozentsatz erfolgreicher Verbindungen zum Relay</string>
<string name="search_and_add_a_user">Benutzer suchen und hinzufügen</string>
<string name="add_a_relay">Relay hinzufügen</string>
<string name="relay_url_not_valid">Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">Mein @tag-Name</string>
<string name="display_name">Anzeigename</string>
<string name="my_display_name">Mein Anzeigename</string>
@@ -1942,14 +1943,91 @@
</plurals>
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
serves several jobs at once, so these deliberately sum to more than the relay count. -->
<plurals name="relay_purpose_line">
<item quantity="one">%1$s \u00b7 %2$d Relay</item>
<item quantity="other">%1$s \u00b7 %2$d Relays</item>
</plurals>
<string name="relay_purpose_browsing">Stöbern</string>
<string name="relay_purpose_media">Medien</string>
<string name="relay_purpose_topics">Themen</string>
<string name="relay_purpose_thread">Unterhaltung</string>
<string name="relay_purpose_search">Suche</string>
<string name="relay_purpose_referenced">Fehlende Events finden</string>
<string name="relay_purpose_engagement">Events beobachten</string>
<string name="relay_explain_referenced">Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel.</string>
<string name="relay_explain_engagement">Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben.</string>
<string name="relay_purpose_add_ons">Erweiterungen</string>
<string name="relay_purpose_relay_info">Relay-Info</string>
<string name="relay_purpose_other">Sonstiges</string>
<!-- Activity labels, used where the app's own noun for the data type already means something
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
their profile screen. Naming the job avoids an active misreading. -->
<string name="relay_purpose_relay_list_finder">Relay-Listen-Finder</string>
<string name="relay_purpose_observing_profiles">Profile beobachten</string>
<string name="relay_purpose_your_account">Kontodaten</string>
<string name="relay_purpose_home_feed">Startseiten-Feed</string>
<string name="relay_purpose_relay_groups">Relay-Gruppen</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d Gruppe</item>
<item quantity="other">%1$d Gruppen</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Verschwindende Chats</string>
<string name="relay_purpose_geohash_chats">Standort-Chats</string>
<string name="relay_purpose_live_chat">Live-Stream-Chat</string>
<string name="relay_explain_relay_groups">NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt.</string>
<string name="relay_explain_ephemeral_chats">Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt.</string>
<string name="relay_explain_geohash_chats">Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen.</string>
<string name="relay_explain_live_chat">Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst.</string>
<string name="relay_purpose_dm_inbox">DM-Posteingang</string>
<string name="relay_purpose_nutzap_inbox">Nutzap-Posteingang</string>
<string name="relay_purpose_mint_directory">Mint-Verzeichnis</string>
<string name="relay_purpose_community_chats">Community-Chats</string>
<string name="relay_purpose_community_feeds">Community-Feeds</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde.</string>
<string name="relay_explain_direct_messages">Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden.</string>
<string name="relay_explain_public_chats">Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist.</string>
<string name="relay_explain_community_chats">Die Relays, auf denen jede Community ihre Planes veröffentlicht.</string>
<string name="relay_explain_encrypted_groups">Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe.</string>
<string name="relay_explain_live_rooms">Die Relays des Raums, solange er geöffnet ist.</string>
<string name="relay_explain_account_data">Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays.</string>
<string name="relay_explain_profiles">Profile der gerade angezeigten Personen.</string>
<string name="relay_explain_relay_lists">Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können.</string>
<string name="relay_explain_follows">Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden.</string>
<string name="relay_explain_moderation">Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen.</string>
<string name="relay_purpose_reports_from_follows">Meldungen von Gefolgten</string>
<string name="relay_explain_wallet">Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast.</string>
<string name="relay_explain_nutzap_inbox">Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht.</string>
<string name="relay_explain_mint_directory">Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden.</string>
<string name="relay_explain_nwc">Benachrichtigungen von deiner verbundenen Wallet.</string>
<string name="active_subs_title">Aktive Relay-Abonnements</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d Filter</item>
<item quantity="other">%1$d Filter</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d Relay</item>
<item quantity="other">%1$d Relays</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
<item quantity="other">%1$d Filter sind noch nicht zugeordnet</item>
</plurals>
<string name="active_subs_unattributed">Keinem Konto zugeordnet</string>
<string name="active_subs_no_entity">Alle</string>
<string name="active_subs_scope_global">Jeder</string>
<string name="active_subs_scope_follows">Personen, denen du folgst</string>
<string name="active_subs_scope_authors">Eine ausgewählte Liste von Personen</string>
<string name="active_subs_scope_muted">Stummgeschaltete Personen</string>
<string name="active_subs_scope_all_communities">Deine Communitys</string>
<string name="active_subs_scope_algo">Ein bevorzugter Feed-Algorithmus</string>
<string name="active_subs_share">%1$d %% von allen</string>
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
<string name="relay_explain_home">Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht.</string>
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
<string name="always_on_notif_setting_description">Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst.</string>
@@ -203,6 +203,7 @@
<string name="connection_success_rate_description">सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ</string>
<string name="search_and_add_a_user">ढूँढें तथा प्रयेक्ता जोडें</string>
<string name="add_a_relay">पुनःप्रसारक जोडें</string>
<string name="relay_url_not_valid">मान्य पुनःप्रसारक पता नहीं। एक जालावास नाम का उपयोग करें। अथवा कोष्ठकों में एक अंकीय जालपता उदाहरण [201:d0e:9ba5:8bbc::1]:8080 के जैसे।</string>
<string name="my_name">मेरा @सूचक नाम</string>
<string name="display_name">प्रदर्शन नाम</string>
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
@@ -332,7 +333,7 @@
<string name="concord_leave_title">क्या समुदाय छोडें।</string>
<string name="concord_leave_message">क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे।</string>
<string name="concord_leave_owner_warning">आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे।</string>
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है।</string>
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत समतलों का प्रकाशन तथा पठन किया जाता है।</string>
<string name="concord_dissolved_read_only">इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते।</string>
<string name="concord_typing_one">%1$s टंकण मध्य…</string>
<string name="concord_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
@@ -1233,7 +1234,7 @@
<string name="nest_participant_unmute">मौन हटाएँ</string>
<string name="nest_force_mute_note">सम्भाव्यतः उन ग्राहकों द्वारा उपेक्षित जो आज्ञा का सम्मान नहीं करते।</string>
<string name="nest_confirm_kick_title">क्या शाला से निष्कासित करें।</string>
<string name="nest_confirm_kick_body">%1$s को ध्वनि तल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें।</string>
<string name="nest_confirm_kick_body">%1$s को ध्वनि समतल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें।</string>
<string name="nest_confirm_kick_confirm">पदप्रहार</string>
<string name="nest_confirm_force_mute_title">क्या वक्ता को मौन करें।</string>
<string name="nest_confirm_force_mute_body">%1$s के ग्राहक को अपना ध्वनिग्राहक मौन करने का अनुरोध करता है। कुछ ग्राहक इस आदेश की उपेक्षा कर सकते हैं।</string>
@@ -1942,14 +1943,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 पुनःप्रसारक</item>
<item quantity="other">%1$s \u00b7 %2$d पुनःप्रसारक</item>
</plurals>
<string name="relay_purpose_browsing">जालभ्रमण</string>
<string name="relay_purpose_media">ध्वनिचित्राभिलेख</string>
<string name="relay_purpose_tags">विषयसूचक</string>
<string name="relay_purpose_topics">विषय सूची</string>
<string name="relay_purpose_thread">वार्तालाप</string>
<string name="relay_purpose_search">खोज</string>
<string name="relay_purpose_referenced">लुप्त घटनाओं को ढूँढें</string>
<string name="relay_purpose_engagement">घटना अवलोकन</string>
<string name="relay_explain_referenced">घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
<string name="relay_purpose_add_ons">संलग्न</string>
<string name="relay_purpose_relay_info">पुनःप्रसारक जानकारी</string>
<string name="relay_purpose_other">अन्य</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">पुनःप्रसारक सूची खोजकर्ता</string>
<string name="relay_purpose_observing_profiles">परिचय अवलोकन</string>
<string name="relay_purpose_your_account">लेखा जानकारी</string>
<string name="relay_purpose_home_feed">मुख्य सूचनावली</string>
<string name="relay_purpose_relay_groups">पुनःप्रसारक समूह</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d समूह</item>
<item quantity="other">%1$d समूह</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">अस्थायी चर्चाएँ</string>
<string name="relay_purpose_geohash_chats">स्थानीय चर्चाएँ</string>
<string name="relay_purpose_live_chat">वर्तमानप्रवाह चर्चा</string>
<string name="relay_explain_relay_groups">निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है।</string>
<string name="relay_explain_ephemeral_chats">चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए।</string>
<string name="relay_explain_geohash_chats">स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं।</string>
<string name="relay_explain_live_chat">चर्चा तथा ज्साप उद्देश्य जो वर्तमानप्रवाहों से संलग्न हैं जिन्हें आप खोले हुए हैं अथवा अनुगमन करते हैं।</string>
<string name="relay_purpose_dm_inbox">सीधासन्देश आगतपेटिका</string>
<string name="relay_purpose_your_wallet">धनकोष</string>
<string name="relay_purpose_nutzap_inbox">नटज्साप आगतपेटिका</string>
<string name="relay_purpose_mint_directory">टकसाल निर्देशिका</string>
<string name="relay_purpose_nwc">धनकोष संयोजन</string>
<string name="relay_purpose_community_chats">समुदाय चर्चाएँ</string>
<string name="relay_purpose_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">आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया।</string>
<string name="relay_explain_direct_messages">आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं।</string>
<string name="relay_explain_public_chats">मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं।</string>
<string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं।</string>
<string name="relay_explain_encrypted_groups">समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर।</string>
<string name="relay_explain_live_rooms">शाला के पुनःप्रसारक। जब वह खुला हो।</string>
<string name="relay_explain_account_data">आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर।</string>
<string name="relay_explain_profiles">वर्तमानतः पटल पर लोगों के परिचय।</string>
<string name="relay_explain_relay_lists">खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो।</string>
<string name="relay_explain_follows">अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त।</string>
<string name="relay_explain_moderation">वृत्तान्त जो आपके अनुचरितों ने लिखा वर्तमानतः आपके पटल पर दिखनेवाले परिचयों के विषय में। पृष्ट प्रत्येक पुनःप्रसारक से जिनपर वे पत्र प्रकाशन करते हैं।</string>
<string name="relay_purpose_reports_from_follows">अनुचरित से वृत्तान्त</string>
<string name="relay_explain_wallet">आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए।</string>
<string name="relay_explain_nutzap_inbox">सुनता है आपके नटज्साप पुनःप्रसारकों पर तथा आपके आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों पर। जिससे कि कोई भी भुगतान छूट ना जाए।</string>
<string name="relay_explain_mint_directory">पुनःप्रसारकों का वीक्षण करता है यह देखने के लिए कि कौनसे टकसाल हैं तथा लोग किनकी अनुशम्सा करते हैं।</string>
<string name="relay_explain_nwc">आपके संयोजित धनकोष से सूचनाएँ।</string>
<string name="active_subs_title">सक्रिय पुनःप्रसारक ग्राहकताएँ</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 छलनी</item>
<item quantity="other">%1$d छलनियाँ</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d पुनःप्रसारक</item>
<item quantity="other">%1$d पुनःप्रसारक</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d छलनी आरोपित नहीं अब तक</item>
<item quantity="other">%1$d छलनियाँ आरोपित नहीं अब तक</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">किसी लेखा प्रति आरोपित नहीं</string>
<string name="active_subs_no_entity">सभी</string>
<string name="active_subs_scope_global">सभी</string>
<string name="active_subs_scope_follows">आपके द्वारा अनुचरित लोग</string>
<string name="active_subs_scope_authors">चयनित लोगों की सूची</string>
<string name="active_subs_scope_muted">मौनकृत लोग</string>
<string name="active_subs_scope_all_communities">आपके समुदाय</string>
<string name="active_subs_scope_algo">एक प्रिय कलनविधि सूचनावली</string>
<string name="active_subs_share">%1$dप्रतिशतप्रतिशत सब में से</string>
<string name="active_subs_search_keywords">ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र</string>
<string name="relay_explain_home">आपके अनुचरितों के पत्र। पठित उन पुनःप्रसारकों से जिनपर उनमें से प्रत्येक प्रकाशन करते हैं।</string>
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
File diff suppressed because it is too large Load Diff
@@ -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>
+16
View File
@@ -214,6 +214,7 @@
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
<string name="search_and_add_a_user">Search and add user</string>
<string name="add_a_relay">Add a Relay</string>
<string name="relay_url_not_valid">Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">My @tag name</string>
<string name="display_name">Display Name</string>
<string name="my_display_name">My display name</string>
@@ -2377,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>
@@ -3511,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>
@@ -3918,6 +3930,10 @@
<string name="git_repo_settings_save">Save</string>
<string name="git_repositories">Git Repositories</string>
<string name="highlights">Highlights</string>
<string name="git_repositories_search_open">Filter repositories</string>
<string name="git_repositories_search_close">Close filter</string>
<string name="git_repositories_search_placeholder">Filter by name, topic, host, maintainer…</string>
<string name="git_repositories_search_no_results">No repositories in the current feed match this search.</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Permissions:</string>
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Locks the per-account side-menu persistence. The hidden rows ride along in the same NIP-78
* app-specific data blob as the bottom bar, so every account keeps its own menu and it syncs across
* the user's devices.
*/
class DrawerPersistenceTest {
@Test
fun defaultIsAnUntouchedMenu() {
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(JsonMapper.toJson(AccountNavigationPreferencesInternal()))
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
}
@Test
fun blobWrittenBeforeTheFieldExistedDecodesToAnUntouchedMenu() {
// Every existing account is in this state, and so is every account that never opens the
// screen — which is exactly why the preference stores hidden rows rather than visible ones.
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>("{}")
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
}
@Test
fun hiddenRowsRoundTripThroughTheSyncedSettingsBlob() {
val hidden = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES)
val json = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = hidden.toNames()))
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
assertEquals(hidden, navBarItemsFromNames(decoded.hiddenDrawerItems))
}
@Test
fun serializedFormIsDeterministic() {
// Two equal sets must produce byte-identical JSON, or a republish that changed nothing would
// still look like a change and churn the account's NIP-78 event.
val a = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES).toNames()))
val b = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.BADGES, NavBarItem.DRAFTS).toNames()))
assertEquals(a, b)
}
@Test
fun anIdFromANewerClientIsDroppedInsteadOfFailingTheWholeBlob() {
// The names are stored as strings precisely for this: decoding them as the enum would throw
// and take every other synced setting down with it.
val json = """{"hiddenDrawerItems":["DRAFTS","SOME_SCREEN_FROM_THE_FUTURE"]}"""
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
assertEquals(setOf(NavBarItem.DRAFTS), navBarItemsFromNames(decoded.hiddenDrawerItems))
}
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The show/hide rules behind the Side Menu settings screen, exercised without the UI.
*
* The preference stores what is *hidden*, so the interesting cases are the empty set (a stock drawer,
* and the state every new install and every newly shipped destination starts in) and the mandatory
* rows, which no code path may switch off.
*/
class DrawerItemVisibilityTest {
private val you = DrawerSections.first { it.id == DrawerSectionId.YOU }
private val system = DrawerSections.first { it.id == DrawerSectionId.SYSTEM }
@Test
fun nothingHiddenMeansEverythingVisible() {
val visible = DrawerItemVisibility.visibleItems(you, emptySet())
assertEquals(you.items, visible)
assertEquals(0, DrawerItemVisibility.hiddenCount(you, emptySet()))
}
@Test
fun toggleHidesThenShows() {
val once = DrawerItemVisibility.toggle(emptySet(), NavBarItem.DRAFTS)
assertEquals(setOf(NavBarItem.DRAFTS), once)
assertFalse(DrawerItemVisibility.isVisible(once, NavBarItem.DRAFTS))
val twice = DrawerItemVisibility.toggle(once, NavBarItem.DRAFTS)
assertEquals(emptySet<NavBarItem>(), twice)
assertTrue(DrawerItemVisibility.isVisible(twice, NavBarItem.DRAFTS))
}
@Test
fun aHiddenRowDropsOutOfItsSectionKeepingTheOrderOfTheRest() {
val hidden = setOf(NavBarItem.DRAFTS)
val visible = DrawerItemVisibility.visibleItems(you, hidden)
assertEquals(you.items.filter { it != NavBarItem.DRAFTS }, visible)
assertEquals(1, DrawerItemVisibility.hiddenCount(you, hidden))
}
@Test
fun settingsCannotBeHidden() {
// The escape hatch: hiding Settings would leave no route back to the screen that hides rows.
assertEquals(emptySet<NavBarItem>(), DrawerItemVisibility.toggle(emptySet(), NavBarItem.SETTINGS))
assertTrue(DrawerItemVisibility.isVisible(setOf(NavBarItem.SETTINGS), NavBarItem.SETTINGS))
}
@Test
fun sanitizeStripsMandatoryItemsSyncedFromElsewhere() {
// Another client (or an older build) could put Settings in the set; reading it back must not
// strand the row as hidden-yet-unhideable.
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.SETTINGS, NavBarItem.DRAFTS))
assertEquals(setOf(NavBarItem.DRAFTS), sanitized)
}
@Test
fun sanitizeKeepsIdsThisDeviceDoesNotRender() {
// Favorite Apps is gated off below API 30. Editing the menu on such a device must not clear
// the choice the same account made on a newer one.
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.FAVORITE_APPS))
assertEquals(setOf(NavBarItem.FAVORITE_APPS), sanitized)
}
@Test
fun hideAllLeavesTheMandatoryRowsOfASection() {
val hidden = DrawerItemVisibility.hideAll(emptySet(), system)
assertTrue(DrawerItemVisibility.isVisible(hidden, NavBarItem.SETTINGS))
assertEquals(0, DrawerItemVisibility.hiddenCount(system, hidden))
}
@Test
fun aSectionOfOnlyMandatoryRowsHasNothingToHide() {
// What gates the section's bulk Show all / Hide all actions.
assertFalse(DrawerItemVisibility.hasHideableRows(system))
assertTrue(DrawerItemVisibility.hasHideableRows(you))
}
@Test
fun hideAllThenShowAllRoundTripsASection() {
val hidden = DrawerItemVisibility.hideAll(emptySet(), you)
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, hidden))
val shown = DrawerItemVisibility.showAll(hidden, you)
assertEquals(emptySet<NavBarItem>(), shown)
}
@Test
fun showAllOnlyTouchesItsOwnSection() {
val hidden = DrawerItemVisibility.hideAll(DrawerItemVisibility.hideAll(emptySet(), you), system)
val shown = DrawerItemVisibility.showAll(hidden, system)
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, shown))
}
@Test
fun stateHolderPersistsEveryEditAndRestoresDefaults() {
val saved = mutableListOf<Set<NavBarItem>>()
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
state.toggle(NavBarItem.DRAFTS)
state.toggle(NavBarItem.BOOKMARKS)
assertEquals(listOf(setOf(NavBarItem.DRAFTS), setOf(NavBarItem.DRAFTS, NavBarItem.BOOKMARKS)), saved)
assertEquals(2, state.totalHidden())
state.restoreDefault()
assertEquals(emptySet<NavBarItem>(), state.hidden)
assertEquals(0, state.totalHidden())
assertEquals(emptySet<NavBarItem>(), saved.last())
}
@Test
fun stateHolderDoesNotRepublishANoOpEdit() {
// Tapping a mandatory row must not republish the account's NIP-78 settings event.
val saved = mutableListOf<Set<NavBarItem>>()
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
state.toggle(NavBarItem.SETTINGS)
state.restoreDefault()
assertTrue(saved.isEmpty())
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The drawer and its settings screen both render [DrawerSections], so a destination missing from
* every section is invisible in both places at once — no compiler error, no crash, just a screen
* nobody can reach from the menu. These pin the invariants that keep that from happening quietly.
*/
class DrawerSectionsTest {
@Test
fun everyCatalogItemAppearsInADrawerSection() {
val sectioned = DrawerSections.flatMap { it.items }.toSet()
val missing = NavBarCatalog.keys - sectioned
assertEquals(
"a catalog destination is in no drawer section — add it to one in NavBarItem.kt, " +
"or to SdkGatedDrawerItems with the reason it can't be there",
emptySet<Any>(),
missing - SdkGatedDrawerItems,
)
}
@Test
fun noItemIsListedInTwoSections() {
val sectioned = DrawerSections.flatMap { it.items }
assertEquals("an item is listed in more than one drawer section", sectioned.size, sectioned.toSet().size)
}
@Test
fun everySectionedItemResolvesInTheCatalog() {
// The drawer looks each id up in NavBarCatalog and skips misses, so an id with no catalog
// entry would silently render nothing while still occupying a row in the settings screen.
DrawerSections.forEach { section ->
section.items.forEach { item ->
assertTrue("$item has no NavBarCatalog entry", NavBarCatalog.containsKey(item))
}
}
}
@Test
fun sectionsAreOrderedWithCreateBetweenFeedsAndSystem() {
// The drawer renders DrawerSections in order, so this list *is* the menu's layout. Create sits
// between the feeds and System, and is the one section with nothing configurable in it.
assertEquals(
listOf(
DrawerSectionId.YOU,
DrawerSectionId.NAVIGATE,
DrawerSectionId.FEEDS,
DrawerSectionId.CREATE,
DrawerSectionId.SYSTEM,
),
DrawerSections.map { it.id },
)
assertEquals(emptyList<Any>(), DrawerSections.first { it.id == DrawerSectionId.CREATE }.items)
}
@Test
fun everySectionHasItsOwnId() {
val ids = DrawerSections.map { it.id }
assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size)
}
@Test
fun aSectionWithNoCatalogItemsRendersFixedRowsOrNothingAtAll() {
// hasFixedRows is declared on the section but consumed by CatalogSection's `when (section.id)`,
// in another file — so the flag and the branch that honours it can drift apart with no compile
// error. A section that carries neither is unreachable in both directions at once: the settings
// screen skips it on items.isEmpty(), and CatalogSection returns before rendering a heading.
val unreachable = DrawerSections.filter { it.items.isEmpty() && !it.hasFixedRows }
assertEquals(
"a drawer section has no catalog items and no fixed rows, so it renders nowhere — " +
"give it items, set hasFixedRows and a branch in CatalogSection, or delete it",
emptyList<Any>(),
unreachable.map { it.id },
)
}
@Test
fun mandatoryItemsAreActuallyRenderedByASection() {
// A mandatory item that no section renders would be unhideable *and* invisible — the worst
// of both. Settings is mandatory precisely because it is the way back to this configuration.
val sectioned = DrawerSections.flatMap { it.items }.toSet()
assertTrue("a mandatory drawer item is in no section", sectioned.containsAll(MandatoryDrawerItems))
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
* visual result: every transition must settle the keyboard *first*.
*
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NavImeSettleTest {
private fun controllerRecording(order: MutableList<String>): NavHostController =
mockk<NavHostController>(relaxed = true) {
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
{ order.add("navigate") }
every { navigate(any<Route>()) } answers { order.add("navigate") }
every { navigateUp() } answers {
order.add("navigate")
true
}
}
@Test
fun popBackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.popBack()
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
runTest {
// The search tab focuses its field on arrival, so the keyboard is already up when the
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.navBottomBar(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun newStackSettlesTheKeyboardBeforeNavigating() =
runTest {
val order = mutableListOf<String>()
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
nav.newStack(Route.Home)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
@Test
fun aSlowKeyboardStillHoldsTheNavigationBack() =
runTest {
// The real settler suspends for the length of the IME close animation. Navigation must
// wait for it, not fire alongside it — that overlap is the bug.
val order = mutableListOf<String>()
val nav =
Nav(
controllerRecording(order),
this,
ImeSettler {
delay(250)
order.add("settle")
},
)
nav.popBack()
assertEquals(emptyList<String>(), order)
advanceUntilIdle()
assertEquals(listOf("settle", "navigate"), order)
}
}
+33 -4
View File
@@ -254,7 +254,14 @@ val jlinkRuntime =
}
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
// Cross-platform — the release workflow tars this up on every OS.
// Cross-platform — the release workflow archives this on every OS (tar.gz on
// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a
// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is
// runnable on any target after extraction, regardless of which OS built it.
// (The bundled jlink runtime is host-native — you still need to unzip a
// Windows-built image on Windows to actually launch it — but the launcher
// scripts themselves are host-agnostic, which keeps the layout uniform and
// makes ad-hoc cross-machine inspection painless.)
val amyImage =
tasks.register<Sync>("amyImage") {
group = "distribution"
@@ -282,13 +289,35 @@ val amyImage =
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
// Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in
// a backslash) so it resolves the app root without depending on CWD, then
// execs the bundled JRE against lib\*. `chcp 65001` pins the console to
// UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale
// as the installDist launcher patch above. CRLF line endings so cmd.exe
// parses it correctly.
//
// The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..`
// out of DIR to the parent directory — same idiom Gradle's own
// installDist .bat uses to resolve APP_HOME. Java tolerates the `..`
// segment but canonicalising once here keeps every classpath entry and
// error message clean (and matches the loose-directory layout users see
// after unzipping the release archive).
val windowsLauncher =
"@echo off\r\n" +
"chcp 65001 > NUL 2>&1\r\n" +
"setlocal\r\n" +
"set \"DIR=%~dp0..\"\r\n" +
"for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" +
"\"%DIR%\\runtime\\bin\\java.exe\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
doLast {
val binDir = amyImageDir.get().asFile.resolve("bin")
binDir.mkdirs()
val launcher = binDir.resolve("amy")
launcher.writeText(unixLauncher)
launcher.setExecutable(true, false)
val unix = binDir.resolve("amy")
unix.writeText(unixLauncher)
unix.setExecutable(true, false)
val windows = binDir.resolve("amy.bat")
windows.writeText(windowsLauncher)
}
}
@@ -668,7 +668,7 @@ class Context(
val filters = relays.associateWith { listOf(responseFilter) }
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -133,7 +133,7 @@ object GeochatCommands {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -167,7 +167,7 @@ object NipCommand {
val remaining = SEARCH_RELAYS.toMutableSet()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -118,7 +118,7 @@ object NostrConnect {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@@ -50,6 +51,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/**
* `amy relay …` — manage every relay list this account maintains, mirroring
@@ -112,10 +114,12 @@ object RelayCommands {
| relay info URL fetch + print a relay's NIP-11 info document (stateless)
| relay probe [--timeout SECS] relay census: mass-connect every relay the store
| [--concurrency N] knows and record live/dead + measured rtt-open
| into the reachability cache (NIP-66 kind:30166),
| [--file PATH] into the reachability cache (NIP-66 kind:30166),
| so reachability-aware commands (graperank crawl/
| refresh) skip dead relays and wait once
| (--timeout: per wave, default 15s)
| (--timeout: per wave, default 15s; --file: also
| probe candidate urls, one per line, each run
| through the relay url normalizer first)
""".trimMargin()
// ------------------------------------------------------------------
@@ -337,12 +341,41 @@ object RelayCommands {
// Relays dialed at once; --relay-concurrency accepted as the alias the
// graperank verbs spell it with.
val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap))
// Optional external candidate list: one raw url per line, run through the
// same RelayUrlNormalizer the app uses, so a probe doubles as a census of
// how a corpus of relay hints normalizes (rejects are counted, not dialed).
val fromFile = args.flag("file")
args.rejectUnknown()
var fileRaw = 0
var fileRejected = 0
var fileOnion = 0
val fileRelays = HashSet<NormalizedRelayUrl>()
if (fromFile != null) {
val candidates = File(fromFile)
if (!candidates.canRead()) return Output.error("bad_args", "cannot read --file $fromFile")
candidates.forEachLine { line ->
if (line.isBlank()) return@forEachLine
fileRaw++
val normalized = line.normalizeRelayUrlOrNull()
if (normalized == null) {
fileRejected++
} else if (RelayUrlNormalizer.isOnion(normalized.url)) {
fileOnion++
} else {
fileRelays.add(normalized)
}
}
System.err.println(
"[relay-probe] $fromFile: $fileRaw urls → ${fileRelays.size} unique clearnet relays " +
"($fileRejected rejected by the normalizer, $fileOnion onion skipped)",
)
}
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val cached = ctx.reachability.snapshot()
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + fileRelays
if (universe.isEmpty()) {
Output.emit(
linkedMapOf<String, Any?>(
@@ -381,6 +414,10 @@ object RelayCommands {
Output.emit(
linkedMapOf<String, Any?>(
"probed" to result.verdicts.size,
"file_urls" to (if (fromFile != null) fileRaw else null),
"file_normalized" to (if (fromFile != null) fileRelays.size else null),
"file_rejected" to (if (fromFile != null) fileRejected else null),
"file_onion_skipped" to (if (fromFile != null) fileOnion else null),
"reachable" to result.reachable.size,
"dead" to result.dead.size,
"closed_by_policy" to authWalled,
@@ -81,7 +81,7 @@ object SubscribeCommand {
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -1,17 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Login & Auth -->
<string name="login_title">Üdvözöljük az Amethystben</string>
<string name="login_subtitle">Jelentkezzen be Nostr a-fiókjába</string>
<string name="login_subtitle_desktop">Asztali Nostr-kliens</string>
<string name="login_card_title">Jelentkezzen be Nostr-a kulcsával</string>
<string name="login_card_subtitle">nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz</string>
<string name="login_with_key">Bejelentkezés kulccsal</string>
<string name="login_button">Bejelentkezés</string>
<string name="login_generate_new">Új kulcs előállítása</string>
<string name="login_generate_button">Új előállítása</string>
<string name="login_key_hint">Adja meg a privát kulcsát (nsec) vagy a nyilvános kulcsát (npub)</string>
<string name="login_key_label">nsec, bunker:// vagy npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Kulcs megjelenítése</string>
<string name="login_hide_key">Kulcs elrejtése</string>
<!-- New Key Warning -->
<string name="new_key_warning_title">FONTOS: Mentse el a kulcsait!</string>
<string name="new_key_warning_message">A titkos kulcsa (nsec) az EGYETLEN módja annak, hogy hozzáférjen a fiókjához. Ha elveszíti, akkor a fiókja végleg elvész. Mentse el egy biztonságos helyre!</string>
<string name="new_key_public_label">Nyilvános kulcs (megosztható):</string>
<string name="new_key_secret_label">Titkos kulcs (SOHA ne ossza meg!):</string>
<string name="new_key_continue_button">Elmentettem a kulcsaimat, folytatás</string>
<!-- Common Actions -->
<string name="action_copy">Másolás</string>
<string name="action_paste">Beillesztés</string>
<string name="action_cancel">Mégse</string>
<string name="action_ok">OK</string>
<string name="action_save">Mentés</string>
<string name="action_delete">Törlés</string>
<string name="action_share">Megosztás</string>
<!-- Errors -->
<string name="error_invalid_key">Érvénytelen kulcsformátum. Ellenőrizze, és próbálja újra.</string>
<string name="error_network">Hálózati hiba. Ellenőrizze a kapcsolatot.</string>
<string name="error_generic">Hiba történt. Próbálja újra.</string>
<!-- Loading & Empty States -->
<string name="action_refresh">Frissítés</string>
<string name="action_try_again">Próbálja újra</string>
<string name="feed_empty">A hírfolyam üres</string>
<string name="error_loading_feed">Hiba történt a hírfolyam betöltésekor: %s</string>
<!-- Placeholder Screens -->
<string name="screen_search_title">Keresés</string>
<string name="screen_search_description">Keressen felhasználókat, bejegyzéseket és kulcsszavakat.</string>
<string name="screen_messages_title">Üzenetek</string>
<string name="screen_messages_description">Az Ön titkosított közvetlen üzenetei itt fognak megjelenni.</string>
<string name="screen_notifications_title">Értesítések</string>
<string name="screen_notifications_description">Az említések, válaszok és reakciók itt fognak megjelenni.</string>
<!-- Accessibility -->
<string name="accessibility_user_avatar">Felhasználó profilképe</string>
<string name="accessibility_navigate">Navigáció</string>
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_loading_label">Betöltés:</string>
<string name="chats_history_fully_loaded_label">Teljesen betöltve:</string>
<string name="chats_history_fully_loaded">(teljesen betöltve)</string>
<string name="chats_history_by_relay">Előzmények átjátszónként</string>
<string name="chats_history_stalled_retry">Újra megpróbálja, amint újra megnyitja ezt a képernyőt</string>
<string name="chats_history_older">%1$s korábbi üzenet</string>
<string name="chats_history_all_caught_up">Naprakész</string>
<string name="chats_history_reached_start">Elérte a(z) %1$s üzeneteinek elejét</string>
<string name="chats_history_subtitle">%1$s · %2$s · betöltve ekkortól: %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">várakozás erre: %1$s</string>
<string name="chats_history_incomplete">Néhány átjátszó nem válaszolt</string>
<string name="chats_history_incomplete_sub">%1$s nem érhető el · koppintson a részletekért</string>
<string name="chats_history_relays_title">%1$s · előzmények átjátszónként</string>
<string name="chats_history_relay_since">ekkortól: %1$s</string>
<string name="action_dismiss">Eltüntetés</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d átjátszó</item>
<item quantity="other">%1$d relé</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">válasz neki: </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">nOldal: %1$s</string>
<string name="napplet_card_title">nKisalkalmazás: %1$s</string>
<string name="napplet_card_kind">nKisalkalmazás</string>
<string name="nsite_website_kind">nOldal</string>
<string name="napplet_card_permissions">Amihez hozzáférhet</string>
<string name="nsite_root_site">Gyökéroldal</string>
<string name="nsite_source">Forrás:</string>
<string name="nsite_servers">Kiszolgálók:</string>
<string name="nsite_open">Megnyitás</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Közvetlen webcím használata</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Becenév</string>
<string name="nickname_dialog_explainer">Ez jelenik meg Önnek ezen felhasználó neve helyett az alkalmazásban bárhol. Titkosítva tárolódik el a kapcsolatkártyájára: csak Ön olvashatja. Írjon be kettőspontot (:) az egyéni emodzsik használatához.</string>
<string name="nickname_label">Becenév</string>
<string name="nickname_summary_label">Privát megjegyzés erről a felhasználóról</string>
<string name="nickname_save">Mentés</string>
<string name="nickname_cancel">Mégse</string>
<string name="git_status_open">Nyitva</string>
<string name="git_status_merged">Beolvasztva</string>
<string name="git_status_closed">Lezárva</string>
@@ -53,6 +131,7 @@
<string name="road_event_traffic_jam">Forgalmi dugó</string>
<string name="road_event_unknown">Útesemény</string>
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Értéket az értékért</string>
<string name="relay_monitor_rtt_open">Megnyitás</string>
<string name="relay_monitor_rtt_read">Olvasás </string>
@@ -61,6 +140,7 @@
<string name="relay_monitor_relay_type">Típus</string>
<string name="relay_monitor_requirements">Követelmények</string>
<string name="relay_monitor_supported_nips">Támogatott NIP-ek</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Elfogadott típusok</string>
<string name="relay_discovery_geohash">Helyszín</string>
<string name="calendar_rsvp_going">Ott leszek</string>
@@ -81,6 +81,16 @@ interface NotificationSettings {
fun setEnabled(v: Boolean)
/**
* True iff the user has taken an explicit action to disable
* notifications (i.e. flipped the master switch OFF at some point).
* Used by the Settings screen to distinguish "master switch is off
* because it defaults to off on first launch" from "master switch
* is off because the user asked for it to be off". Only the former
* gets auto-enabled when the OS permission check passes.
*/
fun wasExplicitlyDisabled(): Boolean
fun setKindToggle(
kind: NotifKind,
v: Boolean,
@@ -100,7 +100,7 @@ class ChessRelayFetchHelper(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -99,7 +99,7 @@ class FeedMetadataCoordinator(
val listener =
if (onEvent != null) {
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -295,7 +295,7 @@ class FeedMetadataCoordinator(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -371,7 +371,7 @@ class FeedMetadataCoordinator(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -90,7 +90,7 @@ abstract class PerKeyEoseManager<T, K : Any>(
newEose(queryState, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -86,7 +86,7 @@ abstract class SingleSubEoseManager<T>(
newEose(relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -44,7 +44,7 @@ class RelayHealthListener(
store.recordConnect(relay.url, TimeUtils.now())
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
/**
* Which player/viewer can render a declared blob, as resolved by
* [RichTextParser.classifyMedia].
*
* The set is deliberately closed: it enumerates the renderers [BaseMediaContent] actually has
* (`MediaUrlImage`, `MediaUrlVideo`, `MediaUrlPdf`), so "no constant fits" a `null`
* classification is the honest answer for every other file type rather than a bucket some
* caller has to invent a default for. Audio folds into [VIDEO] because both play through the
* same pipeline; see `RichTextParser.videoExt`.
*/
enum class MediaContentKind {
IMAGE,
VIDEO,
PDF,
}
@@ -61,41 +61,12 @@ class RichTextParser {
val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()
var isImage = false
var isVideo = false
var isPdf = false
// Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash
// and forcing a URL-preview round-trip to rediscover a type the imeta already declared —
// which is why classifyMedia falls back to the extension before giving up.
val kind = classifyMedia(fullUrl, contentType)
if (contentType != null) {
isImage = contentType.startsWith("image/")
// HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl`
// and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the
// null bucket below and the renderer drops back to a plain hyperlink — even though
// the matching extension would have routed it to MediaUrlVideo. Mirror the canonical
// list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType.
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType)
isPdf = contentType.startsWith("application/pdf")
} else if (fullUrl.startsWith("data:")) {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
isPdf = fullUrl.startsWith("data:application/pdf")
}
// Fall back to file-extension detection when the type is still unknown. This covers both
// the no-MIME case and a *malformed* imeta MIME — e.g. Primal iOS emits `m jpeg` instead
// of `m image/jpeg`, which matches none of the `startsWith` prefixes above. Without this
// fallback such a URL returns null and drops to a plain link: that discards the imeta
// `dim`/blurhash (so the loading placeholder can't reserve the image's height and the
// feed jumps once the bitmap arrives) and forces a needless URL-preview network
// round-trip just to rediscover the type the imeta already declared. `data:` URIs carry
// their type in the prefix, so a miss there is genuine — don't extension-probe them.
if (!isImage && !isVideo && !isPdf && !fullUrl.startsWith("data:")) {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
return if (isImage) {
return if (kind == MediaContentKind.IMAGE) {
MediaUrlImage(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -108,7 +79,7 @@ class RichTextParser {
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isVideo) {
} else if (kind == MediaContentKind.VIDEO) {
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -125,7 +96,7 @@ class RichTextParser {
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isPdf) {
} else if (kind == MediaContentKind.PDF) {
MediaUrlPdf(
url = fullUrl,
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
@@ -582,6 +553,46 @@ class RichTextParser {
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
/**
* Resolves which renderer can display a declared blob the single decision every media
* renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL.
*
* A declared MIME type wins; the URL extension is the fallback both for the no-MIME case
* and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which
* matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is
* genuine and the base64 payload is never extension-probed.
*
* Returns **null** when nothing can render the file. Callers must not substitute a media
* kind for that null: handing an arbitrary blob a webxdc app, a zip, an APK to the
* video player yields a permanently-buffering ExoPlayer where a plain link belongs. The one
* defensible default is on kinds whose *event* already asserts the type (a NIP-71 video
* event is a video however odd its imeta), and those call sites say so explicitly.
*/
fun classifyMedia(
url: String,
mimeType: String?,
): MediaContentKind? {
if (mimeType != null) {
if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE
// HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType].
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/") || isHlsMimeType(mimeType)) return MediaContentKind.VIDEO
if (mimeType.startsWith("application/pdf")) return MediaContentKind.PDF
} else if (url.startsWith("data:")) {
if (url.startsWith("data:image/")) return MediaContentKind.IMAGE
if (url.startsWith("data:video/") || url.startsWith("data:audio/")) return MediaContentKind.VIDEO
if (url.startsWith("data:application/pdf")) return MediaContentKind.PDF
}
if (url.startsWith("data:")) return null
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO
if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF
return null
}
fun isValidURL(url: String?): Boolean = isValidUrl(url)
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* Local, in-memory matcher for the Git Repositories screen search box.
*
* The screen already loads the full set of ngit repository announcements
* (`kind:30617` `GitRepositoryEvent`) the user has subscribed to via their
* follow lists / follow set, so a client-side filter avoids issuing an
* extra NIP-50 relay query for the common "I know its name/topic/host"
* lookup. It also matches on fields the generic NIP-50 search would
* ignore clone/web URLs, maintainer npubs, the ngit `d` identifier
* which are exactly what someone browsing repos on gitworkshop /
* ngit tends to remember.
*
* The query is split on whitespace so `"amethyst nostr"` requires each
* term to appear in at least one indexed field of the same repository.
* Every term match is case-insensitive.
*
* Indexed fields:
* - repo name (`name` tag)
* - repo identifier (`d` tag; what appears in the ngit URL path)
* - description
* - hashtags/topics (`t` tags)
* - clone URLs (`clone` tag values)
* - web URLs (`web` tag values)
* - relay URLs the maintainers listen on (`relays` tag values)
* - maintainer pubkeys (both hex and NIP-19 `npub` form)
* - repo author pubkey (hex and npub)
* - earliest-unique-commit hash (`r euc`) lets you paste a commit
* hash from a nostr:naddr and land on the repo
*/
object GitRepositorySearchMatcher {
/**
* @return `true` when [event] matches every whitespace-separated term
* in [query]. An empty query matches nothing (callers should skip the
* filter path in that case).
*/
fun matches(
event: GitRepositoryEvent,
query: String,
): Boolean {
val terms = query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
if (terms.isEmpty()) return false
val haystack = buildHaystack(event)
return terms.all { term ->
val needle = term.lowercase()
// Support "npub1…" queries by resolving them to hex; the hex
// form is already in the haystack via authorNpubs / dTag /
// maintainers.
val hexFromBech32 = tryDecodeNpubToHex(needle)
haystack.any { field -> field.contains(needle) } ||
(hexFromBech32 != null && haystack.any { field -> field.contains(hexFromBech32) })
}
}
private fun buildHaystack(event: GitRepositoryEvent): List<String> {
val out = ArrayList<String>(16)
event.name()?.lowercase()?.let(out::add)
event
.dTag()
.takeIf { it.isNotEmpty() }
?.lowercase()
?.let(out::add)
event.description()?.lowercase()?.let(out::add)
event.hashtags().forEach { out.add(it.lowercase()) }
event.clones().forEach { out.add(it.lowercase()) }
event.webs().forEach { out.add(it.lowercase()) }
event.relays().forEach { out.add(it.lowercase()) }
// Maintainers as hex + npub. Author is an implicit maintainer per
// NIP-34, so include it in both forms too.
val authors = HashSet<String>()
authors.add(event.pubKey)
authors.addAll(event.maintainers())
authors.forEach { hex ->
out.add(hex.lowercase())
hexToNpub(hex)?.let { out.add(it.lowercase()) }
}
event.earliestUniqueCommit()?.lowercase()?.let(out::add)
return out
}
private val WHITESPACE = Regex("\\s+")
private fun tryDecodeNpubToHex(candidate: String): String? {
if (!candidate.startsWith("npub1")) return null
return runCatching {
when (val parsed = Nip19Parser.uriToRoute(candidate)?.entity) {
is NPub -> parsed.hex
else -> null
}
}.getOrNull()
}
private fun hexToNpub(hex: String): String? = runCatching { NPub.create(hex) }.getOrNull()
}
@@ -118,7 +118,7 @@ class BroadcastTracker {
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -294,7 +294,7 @@ class BroadcastTracker {
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.tor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork
class TorRelayEvaluation(
val torSettings: TorRelaySettings,
@@ -36,6 +37,11 @@ class TorRelayEvaluation(
} else {
if (relay.isLocalHost()) {
false
} else if (relay.isOverlayNetwork()) {
// An overlay-mesh relay (0200::/7, e.g. Yggdrasil) is reachable only through the
// local mesh interface: Tor cannot route the range at all, so proxying it would
// guarantee failure rather than privacy. The overlay already encrypts end to end.
false
} else if (relay.isOnion()) {
// .onion is only reachable over Tor regardless of any other classification.
torSettings.onionRelaysViaTor
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
/**
* The short label a user recognises for a distributable file type "APK", not
* "application/vnd.android.package-archive".
*
* Unmapped types return the raw MIME unchanged, which is the honest fallback: a bare
* `application/x-webxdc` still tells the reader more than an invented label would.
*
* Used by the NIP-82 software-app chips and by the file-attachment card that stands in for any
* blob no viewer can render.
*/
fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
@@ -371,7 +371,7 @@ class OutboxDispatcher(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -415,7 +415,7 @@ class OutboxDispatcher(
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,

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