Compare commits

...
Author SHA1 Message Date
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
Claude 64073f8fa9 perf: drop per-event iterator alloc; harden count/fetchFirst drain loops
Audit follow-up on the timeout work.

- fetchAllPages matched each event with `for ((i, f) in activeFilters)`.
  That destructuring form allocates an Iterator on every event, on the
  relay's reader thread, for the whole download -- millions of short-lived
  objects in a bulk walk. Switched to an indexed loop, which is why quartz
  uses the fast* operators elsewhere in hot event paths (those only cover
  Array, so a List needs the index form).

- count() and fetchFirst() drain their channels in an inner loop that only
  suspends when the channel is empty, so a backlog was consumed with no
  cancellation check: neither the idle window expiring nor the caller
  giving up could interrupt it mid-drain. Added an explicit ensureActive(),
  matching the check fetchAllPages already does per page.

- count() could also lose a result that arrived after the last window
  closed but before unsubscribe, understating the returned map. Added the
  post-loop tryReceive drain that fetchAllWithHooks and fetchFirst have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:28:18 +00:00
Claude 3ef61bafcb refactor: rename accessory timeoutMs to idleTimeoutMs
Every wait in the accessories package is an idle window measured from the
relay's most recent progress, so the parameter now says so. The name is
the contract: a caller reading timeoutMs reasonably expects a deadline,
which is exactly the misreading that made fetchAllPages' hard per-page
cap look correct for so long.

Renamed across the public surface -- fetchAll (7 overloads),
fetchAllWithHooks, fetchAllPages (2), fetchAllPagesFromPool,
fetchAllPagesFromPoolWithHooks, fetchFirst, count (2), countMerged -- plus
the quartz wrappers whose own parameter is a pure pass-through of that
window (KeyPackageFetcher, RecipientRelayFetcher, FollowerCrawler.Config)
and every call site across commons, cli, desktopApp and amethyst.

Deliberately NOT renamed, because these are genuine wall-clock bounds and
the differing name is the tell:
  - publishAndConfirm's timeoutInSeconds -- one fixed window to collect
    the OKs, a bounded confirmation round-trip rather than a stream.
  - GrapeRankCrawler.Config.timeoutMs -- a hard per-drain gate
    (withTimeoutOrNull(config.timeoutMs)) that also drives parking.
  - Context.awaitReply's timeoutMs, Context.syncIncoming, and the
    non-accessory app-layer helpers (RelayProber, FeedMetadataCoordinator,
    RelayAuthPromptBus).

This is a source-breaking change for named-argument callers of quartz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 04:16:31 +00:00
Claude ba35a87a7c refactor: drop the ceiling params; bound waits by progress instead
Follow-up audit on the timeout normalization. Three findings.

1. The maxTotalMs I added to fetchFirst and multi-relay count was the
   wrong shape twice over. A hard wall-clock bound already composes at the
   call site -- withTimeoutOrNull(ms) { fetchFirst(...) } -- so putting it
   in the signature duplicates what the caller has for free. And it was
   papering over the real defect: repeat chatter from a relay already
   accounted for (a CLOSED/reconnect loop, a duplicate COUNT) was treated
   as activity and restarted the idle window, so a flapping relay could
   hold the call open indefinitely. Both now reset the window only on
   genuine progress -- an event, or the first terminal signal from a relay
   still being waited on -- which is the rule the negentropy watchdog
   already applies to NOTICE/CLOSED chatter, and which makes both calls
   self-bounding at one window per relay. Ceiling params removed; the
   overflow guard they needed goes with them.

2. fetchFirst could drop a match: an event landing after the last terminal
   signal but before unsubscribe was left unread in the channel and the
   fetch reported nothing found. Added the post-loop drain that
   fetchAllWithHooks already does. Covered by a test.

3. fetchAllPages published its per-page counters across threads without a
   barrier on the idle path. received/delivered/pageMinTs/idsAtPageMin are
   written on the relay reader thread and read by the driver once the wait
   ends; the EOSE path gets happens-before from the channel, the idle path
   had none, so the driver could read a stale pageMinTs (ending the walk
   early) or an unsafely published idsAtPageMin. The volatile IdleClock
   bump now runs in a finally, so it covers every event including the
   early-returning duplicate and orders after the counters.

Also folded the single-relay count channel close into its finally, so a
throw mid-wait cleans up like every sibling accessory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:26:58 +00:00
Claude ac6034f764 refactor: drop fetchAllPages' maxPageMs ceiling
The per-page wall-clock ceiling added alongside the idle-window change did
not do what it claimed. fetchAllPages waits inside a while(true): when a
page's wait ends, the loop advances the until cursor and issues the next
REQ rather than returning, so a ceiling bounds one page, never the call.
Measured against a relay trickling events forever with an unbounded
filter, maxPageMs=400 produced 8 REQs and no return -- the walk ran until
the caller cancelled, exactly as it would with no ceiling at all.

It also made truncation unsafe. Cutting a page mid-stream advances until
to the oldest event received so far, which only preserves the set if the
relay streams strictly newest-first -- NIP-01 recommends that but does not
require it -- so a ceiling firing on an out-of-order relay can skip the
not-yet-sent events above that cursor. That is the same class of silent
gap the idle window was introduced to close.

What actually bounds a paged download is the filter's limit (already the
documented way) or cancelling the caller, which the ensureActive() at the
top of each page honors. Removed from fetchAllPages, fetchAllPagesFromPool
and fetchAllPagesFromPoolWithHooks; a regression test pins the real
contract so nobody re-adds a ceiling believing it caps the walk.

maxTotalMs stays on fetchAll/fetchAllWithHooks, fetchFirst and multi-relay
count: those wait in a single loop and return, so there the ceiling
genuinely ends the call (each is covered by a test asserting it does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:13:53 +00:00
Claude 53be8d1755 fix: harden accessory timeout ceilings and count() listener cleanup
Audit follow-ups on the idle-window normalization:

- maxTotalMs/maxPageMs defaults are timeoutMs * 10, which silently
  overflows to a negative Long for an effectively-infinite idle window
  (Long.MAX_VALUE * 10 wraps to -10). withTimeoutOrNull then expired
  immediately, inverting 'wait forever' into 'never wait'. All three
  ceilings (fetchAllPages, fetchFirst, fetchAllWithHooks) now treat a
  non-positive ceiling as uncapped, matching the idle window's <= 0
  convention. Regression-tested via fetchFirst.
- count(filters) leaked its RelayConnectionListener and left COUNT subs
  open if the caller cancelled mid-wait or a listener threw: the cleanup
  ran as straight-line code with no try/finally, unlike every sibling
  accessory. Wrapped so unsubscribe + removeConnectionListener + channel
  close always run.
- New FetchFirstIdleTimeoutTest pins fetchFirst's idle-window semantics
  (signals restart the window, silence costs exactly one window, the
  ceiling stops endless terminal chatter, overflow means uncapped).
- README: note publishAndConfirm's fixed window as the deliberate
  exception, and correct the fetchAllPagesFromPool row, which claimed
  cross-relay dedup the function explicitly does not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:48:15 +00:00
Claude 431ad153bc fix: normalize accessory timeouts to idle windows (time since last relay message)
fetchAllPages waited for each page's EOSE under a hard wall-clock
withTimeoutOrNull, unlike fetchAll / fetchAllWithHooks / the negentropy
sync, whose timeouts are idle windows reset by every arriving message.
A slow-but-streaming page could be silently truncated, and a relay
slower than timeoutMs to first byte was mistaken for a drained set.

- Extract IdleClock + receiveWithinIdle out of NostrClientNegentropySyncExt
  into a shared internal IdleWatchdog.kt and document the package-wide
  convention in the accessories README.
- fetchAllPages (+ pool/hooks variants): the per-page timeout is now an
  idle window bumped by every event; a maxPageMs ceiling (10x, matching
  fetchAllWithHooks' maxTotalMs) backstops a never-EOSE trickle. A
  non-positive ceiling means uncapped, mirroring the idle <= 0 convention.
- fetchFirst: the wait restarts on every arriving signal instead of one
  hard deadline across all relays; maxTotalMs ceiling added.
- count (multi-relay): each arriving COUNT result restarts the window;
  maxTotalMs ceiling added. Single-relay count is trivially idle already.
- NegentropyStoreSync page fallback clamps a disabled watchdog (0) to
  DEFAULT_DOWNLOAD_IDLE_MS like fetchByIds, instead of paging unbounded.
- New NostrClientFetchAllPagesIdleTimeoutTest pins the semantics
  (verified to fail against the old hard-deadline wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:35:10 +00:00
Vitor PamplonaandGitHub bf41e75b78 Merge pull request #3848 from vitorpamplona/fix/count-ignores-default-limit
Stop COUNT from inheriting the default page size
2026-08-02 13:15:43 -04:00
Vitor PamplonaandGitHub e10f76665f Merge pull request #3846 from vitorpamplona/claude/30382-rank-follower-providers-j2cnb9
fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
2026-08-01 21:17:36 -04:00
Claude 6cd91bb058 fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
updateFilter only added the rank provider to the trusted-author set, so
when the follower-count provider differed (different pubkey and/or
relay), its kind:30382 cards were never requested from the relay.
followerCountStrFlow then filtered for a signer whose cards never
arrived and rendered "--" forever.

Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet
block, symmetrically with liveUserRankProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv
2026-08-02 00:43:27 +00:00
Vitor PamplonaandGitHub 7922f9a4d1 Merge pull request #3845 from vitorpamplona/claude/explained-filter-test-warnings-clpcv8
Test: Type ExplainedFilter.copy() result as base Filter
2026-08-01 14:08:56 -04:00
Claude 0cc3f72f55 fix: resolve always-true is-check and redundant cast warnings in ExplainedFilterTest
Declaring 'advanced' as the base Filter type keeps the copy() regression
guard as a genuine runtime assertion instead of a compile-time triviality,
which is what the compiler was warning about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KG9YkeFp6zyth5J364DLF
2026-08-01 18:03:03 +00:00
Vitor PamplonaandGitHub 31c3eaf2b3 Merge pull request #3844 from vitorpamplona/claude/code-quality-class-decoupling-rrt199
refactor: decouple LocalCache and Account god classes (behavior-preserving)
2026-08-01 13:31:51 -04:00
Claude 92ca11a583 chore: move stray NIP-29 section comment to AccountRelayGroupActions
The relay-group section header and joinRelayGroup KDoc were left
dangling at the end of AccountConcordActions when the clusters were
split into separate files; reattach them to the function they describe.
Found by the post-refactor audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:27:56 +00:00
Claude dc5e4562fd fix: restore two Marmot log messages mangled during extraction
The account-qualification regex in the AccountMarmotActions extraction
also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL'
inside two log string literals, changing log output text. Restore the
original wording. Found by the post-refactor equivalence audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:22:31 +00:00
Vitor PamplonaandGitHub e0ea365368 Merge pull request #3843 from vitorpamplona/fix/rtt-open-from-handshake
rtt-open is the transport's handshake, not our own queueing
2026-08-01 13:22:11 -04:00
Claude 1dba215d4d refactor: extract AccountZapActions from Account
Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.

Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:16:51 +00:00
Claude d2c9593919 refactor: extract AccountRelayGroupActions from Account
Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration
(join/leave/create/delete/archive, threads, invites, pins, member/role
management, metadata edits, Buzz DMs/jobs/workflows/typing,
community member add/remove) into AccountRelayGroupActions, exposed as
account.relayGroups. External callers now use account.relayGroups.*
directly. Moved code is unchanged except for account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:11:04 +00:00
Claude 20149e2600 refactor: extract AccountMarmotActions from Account
Moves the ~540-line Marmot/MLS orchestration cluster (group create/
leave/reset, member add/remove via key-package fetch, admin grant/
revoke, metadata updates, group messaging, key-package publishing and
relay resolution) into AccountMarmotActions, exposed as account.marmot.
External callers (marmot group screens, AccountViewModel forwarders,
NotificationReplyReceiver, DecryptAndIndexProcessor) now call
account.marmot.* directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:07:24 +00:00
Claude c0932e0330 refactor: extract AccountConcordActions from Account
Moves the ~1,000-line Concord orchestration cluster (join/create/invite
flows, channel messages/reactions/edits/typing, roles and moderation,
refound/rekey/stranded-recovery, metadata + channel management,
control-plane sync) into AccountConcordActions, exposed as
account.concord. The two Concord file-level constants move with it.

Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex)
stays on Account since ConcordSessionManager is constructed with it,
as do the cross-feature sendMinichatReply and the read-path
isConcordBanned policy. External callers (Concord screens,
AccountViewModel forwarders, note action menus) now call
account.concord.* directly - no delegating shims.

Moved code is unchanged except for account. qualification.
Account.kt: 6228 -> 4935 lines so far in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:05:00 +00:00
Vitor PamplonaandGitHub dc1f0dd995 Merge pull request #3842 from vitorpamplona/claude/liveventstore-searchextensions-3ya4ir
Add StoreQueryContext for observer-relative ranking in stores
2026-08-01 13:03:08 -04:00
Vitor PamplonaandClaude Opus 5 7d91931b75 rtt-open is the transport's handshake, not our own queueing
It was measured from onConnecting to onConnected, which includes the time
the call sat in the client's dispatcher queue. Under a 16,507-relay
fan-out that queue dominates everything else: published records showed a
median rtt-open of 33.5 SECONDS and a max of 90, against a true minimum
of 140ms.

That is the field aggregators rank relays by, so it was worse than
publishing nothing — a signed claim that healthy relays are slow, when
the slowness was ours.

pingMillis already carried the right number and was being handed to the
listener unused: BasicOkHttpWebSocket computes it as
receivedResponseAtMillis - sentRequestAtMillis, so it starts when the
upgrade request actually goes out and excludes everything before it.

Zero or negative means the transport could not time the handshake, and
then no timing is published rather than a fabricated one — the same rule
the rest of this class already followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:59:22 -04:00
Claude f2e7a07a97 fix(relay): make the per-connection auth set a copy-on-write snapshot
Audit finding on the StoreQueryContext seam: RelaySession kept
authenticatedUsers as a plain mutable LinkedHashSet and handed out a
live view through RequestContext. A store (or EventSource) reading the
set during a long REQ replay — exactly what StoreQueryContext invites —
could race a concurrent NIP-42 AUTH commit on another coroutine:
iteration vs. add on an unsynchronized set is a
ConcurrentModificationException or a torn read.

The engine now swaps an immutable Set behind a @Volatile field on each
AUTH (the only writer), so every read is a consistent snapshot and
holding one across a replay is safe. AUTH is rare; the copy is off the
hot path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:58:36 +00:00
Claude 7936cab9d5 refactor: extract EventBroadcaster from Account
Moves the sign-and-publish choke point out of Account into an
EventBroadcaster class: relay-set computation (outbox model, hints,
channel home relays, broadcast lists, DM inboxes, the recursive
linked-event descent) plus every publish path (sendAutomatic,
sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast,
signAndSendPrivately*, signAndComputeBroadcast,
signAnonymouslyAndBroadcast, republishEventsTo).

Account keeps one-line delegates so its 85+ internal call sites and all
external callers are unchanged; upcoming Account*Actions extractions
will call the broadcaster directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:54:52 +00:00
Claude 5311efe65e refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui
Two read/reclaim policy clusters leave the LocalCache god object into
sibling classes in the same package, each taking the cache as its only
constructor dependency so the policies are testable in isolation:

- CachePruner: cleanMemory/cleanObservers, the six prune passes
  (hidden/old/expired/superseded/replies+reactions), and the shared
  unlinkAndRemove removal primitive (with removeIfWrap and
  editedTargetIdOf). LocalCache.deleteNote and
  DecryptAndIndexProcessor now call pruner.unlinkAndRemove;
  MemoryTrimmingService drives cache.pruner.*.
  refreshDeletedNoteObservers becomes internal so the pruner can
  notify observers.

- CacheSearch: findUsersStartingWith(username, account),
  findNotesStartingWith, and the three channel prefix searches, plus
  their private exclusion rules. Callers (SearchBarViewModel,
  AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use
  cache.search.* directly - no delegating shims left behind.

Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into
the model package where its implementor (LocalCache) and its types
live, removing a model-layer interface defined in a UI file.

All moved code is unchanged except for cache. qualification; behavior
is identical. LocalCache.kt: 4554 -> 3921 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:48:21 +00:00
Claude 2e90cc24ba feat(quartz): move NIP-50 extension handling into the stores; add IEventStore seams
The IEventStore contract now defines the seams a store implementation
needs instead of having the relay layer decide for it:

- NIP-50 search extensions: LiveEventStore no longer strips key:value
  tokens before the store sees them. Filter.search reaches every store
  verbatim, and each implementation decides which extensions it
  supports — the store, not the middleware, is the only component that
  knows whether sort:rank is a directive or noise. The built-in SQLite
  and filesystem stores strip at their own boundary (their FTS engines
  would otherwise error on / literally match the tokens), preserving
  the NIP-50 'ignore unsupported extensions' behavior end to end.
  Extension-aware stores need no side channel to recover the raw
  string anymore. Fs delete now checks emptiness on the stripped
  filter so an extensions-only search cannot wipe the store.

- Caller identity: new StoreQueryContext coroutine-context element,
  installed by LiveEventStore around every REQ/COUNT store call when
  the connection has NIP-42-authenticated pubkeys. Observer-relative
  stores (web-of-trust ranking, for-you relevance) read it off the
  coroutine context; ranking context only, never match-set changes.

- Negentropy liveness: snapshotIdsForNegentropy gains an optional
  onProgress hook so mirrors syncing huge corpora get a running count
  through the interface type instead of a concrete-class overload.
  SQLite reports from its row loop; the interface default streams and
  reports too.

- FtsReindexProgress.cursor documented as opaque and store-defined so
  resumable-reindex callers never assume id semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:44:31 +00:00
Claude 532b9e67fe refactor: collapse LocalCache event dispatch into grouped when branches
justConsumeInnerInner was one when(event) with ~290 branches, of which
172 were identical single-call bodies routing to consumeBaseReplaceable
or consumeRegularEvent, and ~55 more were single-line Buzz consumer
calls. Since all four shared consumers take a plain Event, the
boilerplate branches are now comma-grouped into one branch per
consumer (replaceable/addressable, regular, Buzz timeline, Buzz
store-only), keeping every branch with per-kind logic exactly as it
was.

Dispatch is provably unchanged: none of the 289 event classes has a
supertype among the classes in any other branch group, so reordering
cannot shadow a branch, and the old and new type-to-consumer mappings
were compared exhaustively and are identical. The else branch still
rejects unlisted kinds, preserving the supported-kinds allowlist.

LocalCache.kt: 5155 -> 4554 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:29:08 +00:00
Vitor PamplonaandGitHub 3823eae11d Merge pull request #3840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 09:54:16 -04:00
vitorpamplonaandgithub-actions[bot] 9cb17a26e8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 13:51:50 +00:00
Vitor PamplonaandGitHub 6d518adddb Merge pull request #3841 from vitorpamplona/feat/observer-out-of-band
Let a monitor publish what it learned without dialling
2026-08-01 09:49:03 -04:00
Vitor PamplonaandClaude Opus 5 c3c20c6615 Let a monitor publish what it learned without dialling
RelayObserver is a RelayConnectionListener, so on its own it can only
report on relays something opened a websocket to. On a large fan-out that
is a small minority, and it is the wrong minority: the cheap checks that
decide NOT to dial — a TCP probe, a DNS failure, a host struck out after
repeated silence — are precisely the ones that learn a relay is gone, and
their findings had nowhere to go.

Measured on a 16,507-relay list: 104 records published. Everything else
was ruled out before the client ever saw it, so the monitor had nothing
to say about 99% of the relays it had just formed an opinion on.

record() takes those findings. Same rules as the connection path — a
relay that answered is not demoted by one failed probe, and a reachable
relay with no measured time is published with no time rather than a
fabricated zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:42:00 -04:00
David KasparandGitHub da009f36bd Merge pull request #3839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 12:07:57 +02:00
davotoulaandgithub-actions[bot] f7959571a7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 09:31:43 +00:00
davotoula 529c114802 fix: hoist the playback-error test fixtures out of composition 2026-08-01 11:21:47 +02:00
Vitor PamplonaandGitHub 2741d32cfd Merge pull request #3838 from vitorpamplona/claude/nip42-auth-dm-delivery-test-fhn7fd
Fix InProcessWebSocket race condition in connect-time AUTH delivery
2026-08-01 01:29:24 -04:00
Claude ae61e69136 fix(relays): deliver in-process server frames only after onOpen
Nip42AuthDmDeliveryTest stalled for its full 10s timeout on CI while
passing locally. The stall is a race in InProcessWebSocket.connect():
server.connect() runs the session's connect-time policies synchronously,
so FullAuthPolicy's AUTH challenge reached the client's listener before
the socket assigned its `incoming` channel and before onOpen fired —
breaking the WebSocketListener contract (no onMessage before onOpen).

RelayAuthenticator answers that challenge on its own coroutine. When the
signed AUTH reply hit send() before the connect thread reached the
`incoming` assignment, send() returned false and the reply was silently
dropped. Nothing recovers from that: the challenge is already dedup'd as
answered, and an EVENT rejected with OK-false `auth-required:` never
re-triggers auth (only a CLOSED does), so the pending gift wrap was
never resent — exactly the CI signature (10.011s, no auth activity
between the authenticator's Init and Destroy logs).

Server->client frames now go through an outbound channel drained by a
coroutine started only after onOpen, so every connect-time frame reaches
the listener with the socket fully wired. Order is preserved by the
single drainer, same as the existing inbound path.

Both new InProcessWebSocketTest cases fail deterministically without the
reorder (the challenge always outran onOpen; a reply sent from the first
onMessage was always rejected) and pass with it, on top of the full
:geode:test and :quartz:jvmTest suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bf1Y91sfjxwi2ig4ymGTA9
2026-08-01 05:27:51 +00:00
Vitor PamplonaandGitHub 79f198c729 Merge pull request #3836 from vitorpamplona/feat/nip66-relay-monitor
NIP-66: measure relays from the traffic a client already makes
2026-07-31 23:23:23 -04:00
Vitor PamplonaandClaude Opus 5 18d229be58 Match the listener's parameter names; drop commas from test names
Native targets reject a comma inside a backticked name, so five tests
that read fine on JVM broke every Kotlin/Native build. Renamed without
them.

The override parameters now match RelayConnectionListener — pingMillis,
compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings
and, more to the point, fixes a misreading: onConnected's second and
third parameters are the connection's ping and whether it is compressed,
and I had them named attempt and success.

Both were missed the same way: jvmTest passes without ever compiling the
native TEST sources. All five targets now compile, main and test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:55:36 -04:00
Vitor PamplonaandGitHub d6333989a4 Merge pull request #3837 from vitorpamplona/claude/quartz-hex-encode-decode-xggbv2
Add optimized decode64/decode128 and encode64/encode128 to Hex
2026-07-31 22:51:50 -04:00
Vitor PamplonaandClaude Opus 5 cf75272202 Fix the native build: toSortedMap is java.util
commonMain, so it compiled on JVM and broke every native target. Sorted
into a LinkedHashMap instead, which is the same output everywhere.

Found by CI on iosSimulatorArm64 because I had only compiled the JVM
target locally; all five now build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:31:39 -04:00
Vitor PamplonaandClaude Opus 5 5c24b003e7 NIP-66: measure relays from the traffic a client already makes
A monitor normally probes — opens connections purely to measure, then
throws them away. A client that is already subscribing, fetching and
publishing has better data for free: measured under real load, against
the relays it actually uses, at the concurrency it actually runs.

RelayObserver is a RelayConnectionListener, so it sees every connection
whichever code path opened it and none of them has to report anything:

  rtt-open      onConnecting to onConnected
  rtt-read      first REQ to its EOSE
  rtt-write     first EVENT to its OK
  reachable     it opened, or served something
  auth-required it sent AUTH, or CLOSED saying so
  the error, verbatim, when it never opened

Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is
the relay's own claim, available to anyone who asks, and republishing it
under a monitor's signature adds nothing but a chance to go stale. Where
the two disagree — a relay advertising open reads that then challenges
us — the observation is the half worth having, and copying the claim
would erase it. It also keeps quartz free of an HTTP dependency.

RelayMonitor is the whole wiring: construct one and connections are
measured, signed as 30166s on an interval, and folded into a cheap
in-memory isKnownDead for picking relays. That read has to be cheap — an
outbox picker runs per event — so it answers from a snapshot refreshed on
an interval, never a store query.

The signer is required. Measuring relay quality and letting others check
it IS NIP-66, and an optional signer would just add the failure mode this
library keeps designing out: configured, silent, doing nothing. A client
that should not publish does not construct one.

RelayObserver also replaces the CLI's RelayDiagnostics, which was the
same listener minus the timings. Porting it surfaced a bug both shared:
substringBefore(':') returns the WHOLE string when there is no colon, so
a relay's free-form CLOSED prose became its own tally key and the map
grew with the number of distinct sentences relays wrote. The colon is
now required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:21:00 -04:00
Claude 729fb1bc17 perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs
javap showed the hexToByte/byteToHex field re-loaded on every use inside
these methods (16 times per readLong call) — the JVM/ART doesn't reliably
prove the load loop-invariant. Hoisting it into a local measured ~25%
faster for decode and ~10% for isEqual and readLong on the JVM
(4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was
neutral on HotSpot but is hoisted too since ART is historically worse
at this (see the internalIsHex comment).

Branchless variants of isHex/isHex64 were also measured and were a
wash-to-slightly-worse than the branchy early-exit versions on valid
input, so those keep their current implementations.

Also adds the new exact-size codecs to the on-device HexBenchmark
(decode64, decode64OrNull, encode64, decode128, encode128, toLong256,
and the old isHex64+decode two-pass for comparison) so ART numbers can
be collected with the existing benchmark harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 01:47:01 +00:00
Vitor PamplonaandGitHub 77ff9e9699 Merge pull request #3834 from vitorpamplona/dependabot/github_actions/actions-08295fc4ea
chore(actions): bump the actions group with 5 updates
2026-07-31 21:34:08 -04:00
dependabot[bot]andGitHub 166ebc755e chore(actions): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `5.6.0` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.1` | `3.0.2` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-java` from 5 to 5.6.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0)

Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 00:45:45 +00:00
Claude d8bae8c628 perf(quartz): tune Hex.decodeExactOrNull at the bytecode level
javap on the previous version showed the hexToByte field re-loaded
twice per iteration, the trip count as a runtime parameter, and the
whole loop wrapped in an exception table (only needed because chars
above 0xFF overflow the 256-entry lookup table).

Now the table is hoisted into a local, the function is inline so the
32/64-byte length becomes a compile-time constant at each call site,
and out-of-range chars are rejected branchlessly: the index is masked
with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative
for any char above 0xFF and is folded into the same sign-bit
accumulator that already catches invalid hex digits. No try/catch, no
exception table, no branches in the loop.

Measured on the JVM (4096 random ids, best-of-200 rounds, two runs
with variant order reversed to rule out JIT profile artifacts):
~25% faster than the previous version and ~2x faster than the
isHex64 + decode two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:33:51 +00:00
Claude 9ea4b81c1f feat(quartz): size-enforcing Hex.decode64/128 and encode64/128
Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:

- decode64 / decode128 throw IllegalArgumentException; the OrNull
  variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.

The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:14:17 +00: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
193 changed files with 15181 additions and 5220 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.
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -126,7 +126,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -161,7 +161,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -220,7 +220,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
+61 -29
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,17 @@ 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). jpackage / jlink / Compose Multiplatform 1.11 all
# produce host-native artifacts — no cross-compilation needed.
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: 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:
@@ -53,7 +63,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -93,13 +103,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).
@@ -208,12 +226,13 @@ jobs:
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 +249,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 +271,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"
@@ -285,7 +315,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -325,8 +355,9 @@ 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" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -337,7 +368,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -536,7 +567,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -574,8 +605,9 @@ 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" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -586,7 +618,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -777,7 +809,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -831,17 +863,17 @@ jobs:
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: geode/Dockerfile
@@ -866,7 +898,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -1010,7 +1042,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+13 -4
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -49,16 +49,24 @@ 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
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -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
+8 -5
View File
@@ -57,9 +57,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 +113,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`) | — |
@@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest {
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Built outside composition on purpose: the mock and its error state are fixtures for the whole
* test, not per-composition state. Creating them inside `setContent` would rebuild both on every
* recomposition (and trips Compose's UnrememberedMutableState lint).
*/
private fun failedControllerState() =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
)
private fun renderInBox(
width: Dp,
height: Dp,
fontScale: Float = 1f,
) {
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) {
Box(Modifier.width(width).height(height)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest {
// button is measured before the weighted text block that absorbs the shortfall. Measure
// the same button roomy and then at its tightest, and require the two to agree.
val boxHeight = mutableStateOf(400.dp)
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest {
// was just tall enough to keep the icon and not tall enough to pay for it, so the title
// rendered sliced. Decoration must yield before words do.
val boxHeight = mutableStateOf(400.dp)
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
/*
* 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
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
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.utils.Log
import kotlin.coroutines.cancellation.CancellationException
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
* metadata updates, group messaging, and key-package publishing. MLS state
* lives in [MarmotManager]; this class wires it to the account's signer, relay
* client, and relay lists. Functions live here (not a ViewModel) so headless
* callers - notification receivers, background workers - can drive them.
*/
class AccountMarmotActions(
private val account: Account,
) {
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
*/
suspend fun sendMarmotGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}" +
"${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
// Link the envelope to the inner message we just encrypted so relay
// OK acceptances drill down to the note the chat renders (see
// LocalCache.addRelayToNoteAndInners).
outbound.signedEvent.innerEventId = innerEvent.id
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before relay round-trip so the UI
// updates immediately.
account.marmotGroupList.markAsKnown(nostrGroupId)
if (groupRelays.isEmpty()) {
Log.w("MarmotDbg") {
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
}
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
* Returns a status message describing the outcome.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun fetchKeyPackageAndAddMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
): String {
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}"
}
val manager = account.marmotManager ?: return "Error: Marmot not initialized"
if (!account.isWriteable()) return "Error: Account is read-only"
// Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox
// (where KeyPackages typically also land), and finally union
// with our own outbox so we still find packages that ended up
// on a shared relay.
val myOutbox = account.outboxRelays.flow.value
val memberKeyPackageRelays =
(
account.cache
.getAddressableNoteIfExists(
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
.createAddress(memberPubKey),
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
)?.relays()?.toSet().orEmpty()
val memberOutbox =
account.cache
.getOrCreateUser(memberPubKey)
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
}
val event =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(account.client, memberPubKey, fetchRelays)
if (event == null) {
Log.w("MarmotDbg") {
"fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)"
}
return "Error: No KeyPackage found for this user. They may not have published one yet."
}
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}"
}
val keyPackageBase64 = event.keyPackageBase64()
if (keyPackageBase64.isBlank()) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
return "Error: KeyPackage event has empty content"
}
// The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them.
val groupRelays = myOutbox.toList()
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}"
}
addMarmotGroupMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = event,
groupRelays = groupRelays,
)
return "Success: Member added to group"
}
/**
* Add a member to a Marmot MLS group.
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
*/
suspend fun addMarmotGroupMember(
nostrGroupId: HexKey,
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
groupRelays: List<NormalizedRelayUrl>,
) {
val memberPubKey = keyPackageEvent.pubKey
Log.d("MarmotDbg") {
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" +
"groupRelays=${groupRelays.size}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val (commitEvent, welcomeDelivery) =
manager.addMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = keyPackageEvent,
relays = groupRelays,
)
// The MLS commit has already been applied to the local group state —
// surface the new member list in the chatroom now so observers (e.g.
// MarmotGroupInfoScreen) update without waiting for our own commit to
// loop back through the relay.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
}
// Publish commit first (critical ordering)
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send the Welcome gift wrap to the new member.
//
// Use the same delivery path that NIP-17 DMs (kind:1059) take —
// computeRelayListToBroadcast() — which has fallbacks for kind:10050
// → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the
// invitee, so this path is the one we know works. We also union
// with our own outbox + the recipient's dmInboxRelays() as a
// belt-and-braces measure in case the cache hasn't been hydrated
// yet for this contact.
if (welcomeDelivery != null) {
val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
val recipientInbox =
account.cache
.getOrCreateUser(memberPubKey)
.dmInboxRelays()
.orEmpty()
val relayList = computed + account.outboxRelays.flow.value + recipientInbox
Log.d("MarmotDbg") {
"addMarmotGroupMember: welcome gift wrap relay sources " +
"computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " +
"recipientInbox=${recipientInbox.size} → union=${relayList.size}"
}
if (relayList.isEmpty()) {
Log.w("MarmotDbg") {
"addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped"
}
} else {
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}" +
"kind:${welcomeDelivery.giftWrapEvent.kind}${relayList.size} relay(s): ${relayList.map { it.url }}"
}
}
account.client.publish(welcomeDelivery.giftWrapEvent, relayList)
} else {
Log.w("MarmotDbg") {
"addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!"
}
}
}
/**
* Relays where this account publishes kind:30443 KeyPackage events.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
/**
* Publish or rotate KeyPackage events.
*/
suspend fun publishMarmotKeyPackages() {
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" }
return
}
val relays = keyPackagePublishRelays()
val needsRotation = manager.needsKeyPackageRotation()
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}"
}
if (needsRotation) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)"
}
rotatedEvents.forEach { event ->
account.cache.justConsumeMyOwnEvent(event)
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}" +
"${relays.size} relay(s): ${relays.map { it.url }}"
}
account.client.publish(event, relays)
}
}
}
/**
* Generate and publish initial KeyPackage for this account.
*/
suspend fun publishMarmotKeyPackage() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val relays = keyPackagePublishRelays()
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
}
val event = manager.generateKeyPackageEvent(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}"
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
}
/**
* Ensure the local user has at least one active KeyPackage bundle and
* a published KeyPackage event on relays. Called from [init] after
* Marmot state has been restored from disk.
*
* - If [KeyPackageRotationManager] already has an active bundle (from
* the persisted snapshot), we trust the previous session and do
* nothing. The matching kind:30443 should already be on relays from
* when the bundle was first generated.
* - Otherwise we generate a fresh bundle (which is now persisted to
* disk by [KeyPackageRotationManager.generateKeyPackage]) and
* publish the corresponding event.
*
* Best-effort: failures are logged but never propagated. We don't want
* a flaky relay or missing outbox config at startup to crash account
* initialization.
*/
internal suspend fun ensureMarmotKeyPackagePublished() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
try {
val hasBundle = manager.hasActiveKeyPackages()
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}"
}
if (hasBundle) {
return
}
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now"
}
publishMarmotKeyPackage()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e)
}
}
/**
* Check if a KeyPackage has been published in this session.
* The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
* persisted snapshot, so there is no fixed address to query in the cache.
*/
suspend fun hasPublishedKeyPackage(): Boolean {
val manager = account.marmotManager ?: return false
return manager.hasActiveKeyPackages()
}
/**
* Create a new Marmot MLS group.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
}
/**
* Leave a Marmot MLS group.
* Publishes the SelfRemove proposal and removes local state.
*
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
* commit dropping themselves from `admin_pubkeys` before issuing a
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
* SelfRemove proposal both go to the same group relays, demote first so
* peers apply it before they see the SelfRemove.
*/
suspend fun leaveMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
if (remaining.isEmpty()) {
val heir =
manager
.memberPubkeys(nostrGroupId)
.map { it.pubkey }
.firstOrNull { it != account.signer.pubKey }
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
account.client.publish(demoteCommit.signedEvent, groupRelays)
}
}
val outbound = manager.leaveGroup(nostrGroupId)
// manager.leaveGroup already wiped MLS state, relay subscriptions and
// the persisted message log. Drop the in-memory chatroom too — that
// releases the strong refs to the decrypted inner notes so LocalCache
// (which holds them weakly) can GC them, and the Notification feed
// (which iterates account.marmotGroupList.rooms) stops surfacing the group.
account.marmotGroupList.removeGroup(nostrGroupId)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* User-initiated "nuclear" reset for the Marmot subsystem.
*
* Wipes every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every relay subscription and every in-memory
* chatroom associated with this account. Does NOT broadcast any
* SelfRemove/leave commits to peers — if the user is in this flow at
* all, local state may already be unusable and a graceful leave is
* probably not possible. Peers will see the user as unresponsive until
* their next commit evicts the stale leaf.
*
* A fresh KeyPackage will be republished lazily on the next
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
* reachable for future group invites.
*/
suspend fun resetMarmotState() {
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}" }
account.marmotManager?.resetAllState()
for (groupId in account.marmotGroupList.allGroupIds()) {
account.marmotGroupList.removeGroup(groupId)
}
}
/**
* Remove a member from a Marmot MLS group.
* Publishes the commit GroupEvent to group relays.
*/
suspend fun removeMarmotGroupMember(
nostrGroupId: HexKey,
targetLeafIndex: Int,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " +
"groupRelays=${groupRelays.size}"
}
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" }
return
}
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}" +
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Update a Marmot MLS group's metadata (name, description, etc.).
* Publishes the commit GroupEvent to group relays.
*/
suspend fun updateMarmotGroupMetadata(
nostrGroupId: HexKey,
metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
// The MLS commit has already been applied locally — surface the new
// metadata in the chatroom now so the UI reflects it without waiting
// for the relay round-trip.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
*
* No-op if the group has no prior metadata (shouldn't happen outside the
* first bootstrap commit) or the target is already an admin. Callers
* must be an admin themselves — the MLS engine enforces this via the
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
*/
suspend fun grantMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
/**
* Revoke admin privileges from [targetPubKey]. Rejects any change that
* would leave the group with zero admins — MIP-03's admin-depletion guard
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
* throw at commit time.
*/
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
}
@@ -0,0 +1,554 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
* join/leave/create/delete/archive groups, threads, invites, pins, member and
* role management, metadata edits, plus the Buzz dialect's DMs, jobs,
* workflows, and typing signals. Event building lives in quartz builders;
* this class wires them to the account's signer and the group's host relay.
*/
class AccountRelayGroupActions(
private val account: Account,
) {
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
// list is our own cross-device bookkeeping of what we joined.
/** Send a kind 9021 join request to the group's host relay and remember it. */
suspend fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) {
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!account.isWriteable()) return
val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
account.client.publish(signed, setOf(channel.groupId.relayUrl))
}
/**
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
* We never assign the channel id ourselves, so callers discover the materialized DM
* by watching that registry rather than from this call's return.
*/
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
): String? {
val signed = account.signer.sign(DmOpenEvent.build(participants))
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
var results = account.client.publishAndCollectResults(signed, setOf(relay))
var channelId = buzzDmChannelIdFromAck(results)
// NIP-42 write race: on a cold connection the relay rejects the first publish with
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = account.client.publishAndCollectResults(signed, setOf(relay))
channelId = buzzDmChannelIdFromAck(results)
}
return channelId
}
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
results.values
.firstOrNull { it.accepted }
?.message
?.substringAfter("\"channel_id\":\"", "")
?.substringBefore('"')
?.takeIf { it.isNotBlank() }
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
val template = DmHideEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
suspend fun addBuzzDmMember(
channel: RelayGroupChannel,
member: HexKey,
) {
val template = DmAddMemberEvent.build(channel.groupId.id, member)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
* channel may accept it. Returns the new job id (the request event id), or null when the
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
*/
suspend fun fileBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
request: String,
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null))
// Reflect it locally so the board updates immediately (publish only sends to relays).
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
suspend fun cancelBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
) {
if (!account.isWriteable()) return
val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
}
/**
* Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on
* [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the
* approval token), returned here. A run pauses on a human-approval gate before anything ships —
* see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator].
*/
suspend fun triggerBuzzWorkflow(
relay: NormalizedRelayUrl,
channelId: String,
workflowId: String,
task: String,
): HexKey? {
if (!account.isWriteable()) return null
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/**
* Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an
* addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a
* human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
*/
suspend fun publishBuzzWorkflowDef(
relay: NormalizedRelayUrl,
channelId: String,
name: String,
yaml: String,
): String? {
if (!account.isWriteable()) return null
val workflowId = RandomInstance.randomChars(16)
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return workflowId
}
/**
* Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which
* doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work.
* Publishing to the single group [relay]; the runner discovers the decision by author.
*/
suspend fun approveBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */
suspend fun denyBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/**
* Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging
* the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
*/
suspend fun upvoteBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
jobAuthor: HexKey?,
) {
if (!account.isWriteable()) return
val template =
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
addUnique(ETag.assemble(jobId, null, null))
jobAuthor?.let { addUnique(PTag.assemble(it, null)) }
addUnique(arrayOf("k", JobRequestEvent.KIND.toString()))
addUnique(GroupIdTag.assemble(channelId))
}
val signed = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.unfollow(channel)
}
/**
* Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
* just removing me; the relay drops the group and its messages. Also drops it from our own list
* so it disappears from Messages immediately instead of lingering as a now-dead id.
*/
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
val template = DeleteGroupEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.unfollow(channel)
// Remember the deletion so the channel leaves the community's browse list immediately and
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
RelayGroupDeletions.markDeleted(channel.groupId)
}
/**
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
* the new group's id.
*/
suspend fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
name: String,
about: String? = null,
picture: String? = null,
isPrivate: Boolean = false,
isClosed: Boolean = false,
isHidden: Boolean = false,
isRestricted: Boolean = false,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
channelType: String? = null,
): GroupId {
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
// a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay
// publish two events and produce nothing at all.
account.broadcaster.signAndSendPrivatelyOrBroadcast(
CreateGroupEvent.build(
groupId = groupId,
name = name,
about = about,
visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN,
channelType = channelType,
),
) { listOf(relay) }
val edit =
EditMetadataEvent.build(
groupId,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
val id = GroupId(groupId, relay)
account.follow(LocalCache.getOrCreateRelayGroupChannel(id))
return id
}
/**
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
* restrictive counterpart — so only the enabled restrictive flags are added.
*/
private fun relayGroupStatus(
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
}
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
suspend fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) {
val template =
ThreadEvent.build(body, title) {
hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.pubKey))
}
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
suspend fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) {
val template = CreateInviteEvent.build(channel.groupId.id, code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
suspend fun updateRelayGroupPins(
channel: RelayGroupChannel,
pinnedEventIds: List<HexKey>,
) {
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
suspend fun pinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
}
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
suspend fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (!channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
suspend fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) {
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
* event (moderator only). Pass an empty [roles] list for a plain member.
*/
suspend fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) {
// Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its
// own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
val buzzRole =
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
when {
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
else -> BUZZ_ROLE_MEMBER
}
} else {
null
}
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
* [relay] with no channel scope.
*/
suspend fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) {
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
}
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
suspend fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) {
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
* existing `child` is rejected by the relay. So unless the caller is explicitly
* re-parenting, we re-carry the group's current [parent] and full [children] list
* from its latest known metadata to keep the tree intact across a plain name/flag
* edit. Pass an explicit value to change them.
*/
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
val template =
EditMetadataEvent.build(
channel.groupId.id,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
* history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
*/
suspend fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) {
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
}
@@ -0,0 +1,337 @@
/*
* 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
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
/**
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
* onchain zaps/sends. Event building lives in the commons ZapActions/
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
* routing to the account.
*/
class AccountZapActions(
private val account: Account,
) {
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
message: String = "",
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
account.client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
account.client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.client.publish(event, setOf(relay))
}
/**
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = account.nip47SignerState.defaultWalletUri.value ?: return false
return account.nip47SignerState.infoCache
?.current(uri)
?.supportsMethod(NwcMethod.PAY) == true
}
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
* fails the binding check, so no invalid receipt is ever published (the payment
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
* external-wallet or LNURL fallback because only NWC returns the proof.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
recipientPubKey: HexKey,
offer: String,
amountMillisats: Long,
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
onProcessed: () -> Unit,
) {
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
if (zapType == LnZapEvent.ZapType.NONZAP) {
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
account.scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
}
}
return
}
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
val intent =
if (zappedEvent == null) {
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
} else {
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
}
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
account.scope.launch {
// try/finally so a failure while assembling/publishing the receipt (e.g. a
// remote signer error) still steps progress and surfaces an error, instead
// of vanishing as an uncaught coroutine exception. The payment already
// settled at this point, so such a failure means "paid, no receipt".
try {
when (response) {
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
account.cache.justConsumeMyOwnEvent(zap)
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = account.signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
account.cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
}
@@ -0,0 +1,492 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
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.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Memory-reclaim policy over the [LocalCache] stores: trims the soft caches,
* prunes hidden/old/expired/superseded events, and owns the shared
* [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on.
*
* Pure policy — it holds no state of its own beyond the cache reference, so every
* function can be exercised against a populated cache in tests. Driven by
* `MemoryTrimmingService`.
*/
class CachePruner(
private val cache: LocalCache,
) {
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" }
cache.notes.cleanUp()
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" }
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" }
cache.addressables.cleanUp()
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" }
Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" }
cache.users.cleanUp()
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" }
}
fun cleanObservers() {
cache.notes.forEach { _, it -> it.clearFlow() }
cache.addressables.forEach { _, it -> it.clearFlow() }
}
private fun pruneHiddenMessagesChannel(
channel: Channel,
account: Account,
) {
val toBeRemoved = channel.pruneHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneHiddenMessages(account: Account) {
cache.ephemeralChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.geohashChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.liveChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.publicChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
}
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
// `NestsFeedFilter` so a presence still inside any feed's window
// can never be pruned.
private val presencePruneAgeSeconds = 20L * 60L
private fun pruneOldMessagesChannel(channel: Channel) {
val toBeRemoved = channel.pruneOldMessages()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Audio-room presence is keyed separately from `notes` and
// never gets reaped by the top-N rule. Drop entries older
// than 2× the 10-min freshness window so the index doesn't
// grow unbounded with every author who ever heartbeat here.
if (channel is LiveActivitiesChannel) {
channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds)
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneOldMessages() {
checkNotInMainThread()
cache.ephemeralChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.geohashChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.liveChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.publicChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.chatroomList.forEach { userHex, room ->
// History floors are pinned per scope on first advance; null means that window never paged
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
// filter when accumulating below.
val giftWrapFloor = room.giftWrapHistory.floor
val accountNip04Floor = room.nip04History.floor
room.rooms.map { key, chatroom ->
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
// kind:4 message, so rooms that never paged conversation history pay nothing.
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
toBeRemoved.forEach { note ->
when (val ev = note.event) {
is BaseDMGroupEvent ->
if (giftWrapFloor != null) {
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
}
is PrivateDmEvent -> {
val until = ev.createdAt
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
}
}
childrenToBeRemoved.addAll(removeIfWrap(note))
unlinkAndRemove(note)
childrenToBeRemoved.addAll(note.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
// re-requests it on the next demand-advance instead of skipping the hole.
if (giftWrapPruned.isNotEmpty()) {
room.giftWrapHistory.rewindTo(giftWrapPruned)
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
}
if (accountNip04Pruned.isNotEmpty()) {
room.nip04History.rewindTo(accountNip04Pruned)
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
}
if (roomNip04Pruned.isNotEmpty()) {
chatroom.nip04History.rewindTo(roomNip04Pruned)
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
}
if (toBeRemoved.size > 1) {
println(
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
)
}
}
}
}
private fun removeIfWrap(note: Note): List<Note> {
val host = note.rumorHost ?: return emptyList()
val children = mutableListOf<Note>()
cache.getNoteIfExists(host.id)?.let { hostNote ->
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
cache.getNoteIfExists(sealId)?.let { sealNote ->
unlinkAndRemove(sealNote)
children.addAll(sealNote.clearChildLinks())
}
}
unlinkAndRemove(hostNote)
children.addAll(hostNote.clearChildLinks())
}
note.rumorHost = null
return children
}
fun prunePastVersionsOfReplaceables() {
val toBeRemoved =
cache.notes.filter { _, note ->
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt <
(
cache.addressables
.get(noteEvent.address())
?.event
?.createdAt ?: 0
)
} else {
false
}
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) }
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val toBeRemoved =
cache.notes.filter { _, note ->
(
(note.event is TextNoteEvent && !note.isNewThread()) ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is ReportEvent ||
note.event is GenericRepostEvent
) &&
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
note.flowSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in
accounts &&
// don't delete if it is the logged in account
note.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
/**
* Unlinks [note] from everything in the cache that references it, then drops it
* from the notes map and notifies observers. This is the shared "unlink from
* above" half of removal, used by both the prune callers and [LocalCache.deleteNote].
*
* It detaches the note from:
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
* because event-level reports and torrent comments both carry the target in
* `replyTo`, [Note.removeNote] cleans those up here too;
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
* resolve so a note can never linger in a channel after leaving the cache);
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
* reported addresses, contact cards, statuses, and poll responses.
*
* It deliberately does NOT touch the note's own children: prune callers collect
* them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote]
* keeps them and severs only their back-reference. Every per-target removal is
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
* event-level report reachable both ways) is harmless. Addressable notes are
* dropped from the addressables map by the caller; this only removes from notes.
*/
fun unlinkAndRemove(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeNote(note)
}
note.inGatherers?.forEach { it.removeNote(note) }
cache.getAnyChannel(note)?.removeNote(note)
val noteEvent = note.event
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
// detach this note from every quoted note's boosts here.
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
cache.getNoteIfExists(quotedId)?.removeBoost(note)
}
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
// and drop it there, or a deleted edit would keep overlaying its message.
editedTargetIdOf(noteEvent)?.let { cache.getNoteIfExists(it)?.removeEdit(note) }
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
if (noteEvent is OtsEvent) {
noteEvent.digestEventId()?.let { cache.getNoteIfExists(it)?.removeTimestamp(note) }
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
}
noteEvent.reportedPost().forEach {
cache.getNoteIfExists(it.eventId)?.removeReport(note)
}
noteEvent.reportedAddresses().forEach {
cache.getAddressableNoteIfExists(it.address)?.removeReport(note)
}
}
if (note is AddressableNote && noteEvent is ContactCardEvent) {
cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
if (note is AddressableNote && noteEvent is StatusEvent) {
note.author?.statusStateOrNull()?.removeStatus(note)
}
if (noteEvent is PollResponseEvent) {
noteEvent.poll()?.eventId?.let {
cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
}
}
note.clearFlow()
cache.notes.remove(note.idHex)
cache.refreshDeletedNoteObservers(note)
}
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
private fun editedTargetIdOf(event: Event?): HexKey? =
when (event) {
is TextNoteModificationEvent -> event.editedNote()?.eventId
is ConcordChatEditEvent -> event.editedMessageId()
is StreamMessageEditEvent -> event.editedMessage()
else -> null
}
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val now = TimeUtils.now()
val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>()
versionsToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
addressesToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
}
}
fun pruneHiddenEvents(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved =
account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex ->
(cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
}
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
}
@@ -0,0 +1,266 @@
/*
* 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
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CancellationException
/**
* Prefix/content search over the [LocalCache] stores: users, notes, and the
* public-chat / ephemeral / live-activity channel maps. Pure read-side policy —
* no state beyond the cache reference — so ranking and filtering rules can be
* tested against a populated cache.
*/
class CacheSearch(
private val cache: LocalCache,
) {
fun findUsersStartingWith(
username: String,
forAccount: Account?,
): List<User> {
if (username.isBlank()) return emptyList()
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null) {
val user = cache.getUserIfExists(key)
if (user != null) {
return listOfNotNull(user)
}
}
val dualCase =
listOf(
DualCase(username.lowercase(), username.uppercase()),
)
val finds =
cache.users.filter { _, user: User ->
val metadata = user.metadataOrNull()
if (metadata == null) {
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
} else {
(
metadata.anyNameOrAddressContains(dualCase) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
(forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase)))
}
}
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
return finds.sortedWith(
compareBy(
{ findsFollowing[it] == false },
{ anyNameStartsWith[it] == false },
{ anyAddressStartsWith[it] == false },
{ displayNames[it] },
{ it.pubkeyHex },
),
)
}
/**
* Will return true if supplied note is one of events to be excluded from
* search results.
*/
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
(
note.event is GenericRepostEvent ||
note.event is RepostEvent ||
note.event is CommunityPostApprovalEvent ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent ||
note.event is AppSpecificDataEvent
)
/**
* Tag names whose values should not match text searches: the `client` tag
* names the app that published the event (searching for "Amethyst" would
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
* values are ids or descriptions of other events, not content of this one.
*/
private val excludedTagNamesFromSearch =
setOf(
ClientTag.TAG_NAME,
PTag.TAG_NAME,
ETag.TAG_NAME,
ATag.TAG_NAME,
AltTag.TAG_NAME,
)
fun findNotesStartingWith(
text: String,
hiddenUsers: HiddenUsersState,
): List<Note> {
checkNotInMainThread()
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
val note = cache.getNoteIfExists(key)
val noteEvent = note?.event
val newNote =
if (noteEvent is AddressableEvent) {
val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address())
if (addressableNote?.event?.id == note.idHex) {
addressableNote
} else {
note
}
} else {
note
}
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
return listOfNotNull(newNote)
}
}
return cache.notes.filter { _, note ->
if (note.event is AddressableEvent) {
return@filter false
}
if (excludeNoteEventFromSearchResults(note)) {
return@filter false
}
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
note.idHex.startsWith(text, true)
) {
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
}
if (note.event?.isContentEncoded() == false) {
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
note.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
} +
cache.addressables.filter { _, addressable ->
if (excludeNoteEventFromSearchResults(addressable)) {
return@filter false
}
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
addressable.idHex.startsWith(text, true)
) {
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
}
if (addressable.event?.isContentEncoded() == false) {
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
addressable.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
}
}
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
cache.getPublicChatChannelIfExists(key)?.let {
return listOf(it)
}
}
return cache.publicChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
if (text.isBlank()) return emptyList()
return cache.ephemeralChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
if (text.isBlank()) return emptyList()
try {
val parsed = Nip19Parser.uriToRoute(text)?.entity
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
return listOf(cache.getOrCreateLiveChannel(parsed.address()))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return cache.liveChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
}
@@ -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.model
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The minimal get-or-create surface of the event cache, used by callers (like
* `NewMessageTagger`) that resolve user/note references while composing without
* needing the full [LocalCache] API.
*/
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
fun getOrCreateAddressableNote(address: Address): AddressableNote?
}
@@ -0,0 +1,431 @@
/*
* 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
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
/**
* The sign-and-publish choke point for an [Account]: computes the relay set an
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
* relays, broadcast lists, DM inboxes) and owns every publish path - automatic,
* outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast.
*
* Feature orchestration on [Account] (and the Account*Actions classes) should
* funnel every publish through this class instead of calling the relay client
* directly.
*/
class EventBroadcaster(
private val account: Account,
) {
private fun computeRelayListForLinkedUser(user: User): Set<NormalizedRelayUrl> =
if (user == account.userProfile()) {
account.notificationRelays.flow.value
} else {
user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (
account.cache.relayHints
.hintsForKey(user.pubkeyHex)
.toSet() + user.allUsedRelays()
)
}
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
if (pubkey == account.userProfile().pubkeyHex) {
account.notificationRelays.flow.value
} else {
account.cache
.getUserIfExists(pubkey)
?.inboxRelays()
?.ifEmpty { null }
?.toSet()
?: account.cache.relayHints
.hintsForKey(pubkey)
.toSet()
}
private fun computeRelaysForChannels(event: Event): Set<NormalizedRelayUrl> = account.cache.getAnyChannel(event)?.relays() ?: emptySet()
// Personal events the user stores just for themselves — drafts, app settings, bookmark
// lists — and channel/community events that already declare their own home relays
// should not be replicated to the user's broadcasting relays. Channel/community events
// that don't define any home relays fall through to broadcast, since there's nowhere
// else for them to land.
private fun wantsBroadcastRelays(event: Event): Boolean {
if (event is DraftWrapEvent ||
event is AppSpecificDataEvent ||
event is BookmarkListEvent ||
event is OldBookmarkListEvent ||
event is LabeledBookmarkListEvent
) {
return false
}
if (event is PollEvent && event.relays().isNotEmpty()) return false
if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
return true
}
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
private fun computeRelayListToBroadcast(
event: Event,
visited: MutableSet<HexKey>,
): Set<NormalizedRelayUrl> {
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
if (!visited.add(event.id)) return emptySet()
if (event is GiftWrapEvent) {
val receiver = event.recipientPubKey()
return if (receiver != null) {
val relayList =
account.cache
.getOrCreateUser(receiver)
.dmInboxRelayList()
?.relays()
?.ifEmpty { null }
relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
} else {
emptySet()
}
}
// Seals, inner DM messages, and unsigned rumors never get broadcast
// relays: they only travel inside gift wraps.
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
return emptySet()
}
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
// everywhere
return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays
}
val relayList = mutableSetOf<NormalizedRelayUrl>()
relayList.addAll(broadcastRelays)
val author = account.cache.getUserIfExists(event.pubKey)
if (author != null) {
if (author == account.userProfile()) {
if (includeBroadcast) {
relayList.addAll(account.outboxRelays.flow.value)
} else {
// account.outboxRelays mixes in the broadcast list; for personal/channel events
// we want the user's NIP-65 / private / local outbox without it.
relayList.addAll(account.nip65RelayList.outboxFlow.value)
relayList.addAll(account.privateStorageRelayList.flow.value)
relayList.addAll(account.localRelayList.flow.value)
}
} else {
val relays =
author.outboxRelays()?.ifEmpty { null }
?: author.allUsedRelaysOrNull()
?: account.cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
}
} else {
relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey))
}
if (event is PubKeyHintProvider) {
event.pubKeyHints().forEach {
relayList.add(it.relay)
}
event.linkedPubKeys().forEach { pubkey ->
relayList.addAll(computeRelayListForLinkedUser(pubkey))
}
}
if (event is EventHintProvider) {
event.eventHints().forEach {
relayList.add(it.relay)
}
event.linkedEventIds().forEach { eventId ->
account.cache.getNoteIfExists(eventId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is AddressHintProvider) {
event.addressHints().forEach {
relayList.add(it.relay)
}
event.linkedAddressIds().forEach { addressId ->
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is PollEvent) {
relayList.addAll(event.relays())
}
if (event is MeetingSpaceEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is MeetingRoomEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is LiveActivitiesEvent) {
relayList.addAll(event.allRelayUrls())
}
relayList.addAll(computeRelaysForChannels(event))
return relayList
}
fun computeRelayListToBroadcast(note: Note): Set<NormalizedRelayUrl> {
val noteEvent = note.event
return if (noteEvent != null) {
computeRelayListToBroadcast(noteEvent)
} else {
note.relays.toSet()
}
}
suspend fun broadcast(note: Note) {
note.event?.let { noteEvent ->
val host = note.rumorHost
if (host != null) {
// Rumors are rebroadcast as their delivering envelope: the
// cached copy is content-stripped, so download it and send it.
// A just-sent note has no relays until its self-wrap echoes
// back — fall back to our own DM inbox relays. Bare seals
// (kind 13) carry no p tag, so that filter is wrap-only.
val relays =
note.relays.ifEmpty {
account.dmRelays.flow.value
.toList()
}
val filter =
if (host.kind == SealedRumorEvent.KIND) {
Filter(
kinds = listOf(host.kind),
ids = listOf(host.id),
)
} else {
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(account.pubKey)),
ids = listOf(host.id),
)
}
account.client
.fetchFirst(
filters = relays.associateWith { _ -> listOf(filter) },
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
account.client.publish(downloadedEvent, toRelays)
}
} else if (noteEvent.sig.isEmpty()) {
// Rumor with no known wrap: publishing it would disclose the
// private content to relays even though they reject the
// missing signature.
return
} else {
account.client.publish(noteEvent, computeRelayListToBroadcast(note))
}
}
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, computeRelayListToBroadcast(event))
}
fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, account.outboxRelays.flow.value)
}
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
events.forEach {
account.client.publish(it, account.outboxRelays.flow.value)
account.cache.justConsumeMyOwnEvent(it)
}
}
fun sendLiterallyEverywhere(event: Event) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
account.cache.justConsumeMyOwnEvent(event)
}
suspend fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
) {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relayList)
}
/**
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
* identity that is deliberately NOT this account's key) and publish to exactly
* [relayList]. Used by geohash location chat, where authorship inside a cell
* must not be linkable to the user's npub.
*/
suspend fun <T : Event> signWithAndSendPrivately(
template: EventTemplate<T>,
signer: NostrSigner,
relayList: Set<NormalizedRelayUrl>,
): T {
val event = signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
if (relayList.isNotEmpty()) account.client.publish(event, relayList)
return event
}
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
template: EventTemplate<T>,
relayList: (T) -> List<NormalizedRelayUrl>?,
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
val targets =
if (!relays.isNullOrEmpty()) {
relays.toSet()
} else {
computeRelayListToBroadcast(event)
}
account.chatDeliveryTracker.trackPublic(event.id, targets)
account.client.publish(event, targets)
return event
}
suspend fun <T : Event> signAndComputeBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val event = anonymousSigner.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
fun republishEventsTo(
events: List<Event>,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty() || events.isEmpty()) return
events.forEach { account.client.publish(it, relays) }
}
}
File diff suppressed because it is too large Load Diff
@@ -255,7 +255,7 @@ class AccountNappletGateways(
emptyList()
} else {
runCatching {
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
}.getOrDefault(emptyList())
}
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
@@ -279,7 +279,7 @@ class AccountNappletGateways(
}
val result = CompletableDeferred<String?>()
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
when (response) {
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
@@ -144,7 +144,7 @@ class NappletResourceFetcher(
val relays = account.homeRelays.flow.value
if (relays.isEmpty()) return null
return runCatching {
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
}.getOrDefault(emptyList())
.maxByOrNull { it.createdAt }
}
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
account.zaps.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.defaultWalletSupportsBolt12Pay()
account.zaps.defaultWalletSupportsBolt12Pay()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
return mapNotNullAsync(
items = payables,
runRequestFor = { payable: Payable ->
account.sendZapPaymentRequestFor(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
onResponse = { response ->
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
account.sendBolt12Zap(
account.zaps.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
offer = recipient.offer,
@@ -54,21 +54,21 @@ class MemoryTrimmingService(
) {
// Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are
// not currently held by the UI, so it is safe and inexpensive at any pressure level.
cache.cleanMemory()
cache.cleanObservers()
cache.pruneExpiredEvents()
cache.prunePastVersionsOfReplaceables()
cache.pruner.cleanMemory()
cache.pruner.cleanObservers()
cache.pruner.pruneExpiredEvents()
cache.pruner.prunePastVersionsOfReplaceables()
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
// messages, and unobserved reactions.
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
cache.pruner.pruneHiddenEvents(it)
cache.pruner.pruneHiddenMessages(it)
}
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
cache.pruner.pruneOldMessages()
cache.pruner.pruneRepliesAndReactions(accounts)
}
}
@@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
persistOwn = false,
)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
@@ -84,9 +84,14 @@ class UserCardsSubAssembler(
add(it, account.userProfile().pubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
if (account != null) {
add(account.relayUrl, account.pubkey)
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
}
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}
@@ -21,10 +21,9 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -258,11 +257,3 @@ class NewMessageTagger(
return null
}
}
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
fun getOrCreateAddressableNote(address: Address): AddressableNote?
}
@@ -76,7 +76,7 @@ fun ConcordInviteCard(
// Peek the bundle once per link to reveal the community name (null until it resolves).
val invite by produceState<CommunityInvite?>(initialValue = null, linkText) {
value = accountViewModel.account.peekConcordInvite(linkText)
value = accountViewModel.account.concord.peekConcordInvite(linkText)
}
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
@@ -275,8 +275,8 @@ fun CardBody(
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
// Concord moderation: only present when this account may actually act.
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) }
val canConcordBan = remember(note) { accountViewModel.account.concord.concordBanTarget(note) != null }
val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) }
val showConcordBanDialog = remember { mutableStateOf(false) }
if (showConcordBanDialog.value) {
@@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0)
wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0)
canZap.value = checkIfCanZap()
tallies.forEach {
@@ -190,7 +190,7 @@ class UserSuggestionState(
if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") {
rankPriorityFirst(
account.cache.findUsersStartingWith(prefix, account),
account.cache.search.findUsersStartingWith(prefix, account),
priorityPubkeys(),
)
}
@@ -371,7 +371,7 @@ fun noteActionSections(
// message's author (both return null unless it's a Concord message this
// account may act on). Promote/demote is instant; a ban re-keys the
// community, so it defers to the surface's confirmation dialog.
val concordAdmin = accountViewModel.account.concordAdminTarget(note)
val concordAdmin = accountViewModel.account.concord.concordAdminTarget(note)
if (concordAdmin != null) {
val isAdmin = concordAdmin.third
add(
@@ -384,7 +384,7 @@ fun noteActionSections(
},
)
}
if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) {
if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) {
add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan))
}
}
@@ -150,7 +150,7 @@ fun GoalProgressBar(
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
if (percentage > 1) percentage = 1f
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
@@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismis
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
@@ -548,7 +548,7 @@ class AccountViewModel(
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
// existing Concord reaction is a follow-up; for now this only adds one.)
if (note.inGatherers?.any { it is ConcordChannel } == true) {
launchSigner { account.reactToConcordMessage(note, reaction) }
launchSigner { account.concord.reactToConcordMessage(note, reaction) }
return
}
@@ -606,15 +606,15 @@ class AccountViewModel(
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
fun banConcordMember(note: Note) {
val (communityId, member) = account.concordBanTarget(note) ?: return
launchSigner { account.banConcordMember(communityId, member) }
val (communityId, member) = account.concord.concordBanTarget(note) ?: return
launchSigner { account.concord.banConcordMember(communityId, member) }
}
/** Toggle the Admin role on the author of a Concord channel message (owner only). */
fun toggleConcordAdmin(note: Note) {
val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return
val (communityId, member, isAdmin) = account.concord.concordAdminTarget(note) ?: return
launchSigner {
if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member)
if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member)
}
}
@@ -624,7 +624,7 @@ class AccountViewModel(
member: HexKey,
makeAdmin: Boolean,
) = launchSigner {
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member)
}
/**
@@ -640,7 +640,7 @@ class AccountViewModel(
member: HexKey,
roleIds: List<String>,
) = launchSigner {
if (!account.grantConcordRole(communityId, member, roleIds)) {
if (!account.concord.grantConcordRole(communityId, member, roleIds)) {
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
}
}
@@ -651,7 +651,7 @@ class AccountViewModel(
member: HexKey,
ban: Boolean,
) = launchSigner {
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
}
/**
@@ -663,7 +663,7 @@ class AccountViewModel(
communityId: String,
member: HexKey,
) = launchSigner {
account.refoundConcordCommunity(communityId, setOf(member))
account.concord.refoundConcordCommunity(communityId, setOf(member))
}
/**
@@ -683,7 +683,7 @@ class AccountViewModel(
else -> emptyList()
}
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
account.importConcordCommunities(pinnedRelays)
account.concord.importConcordCommunities(pinnedRelays)
}
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
@@ -691,12 +691,12 @@ class AccountViewModel(
communityId: String,
channelIdHex: String,
) = viewModelScope.launch(Dispatchers.IO) {
account.sendConcordTyping(communityId, channelIdHex)
account.concord.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
account.relayGroups.sendBuzzTyping(channel)
}
@Immutable
@@ -843,7 +843,7 @@ class AccountViewModel(
afterTimeInSeconds: Long,
): Boolean =
withContext(Dispatchers.IO) {
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
}
suspend fun calculateZapAmount(zappedNote: Note): String {
@@ -854,7 +854,7 @@ class AccountViewModel(
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val nwc = account.calculateZappedAmount(zappedNote)
val nwc = account.zaps.calculateZappedAmount(zappedNote)
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
}
} else {
@@ -866,7 +866,7 @@ class AccountViewModel(
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val newZapAmount = account.calculateZappedAmount(zappedNote)
val newZapAmount = account.zaps.calculateZappedAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
@@ -1202,7 +1202,7 @@ class AccountViewModel(
.isNotEmpty()
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay()
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
/**
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
@@ -1214,7 +1214,7 @@ class AccountViewModel(
offer: String,
amountMillisats: Long,
) = launchSigner {
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
when (response) {
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
is IErrorResponseLike ->
@@ -1668,12 +1668,12 @@ class AccountViewModel(
fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) = launchSigner { account.joinRelayGroup(channel, code) }
) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) }
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) }
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) }
/**
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
@@ -1682,7 +1682,7 @@ class AccountViewModel(
fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) = launchSigner { account.archiveRelayGroup(channel, archived) }
) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) }
/**
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
@@ -1721,7 +1721,7 @@ class AccountViewModel(
* Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
*/
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) }
/**
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
@@ -1731,7 +1731,7 @@ class AccountViewModel(
fun unhideBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) = launchSigner { account.openBuzzDm(relay, participants) }
) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) }
/**
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
@@ -1745,7 +1745,7 @@ class AccountViewModel(
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
fun leaveChannelInvite(channel: RelayGroupChannel) =
launchSigner {
account.leaveRelayGroup(channel)
account.relayGroups.leaveRelayGroup(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
@@ -1756,7 +1756,7 @@ class AccountViewModel(
* what makes leaving a community whose own relays are dead work at all — the list lives in *our*
* outbox, not in the community's relays.
*/
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
fun leaveConcordCommunity(communityId: String) = launchSigner { account.concord.leaveConcordCommunity(communityId) }
fun createRelayGroup(
relay: NormalizedRelayUrl,
@@ -1771,7 +1771,7 @@ class AccountViewModel(
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.createRelayGroup(
account.relayGroups.createRelayGroup(
relay,
groupId,
name,
@@ -1789,47 +1789,47 @@ class AccountViewModel(
fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) = launchSigner { account.createRelayGroupInvite(channel, code) }
) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) }
fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) }
fun pinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) }
fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
note: Note,
) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) }
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) }
fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) }
/** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */
fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) = launchSigner { account.addCommunityMember(relay, pubkey, role) }
) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) }
/** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */
fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) }
fun editRelayGroupMetadata(
channel: RelayGroupChannel,
@@ -1843,7 +1843,7 @@ class AccountViewModel(
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.editRelayGroupMetadata(
account.relayGroups.editRelayGroupMetadata(
channel,
name,
about,
@@ -2330,8 +2330,8 @@ class AccountViewModel(
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
)
?: return
val relays = account.marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
}
suspend fun sendMarmotGroupMediaMessage(
@@ -2356,21 +2356,21 @@ class AccountViewModel(
account.signer.pubKey,
template,
)
val relays = account.marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
}
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
suspend fun createMarmotGroup(nostrGroupId: String) {
account.createMarmotGroup(nostrGroupId)
account.marmot.createMarmotGroup(nostrGroupId)
}
suspend fun publishMarmotKeyPackage() {
account.publishMarmotKeyPackage()
account.marmot.publishMarmotKeyPackage()
}
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
@@ -2394,12 +2394,12 @@ class AccountViewModel(
}
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.leaveMarmotGroup(nostrGroupId, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
}
suspend fun resetMarmotState() {
account.resetMarmotState()
account.marmot.resetMarmotState()
}
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
@@ -2407,30 +2407,30 @@ class AccountViewModel(
suspend fun addMarmotGroupMember(
nostrGroupId: String,
memberPubKey: String,
): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
suspend fun removeMarmotGroupMember(
nostrGroupId: String,
targetLeafIndex: Int,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
}
suspend fun grantMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = account.marmotGroupRelays(nostrGroupId)
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
/**
@@ -2486,8 +2486,8 @@ class AccountViewModel(
imageUploadKey = icon.upload.imageUploadKey,
)
}
val relays = account.marmotGroupRelays(nostrGroupId)
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
}
override fun onCleared() {
@@ -2745,7 +2745,7 @@ class AccountViewModel(
onSent: () -> Unit = {},
onResponse: (Response?) -> Unit,
) = launchSigner {
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
onSent()
}
@@ -2801,7 +2801,7 @@ class AccountViewModel(
if (effectiveZapType != LnZapEvent.ZapType.NONZAP) {
// NIP-57 Appendix F: include amount + lnurl so the receipt can be validated.
val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32)
account.createZapRequestFor(
account.zaps.createZapRequestFor(
user = user,
message = message,
zapType = effectiveZapType,
@@ -308,7 +308,7 @@ class GiftWrapEventHandler(
// already folded the state they carried, so drop the durable wrap note now
// to keep LocalCache from growing without bound.
if (event is EphemeralGiftWrapEvent) {
cache.unlinkAndRemove(listOf(eventNote))
cache.pruner.unlinkAndRemove(listOf(eventNote))
}
return
}
@@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow(
// Rotate KeyPackages if needed
if (result.needsKeyPackageRotation) {
account.publishMarmotKeyPackages()
account.marmot.publishMarmotKeyPackages()
}
// Fire the "You've been added to <group>" notification. Welcomes
@@ -436,7 +436,10 @@ private fun AgentKeyPicker(
delay(150)
suggestions =
withContext(Dispatchers.IO) {
LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8)
LocalCache.search
.findUsersStartingWith(query.trim(), accountViewModel.account)
.map { it.pubkeyHex }
.take(8)
}
}
@@ -153,7 +153,7 @@ class AgentConsoleViewModel : ViewModel() {
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() {
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, channelId ->
if (requireApproval) {
account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
} else {
account.fileBuzzJob(relay, channelId, text) != null
account.relayGroups.fileBuzzJob(relay, channelId, text) != null
}
}
fun approve(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null }
) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null }
fun deny(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null }
) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null }
fun upvote(
jobId: HexKey,
jobAuthor: HexKey?,
) = act({}) { account, relay, channelId ->
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
true
}
fun cancel(jobId: HexKey) =
act({}) { account, relay, channelId ->
account.cancelBuzzJob(relay, channelId, jobId)
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
true
}
@@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery(
// rather than returning empty.
account.client.fetchAllWithHooks(
filters = relays.associateWith { discoveryFilters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
@@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata(
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -211,7 +211,7 @@ private fun DmRowCard(
addMemberOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.addBuzzDmMember(channel, hex)
accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex)
}
},
)
@@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() {
)
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
@@ -215,7 +215,7 @@ class BuzzDmListViewModel : ViewModel() {
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/**
@@ -254,7 +254,7 @@ class BuzzDmListViewModel : ViewModel() {
fun removeFromMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
}
}
@@ -268,7 +268,7 @@ class BuzzDmListViewModel : ViewModel() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val me = account.userProfile().pubkeyHex
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
// row returns even if this screen's socket missed the snapshot.
refresh()
@@ -118,7 +118,7 @@ class BuzzNewDmViewModel : ViewModel() {
val me = account.userProfile().pubkeyHex
val already = _participants.value.toSet()
val ranked =
LocalCache
LocalCache.search
.findUsersStartingWith(text.trim(), account)
.asSequence()
.map { it.pubkeyHex }
@@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() {
_status.value = Status.Sending
viewModelScope.launch(Dispatchers.IO) {
try {
val channelId = account.openBuzzDm(relay, others)
val channelId = account.relayGroups.openBuzzDm(relay, others)
val groupId = channelId?.let { GroupId(it, relay) }
withContext(Dispatchers.Main) { onOpened(groupId) }
} catch (e: CancellationException) {
@@ -149,7 +149,7 @@ class BuzzRelayImportViewModel : ViewModel() {
),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
@@ -172,7 +172,7 @@ class BuzzRelayImportViewModel : ViewModel() {
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
),
),
timeoutMs = 8_000,
idleTimeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
@@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() {
fun file(request: String) =
act { account, relay, channelId ->
account.fileBuzzJob(relay, channelId, request)
account.relayGroups.fileBuzzJob(relay, channelId, request)
}
fun upvote(
jobId: String,
jobAuthor: String?,
) = act { account, relay, channelId ->
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
}
fun cancel(jobId: String) =
act { account, relay, channelId ->
account.cancelBuzzJob(relay, channelId, jobId)
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
}
private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) {
@@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() {
task: String,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, channelId ->
account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
}
fun approve(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ ->
account.approveBuzzWorkflowRun(relay, runId) != null
account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null
}
fun deny(
runId: HexKey,
onResult: (Boolean) -> Unit,
) = act(onResult) { account, relay, _ ->
account.denyBuzzWorkflowRun(relay, runId) != null
account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null
}
/**
@@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() {
return
}
viewModelScope.launch(Dispatchers.IO) {
val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml)
val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml)
withContext(Dispatchers.Main) { onResult(newId) }
}
}
@@ -190,9 +190,9 @@ fun ConcordChannelListScreen(
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.createConcordChannel(communityId, newName)
account.concord.createConcordChannel(communityId, newName)
} else {
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
}
},
@@ -208,7 +208,7 @@ fun ConcordChannelListScreen(
confirmButton = {
TextButton(onClick = {
channelToDelete = null
scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) }
scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) }
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm))
}
@@ -254,7 +254,7 @@ fun ConcordChannelListScreen(
minting = true
scope.launch {
try {
inviteLink = account.mintConcordInvite(communityId)
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
@@ -596,7 +596,7 @@ private fun ConcordFileUploadDialog(
onceUploaded = { uploads ->
val imetas = uploads.mapNotNull { it.toConcordImeta() }
if (imetas.isNotEmpty()) {
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
accountViewModel.account.concord.sendConcordChannelImageMessage(community, channel, "", imetas)
}
onUpload()
},
@@ -120,7 +120,7 @@ fun ConcordCreateScreen(
scope.launch {
val communityId =
try {
accountViewModel.account.createConcordCommunity(
accountViewModel.account.concord.createConcordCommunity(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
@@ -163,7 +163,7 @@ fun ConcordEditScreen(
scope.launch {
val ok =
try {
account.editConcordMetadata(
account.concord.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
@@ -116,7 +116,7 @@ fun ConcordInviteScreen(
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
state =
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
when (val result = accountViewModel.account.concord.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
@@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader(
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return@LaunchedEffect
account.warmConcordChannelPreviews(listOf(entry))
account.concord.warmConcordChannelPreviews(listOf(entry))
}
}
@@ -72,6 +72,6 @@ fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) {
LaunchedEffect(communities, revision) {
// Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain.
delay(1500)
account.warmConcordChannelPreviews(communities)
account.concord.warmConcordChannelPreviews(communities)
}
}
@@ -150,7 +150,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
// (1) Load + membership/epoch change: one complete sweep of the whole set.
LaunchedEffect(sig) {
if (communities.isNotEmpty()) account.syncConcordControlPlanes(communities)
if (communities.isNotEmpty()) account.concord.syncConcordControlPlanes(communities)
}
// (2) Reconnect: re-sweep when a relay of ours transitions disconnected → connected.
@@ -174,7 +174,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
val now = TimeUtils.nowMillis()
if (now - lastSweep < RECONNECT_RESWEEP_MIN_INTERVAL_MS) return@collect
lastSweep = now
account.syncConcordControlPlanes(liveCommunities)
account.concord.syncConcordControlPlanes(liveCommunities)
}
}
}
@@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() {
val editing = editingMessage.value
if (editing != null) {
account.editConcordChannelMessage(editing, text)
account.concord.editConcordChannelMessage(editing, text)
editingMessage.value = null
} else {
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
}
message.clearText()
@@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
val geohashes = parseGeohashes()
val existing = channel
if (existing == null) {
account.createRelayGroup(
account.relayGroups.createRelayGroup(
relay = relay!!,
groupId = groupId,
name = name,
@@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null,
)
} else {
account.editRelayGroupMetadata(
account.relayGroups.editRelayGroupMetadata(
channel = existing,
name = name,
about = about,
@@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel :
val pk = user.pubkeyHex
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
try {
accountViewModel.account.putRelayGroupUser(channel, pk, emptyList())
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")
@@ -509,13 +509,13 @@ private fun SendPaymentLoaded(
if (onchainAddressTarget != null) {
// Pays the profile's announced bitcoin address directly —
// a plain wallet send, no NIP-BC receipt exists for it.
accountViewModel.account.sendOnchainToAddress(
accountViewModel.account.zaps.sendOnchainToAddress(
recipientAddress = onchainAddressTarget,
amountSats = amount,
feeRateSatPerVByte = feeRate,
)
} else {
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = user.pubkeyHex,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -502,7 +502,7 @@ class EventSync(
try {
client.fetchAllPagesFromPool(
filters = perRelayFilters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
maxConcurrentRelays = MAX_CONCURRENT_RELAYS,
onNewPage = { until, sourceRelay ->
_liveActivity.value.runningRelays[sourceRelay]
@@ -268,7 +268,7 @@ class SearchBarViewModel(
}
if (term.isBlank()) return@combine emptyList<User>()
val users = LocalCache.findUsersStartingWith(term, account)
val users = LocalCache.search.findUsersStartingWith(term, account)
if (follows != null) users.filter { it.pubkeyHex in follows } else users
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -285,7 +285,7 @@ class SearchBarViewModel(
) { term, _, currentScope, order, follows ->
if (currentScope == SearchScope.PEOPLE) return@combine emptyList()
val raw = LocalCache.findNotesStartingWith(term, account.hiddenUsers)
val raw = LocalCache.search.findNotesStartingWith(term, account.hiddenUsers)
val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw
when (order) {
@@ -317,7 +317,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findPublicChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -327,7 +327,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findEphemeralChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -337,7 +337,7 @@ class SearchBarViewModel(
invalidations,
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term)
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findLiveActivityChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -425,7 +425,7 @@ fun OnchainZapSendDialog(
)
return@launch
}
accountViewModel.account.sendOnchainZapWithSplits(
accountViewModel.account.zaps.sendOnchainZapWithSplits(
recipients = shares,
feeRateSatPerVByte = feeRate,
comment = comment.trim(),
@@ -433,7 +433,7 @@ fun OnchainZapSendDialog(
)
} else {
val recipient = resolvedRecipient ?: return@launch
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = recipient,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -347,7 +347,7 @@ class ReloadMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -209,7 +209,7 @@ class TopUpMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -222,7 +222,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
delay(NWC_TIMEOUT_MS)
val requestId = requestIdProvider()
val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0
val spoofs = requestId?.let { account?.zaps?.nwcSpoofAttempts(it) ?: 0 } ?: 0
_error.value =
if (spoofs > 0) {
"Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " +
@@ -230,7 +230,7 @@ class WalletViewModel : ViewModel() {
} else {
"Wallet request timed out"
}
requestId?.let { account?.cleanupNwcRequest(it) }
requestId?.let { account?.zaps?.cleanupNwcRequest(it) }
onTimeout()
}
@@ -406,7 +406,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) }
try {
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
when (response) {
is GetBalanceSuccessResponse -> {
val sats = (response.result?.balance ?: 0L) / 1000L
@@ -437,7 +437,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) }
@@ -479,7 +479,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) {
is GetBalanceSuccessResponse -> {
@@ -512,7 +512,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
_walletAlias.value = response.result?.alias
@@ -538,7 +538,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -591,7 +591,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -638,7 +638,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_sendState.value = SendState.Sending
try {
acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
when (response) {
is PayInvoiceSuccessResponse -> {
_sendState.value = SendState.Success(response.result?.preimage)
@@ -676,7 +676,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_receiveState.value = ReceiveState.Creating
try {
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
MakeInvoiceMethod.create(
amount = amountSats * 1000L,
@@ -199,7 +199,7 @@ class CashuWalletDiscovery(
fetchAllPages(
relay = relay,
filters = filters,
timeoutMs = RELAY_TIMEOUT_MS,
idleTimeoutMs = RELAY_TIMEOUT_MS,
onEvent = onEvent,
)
}.onFailure {
@@ -332,7 +332,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 +1233,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 +1942,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
@@ -2024,14 +2024,105 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
</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 transmiter</item>
<item quantity="few">%1$s \u00b7 %2$d transmiterów</item>
<item quantity="many">%1$s \u00b7 %2$d transmiterów</item>
<item quantity="other">%1$s \u00b7 %2$d transmitery</item>
</plurals>
<string name="relay_purpose_browsing">Przeglądanie</string>
<string name="relay_purpose_media">Multimedia</string>
<string name="relay_purpose_tags">Hashtagi</string>
<string name="relay_purpose_topics">Tematy</string>
<string name="relay_purpose_thread">Rozmowa</string>
<string name="relay_purpose_search">Szukaj</string>
<string name="relay_purpose_referenced">Wyszukiwanie brakujących wydarzeń</string>
<string name="relay_purpose_engagement">Obserwowanie wydarzeń</string>
<string name="relay_explain_referenced">Pobiera wydarzenia na podstawie identyfikatora, do których odnosi się jakiś element na ekranie, ale których jeszcze nie masz — cytat, wiadomość nadrzędna odpowiedzi, początek wątku.</string>
<string name="relay_explain_engagement">Monitoruje aktualnie wyświetlane wydarzenia pod kątem nowych odpowiedzi, reakcji, udostępnień, zapsów i zgłoszeń, dzięki czemu liczby są aktualizowane na bieżąco podczas czytania.</string>
<string name="relay_purpose_add_ons">Dodatki</string>
<string name="relay_purpose_relay_info">Informacje o transmiterze</string>
<string name="relay_purpose_other">Inne</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">Wyszukiwarka listy transmiterów</string>
<string name="relay_purpose_observing_profiles">Obserwowanie profili</string>
<string name="relay_purpose_your_account">Dane konta</string>
<string name="relay_purpose_home_feed">Główny kanał</string>
<string name="relay_purpose_relay_groups">Grupy Transmiterów</string>
<plurals name="active_subs_groups">
<item quantity="one">%1$d grupa</item>
<item quantity="few">%1$d grup</item>
<item quantity="many">%1$d grup</item>
<item quantity="other">%1$d grupy</item>
</plurals>
<string name="relay_purpose_ephemeral_chats">Czaty efemeryczne</string>
<string name="relay_purpose_geohash_chats">Czaty z funkcją lokalizacji</string>
<string name="relay_purpose_live_chat">Czat podczas transmisji na żywo</string>
<string name="relay_explain_relay_groups">Grupy NIP-29, do których dołączyłeś. Każda grupa działa na jednym transmiterze, więc aplikacja łączy się z każdym transmiterem, na którym znajduje się jakaś Twoja grupa.</string>
<string name="relay_explain_ephemeral_chats">Czaty, w których nie jest zapisywana historia — wiadomości są dostępne tylko wtedy, gdy użytkownik jest podłączony, więc aby otrzymywać jakiekolwiek wiadomości, należy pozostać subskrybentem.</string>
<string name="relay_explain_geohash_chats">Pokoje powiązane z lokalizacją dla obszarów, które obserwujesz, wymagane od transmiterów, które je obsługują.</string>
<string name="relay_explain_live_chat">Czatuj i zap cele powiązane z transmisjami na żywo, które masz otwarte lub które obserwujesz.</string>
<string name="relay_purpose_dm_inbox">Skrzynka odbiorcza DM</string>
<string name="relay_purpose_your_wallet">Portfel</string>
<string name="relay_purpose_nutzap_inbox">Skrzynka odbiorcza Nutzap</string>
<string name="relay_purpose_mint_directory">Katalog Mint</string>
<string name="relay_purpose_nwc">Podłącz portfel</string>
<string name="relay_purpose_community_chats">Czaty społecznościowe</string>
<string name="relay_purpose_community_feeds">Kanały społecznościowe</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">Twoje transmitery skrzynek odbiorczych oraz niewielka, zmieniająca się próbka transmiterów, na których publikują osoby, które obserwujesz na wypadek, gdyby wzmianka została dostarczona gdzie indziej.</string>
<string name="relay_explain_direct_messages">Transmitery skrzynek odbiorczych DM, gdzie dostarczane są zawijane wiadomości DM.</string>
<string name="relay_explain_public_chats">Transmiter domowy każdego czatu, który masz otwarty lub do którego dołączyłeś.</string>
<string name="relay_explain_community_chats">Transmitery, na których każda społeczność publikuje swoje plany.</string>
<string name="relay_explain_encrypted_groups">Wiadomości grupowe i pakiety kluczy na transmiterach poszczególnych grup.</string>
<string name="relay_explain_live_rooms">Transmitery pokoju, dopóki jest otwarty.</string>
<string name="relay_explain_account_data">Twój profil, ustawienia i wersje robocze na Twoich domowych transmiterach.</string>
<string name="relay_explain_profiles">Profile osób znajdujących się obecnie na ekranie.</string>
<string name="relay_explain_relay_lists">Ustala, na jakich transmiterach poszczególne osoby publikują swoje treści, dzięki czemu ich posty mogą być pobierane z właściwego miejsca.</string>
<string name="relay_explain_follows">Listy obserwacji, używane do budowania Twojego kanału i Twojej sieci WoT.</string>
<string name="relay_explain_moderation">Raporty sporządzone przez obserwowanych przez Ciebie użytkowników na temat profili wyświetlanych obecnie na ekranie, uzyskane z poszczególnych transmiterów, na których publikują ci użytkownicy.</string>
<string name="relay_purpose_reports_from_follows">Zgłoszenia od obserwujących</string>
<string name="relay_explain_wallet">Własne zdarzenia z portfela, odczytane z transmiterów, na których zostały opublikowane.</string>
<string name="relay_explain_nutzap_inbox">Monitoruje transmitery Nutzap, a także transmitery skrzynek odbiorczych i wiadomości prywatnych, dzięki czemu żadna płatność nie umknie.</string>
<string name="relay_explain_mint_directory">Sprawdza na różnych transmiterach, na których istnieją serwisy typu „mint”, oraz które z nich są polecane przez użytkowników.</string>
<string name="relay_explain_nwc">Powiadomienia z podłączonego portfela.</string>
<string name="active_subs_title">Aktywne subskrypcje transmitera</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 filtrów</item>
<item quantity="many">%1$d filtrów</item>
<item quantity="other">%1$d filtry</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d transmiter</item>
<item quantity="few">%1$d transmiterów</item>
<item quantity="many">%1$d/ transmiterów</item>
<item quantity="other">%1$d transmitery</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr nie został jeszcze przypisany</item>
<item quantity="few">%1$d filtrów nie zostało jeszcze przypisanych</item>
<item quantity="many">%1$d filtrów nie zostało jeszcze przypisanych</item>
<item quantity="other">%1$d filtry nie zostały jeszcze przypisane</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Nie przypisano do konta</string>
<string name="active_subs_no_entity">Wszystkie</string>
<string name="active_subs_scope_global">Wszyscy</string>
<string name="active_subs_scope_follows">Osoby, które obserwujesz</string>
<string name="active_subs_scope_authors">Wybrana lista osób</string>
<string name="active_subs_scope_muted">Uciszone osoby</string>
<string name="active_subs_scope_all_communities">Twoje społeczności</string>
<string name="active_subs_scope_algo">Ulubiony kanał algorytmów</string>
<string name="active_subs_share">%1$d%% z wszystkich</string>
<string name="active_subs_search_keywords">subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka</string>
<string name="relay_explain_home">Posty osób, które obserwujesz, są pobierane z transmiterów, na których każda z nich publikuje swoje treści.</string>
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
<string name="always_on_notif_setting_description">Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości.</string>
@@ -153,12 +153,12 @@ class AmethystAppFunctions {
// Quartz's INostrClient.fetchAll handles subscribe → drain on
// EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort
// newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs)
// newest-first. Wraps everything in a withTimeoutOrNull(idleTimeoutMs)
// so a slow relay can't stall the dispatch.
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val candidates =
@@ -397,7 +397,7 @@ class AmethystAppFunctions {
return Amethyst.instance.client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? TextNoteEvent }
.take(limit)
}
@@ -449,7 +449,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -521,7 +521,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.filter { it.pubKey == pubkey }
.maxByOrNull { it.createdAt }
@@ -569,7 +569,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -643,7 +643,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -686,7 +686,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -733,7 +733,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -785,7 +785,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val receipts = events.mapNotNull { it as? LnZapEvent }
@@ -880,7 +880,7 @@ class AmethystAppFunctions {
client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? GiftWrapEvent }
val seen = HashSet<HexKey>()
@@ -947,7 +947,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -991,7 +991,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val streams =
@@ -1467,7 +1467,7 @@ class AmethystAppFunctions {
}
val result =
account.sendOnchainZap(
account.zaps.sendOnchainZap(
recipientPubKey = recipientPub,
amountSats = sats,
feeRateSatPerVByte = feeRateSatPerVByte,
@@ -1691,7 +1691,7 @@ class AmethystAppFunctions {
return client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.maxByOrNull { it.createdAt }
?.contactMetaData()
@@ -1751,7 +1751,7 @@ class AmethystAppFunctions {
val deferred = CompletableDeferred<Response?>()
// sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies
// (success, error, or NwcError). On timeout we discard the late response.
account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
if (!deferred.isCompleted) deferred.complete(response)
}
val response =
@@ -2027,7 +2027,7 @@ class AmethystAppFunctions {
val events =
client.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Dao
import com.vitorpamplona.amethyst.model.LocalCache.getOrCreateAddressableNoteInternal
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
@@ -39,9 +39,13 @@ class HexBenchmark {
@get:Rule val r = BenchmarkRule()
val hex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf"
val hex128 = hex + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
val bytes =
fr.acinq.secp256k1.Hex
.decode(hex)
val bytes64 =
fr.acinq.secp256k1.Hex
.decode(hex128)
@Test
fun hexIsEqual() {
@@ -103,4 +107,40 @@ class HexBenchmark {
fun isHex64() {
r.measureRepeated { Hex.isHex64(hex) }
}
@Test
fun hexDecode64() {
r.measureRepeated { Hex.decode64(hex) }
}
@Test
fun hexDecode64OrNull() {
r.measureRepeated { Hex.decode64OrNull(hex) }
}
@Test
fun hexEncode64() {
r.measureRepeated { Hex.encode64(bytes) }
}
@Test
fun hexDecode128() {
r.measureRepeated { Hex.decode128(hex128) }
}
@Test
fun hexEncode128() {
r.measureRepeated { Hex.encode128(bytes64) }
}
/** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */
@Test
fun hexIsHex64ThenDecode() {
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null }
}
@Test
fun hexToLong256() {
r.measureRepeated { Hex.toLong256(hex) }
}
}
@@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@@ -210,8 +211,12 @@ class Context(
* (auth-required / rate-limited / restricted / ), and NIP-42 AUTH
* challenges so a failed REQ can be explained instead of guessed at.
* Registered on [client] for the life of this run.
*
* Quartz's [RelayObserver], which also measures the connect/read/write
* round trips behind that feedback and is what a [RelayMonitor] publishes
* as NIP-66. One listener now answers both questions.
*/
val relayDiagnostics: RelayDiagnostics = RelayDiagnostics().also { client.addConnectionListener(it) }
val relayDiagnostics: RelayObserver = RelayObserver().also { client.addConnectionListener(it) }
/**
* Adaptive per-relay concurrent-subscription cap. Starts every relay
@@ -542,7 +547,7 @@ class Context(
if (needAuth.isEmpty()) break
// A cheap REQ whose only purpose is to force the AUTH handshake to completion.
val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1))
drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true)
drain(needAuth.associateWith { warmFilter }, idleTimeoutMs = 8_000, pendingOnAuthRequired = true)
results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs)
attempt++
}
@@ -560,7 +565,7 @@ class Context(
* When [deadOut] is provided, every relay that reported it could not be
* connected to (`onCannotConnect`) is added to it, so callers can prune
* proven-dead relays from future routing instead of paying the full
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported
* [idleTimeoutMs] on them again. Slow-but-connected relays are NOT reported
* only hard connect failures, so a temporarily-busy relay isn't discarded.
*
* With [pendingOnAuthRequired], a relay that refuses the REQ with an
@@ -568,24 +573,24 @@ class Context(
* NIP-42 responder answers the challenge and the client re-fires this same
* subscription (`syncFilters`), so the post-auth events are collected instead of
* returning empty. If auth never satisfies it, the relay simply falls through to
* the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* the [idleTimeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
* only to a connection authenticated as the derived stream key.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
idleTimeoutMs: Long = 8_000,
diagnoseSlow: Boolean = false,
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
pendingOnAuthRequired: Boolean = false,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
pendingOnAuthRequired = pendingOnAuthRequired,
deadOut = deadOut,
onTimeout =
if (diagnoseSlow) {
{ stalled, doneReasons, collected -> logSlowDrain(timeoutMs, stalled, doneReasons, collected) }
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
} else {
null
},
@@ -599,7 +604,7 @@ class Context(
* "relay is slow" and "we never connected" are easy to tell apart.
*/
private fun logSlowDrain(
timeoutMs: Long,
idleTimeoutMs: Long,
stalled: Set<NormalizedRelayUrl>,
doneReasons: Map<NormalizedRelayUrl, String>,
collected: List<Pair<NormalizedRelayUrl, Event>>,
@@ -610,7 +615,7 @@ class Context(
val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" }
val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" }
System.err.println(
"[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
"[drain] timeout ${idleTimeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
(if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") +
(if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""),
)
@@ -636,12 +641,12 @@ class Context(
*/
suspend fun drainAllPages(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 30_000,
idleTimeoutMs: Long = 30_000,
maxConcurrentRelays: Int = 8,
): List<Pair<NormalizedRelayUrl, Event>> =
client.fetchAllPagesFromPoolWithHooks(
filters = filters,
timeoutMs = timeoutMs,
idleTimeoutMs = idleTimeoutMs,
maxConcurrentRelays = maxConcurrentRelays,
) { _, event -> verifyAndStore(event) }
@@ -1,96 +0,0 @@
/*
* 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.cli
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Client-wide tally of the relay feedback the crawl would otherwise never see:
* `NOTICE` frames, `CLOSED` reasons (`auth-required` / `rate-limited` /
* `restricted` / ), and NIP-42 `AUTH` challenges. Registered as a
* [RelayConnectionListener] on the shared client, so every incoming message
* during a run is counted and a REQ failure can be explained instead of
* guessed at.
*
* Callbacks fire on the per-relay socket threads, so all state is concurrent.
*/
class RelayDiagnostics : RelayConnectionListener {
private val closedByReason = ConcurrentHashMap<String, AtomicLong>()
private val noticeSamples = ConcurrentHashMap<String, AtomicLong>()
private val authChallenges = AtomicLong()
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
// CLOSED reasons follow the NIP-01 machine-readable "word: text"
// convention, so the prefix categorises the failure.
is ClosedMessage -> bump(closedByReason, prefix(msg.message))
// NOTICE is free-form; keep the (truncated) text so recurring
// relay complaints ("too many concurrent REQs", …) are visible.
is NoticeMessage -> if (noticeSamples.size < MAX_DISTINCT_NOTICES) bump(noticeSamples, msg.message.trim().take(80))
is AuthMessage -> authChallenges.incrementAndGet()
else -> Unit
}
}
private fun bump(
map: ConcurrentHashMap<String, AtomicLong>,
key: String,
) {
map.getOrPut(key) { AtomicLong() }.incrementAndGet()
}
/** The NIP-01 machine-readable prefix (`word` before `:`), or `other`. */
private fun prefix(message: String): String {
val head = message.substringBefore(':').trim().lowercase()
return head.ifEmpty { "other" }.take(24)
}
fun hadFeedback(): Boolean = authChallenges.get() > 0 || closedByReason.isNotEmpty() || noticeSamples.isNotEmpty()
/** JSON-friendly summary for the command output. */
fun snapshot(): Map<String, Any?> =
mapOf(
"auth_challenges" to authChallenges.get(),
"closed_by_reason" to closedByReason.entries.associate { it.key to it.value.get() }.toSortedMap(),
"notices" to noticeSamples.values.sumOf { it.get() },
"notice_top" to
noticeSamples.entries
.sortedByDescending { it.value.get() }
.take(TOP_NOTICES)
.map { "${it.key} (${it.value.get()})" },
)
companion object {
private const val MAX_DISTINCT_NOTICES = 500
private const val TOP_NOTICES = 8
}
}
@@ -112,7 +112,7 @@ object AwaitCommands {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
idleTimeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Output.emit(
@@ -357,7 +357,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
val raw = ctx.drain(filters, idleTimeoutMs = timeoutSecs * 1000)
val messages = decryptDms(ctx, raw, peerHex)
val out =
@@ -415,7 +415,7 @@ object DmCommands {
.groupBy { it.relay }
.mapValues { (_, v) -> v.map { it.filter } }
val raw = ctx.drain(filters, timeoutMs = 3_000)
val raw = ctx.drain(filters, idleTimeoutMs = 3_000)
val messages = decryptDms(ctx, raw, peerHex)
// Match against the text body for kind:14 and against the URL
// for kind:15 — both are exposed as `searchText` so callers
@@ -103,7 +103,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
@@ -160,7 +160,7 @@ object GitReadCommands {
relays.associateWith {
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
},
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
.distinctBy { it.id }
@@ -208,7 +208,7 @@ object GitReadCommands {
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
timeoutMs = READ_TIMEOUT_MS,
idleTimeoutMs = READ_TIMEOUT_MS,
).map { it.second }
}.filterIsInstance<GitStatusEvent>()
.distinctBy { it.id }
@@ -97,7 +97,7 @@ object GroupAddMemberCommand {
client = ctx.client,
targetPubKey = pub,
relays = kpRelays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (kpEvent == null) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
@@ -99,7 +99,7 @@ object KeyPackageCommands {
client = ctx.client,
targetPubKey = targetHex,
relays = relays,
timeoutMs = 10_000,
idleTimeoutMs = 10_000,
)
if (event == null) {
return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
@@ -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,
@@ -129,7 +129,7 @@ object GrapeRankCrawl {
/** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */
internal fun reportRelayFeedback(ctx: Context) {
if (ctx.relayDiagnostics.hadFeedback()) {
System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.snapshot()}")
System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.summary()}")
}
if (ctx.relayLimiter.hadThrottling()) {
System.err.println("[graperank] relay throttling: ${ctx.relayLimiter.snapshot()}")
@@ -204,7 +204,7 @@ object GrapeRankCrawl {
"observer" to observer,
"crawl_rounds" to stats.rounds,
"relays_contacted" to stats.relaysContacted,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null,
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
"max_hop_reached" to (stats.hopHistogram.keys.maxOrNull() ?: 0),
"users_by_hop" to stats.hopHistogram.mapKeys { it.key.toString() },
@@ -287,7 +287,7 @@ object GrapeRankCrawl {
// Default null → pull EVERY follower each relay holds; --max
// N caps the total per relay for a quick spot check.
maxPerRelay = args.flag("max")?.toIntOrNull(),
timeoutMs = args.timeoutMs(15),
idleTimeoutMs = args.timeoutMs(15),
maxConcurrentRelays = relayConcurrency,
insertBatchSize = args.intFlag(FLAG_INSERT_BATCH, INSERT_BATCH_DEFAULT),
),
@@ -191,7 +191,7 @@ object GrapeRankScore {
"observer" to observer,
"crawl_rounds" to (crawlStats?.rounds ?: 0),
"relays_contacted" to (crawlStats?.relaysContacted ?: 0),
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null,
"relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null,
"relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null,
"max_hop_reached" to (hopHistogram.keys.maxOrNull() ?: 0),
"users_by_hop" to hopHistogram.mapKeys { it.key.toString() },
@@ -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,
@@ -147,7 +147,7 @@ class DmInboxRelayResolver(
if (writeRelays.isNotEmpty()) {
relays =
RecipientRelayFetcher
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L)
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, idleTimeoutMs = 5_000L)
.dmInbox
}
}
@@ -53,8 +53,20 @@ class PreferencesNotificationSettings(
override fun setEnabled(v: Boolean) {
_enabled.value = v
prefs.putBoolean(KEY_ENABLED, v)
// Track explicit user intent so the Settings screen can auto-enable
// on next visit for users who never touched the switch, while
// respecting users who deliberately turned it off. Only false
// → "explicit disable"; going from off to on clears the flag so
// subsequent auto-enable heuristics work normally.
if (v) {
prefs.remove(KEY_EXPLICITLY_DISABLED)
} else {
prefs.putBoolean(KEY_EXPLICITLY_DISABLED, true)
}
}
override fun wasExplicitlyDisabled(): Boolean = prefs.getBoolean(KEY_EXPLICITLY_DISABLED, false)
override fun setKindToggle(
kind: NotifKind,
v: Boolean,
@@ -92,6 +104,7 @@ class PreferencesNotificationSettings(
companion object {
const val NODE = "com/vitorpamplona/amethyst/notifications"
private const val KEY_ENABLED = "enabled"
private const val KEY_EXPLICITLY_DISABLED = "explicitly_disabled"
private const val KEY_DND_UNTIL = "dnd_until"
private const val KEY_PREVIEW = "preview_in_toast"
@@ -0,0 +1,86 @@
/*
* 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.moderation.notifications
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.UUID
import java.util.prefs.Preferences
/**
* Pins the semantics of the new [NotificationSettings.wasExplicitlyDisabled]
* flag added to unblock the "Enable OS notifications button doesn't work on
* desktop" bug's second failure mode.
*
* The Settings screen auto-enables the master notifications switch when it
* detects that the OS permission is fine and the switch is off. That's the
* common path for users who click the "Enable OS notifications" button and
* expect it to fully take effect (button label promise). But it MUST NOT
* override users who deliberately turned notifications off. The
* [wasExplicitlyDisabled] flag is how we distinguish the two.
*/
class PreferencesNotificationSettingsExplicitDisableTest {
private fun freshNode(): Preferences {
// Use a UUID-scoped node so tests never share state and never
// pollute the real user prefs on the machine running CI/dev builds.
return Preferences.userRoot().node("amethyst-test-" + UUID.randomUUID())
}
@Test
fun `fresh install defaults to not-explicitly-disabled`() {
val settings = PreferencesNotificationSettings(freshNode())
assertFalse(
"First launch must not look like a deliberate opt-out; otherwise auto-enable stays off forever",
settings.wasExplicitlyDisabled(),
)
}
@Test
fun `turning off marks explicitly disabled`() {
val prefs = freshNode()
val settings = PreferencesNotificationSettings(prefs)
settings.setEnabled(false)
assertTrue(settings.wasExplicitlyDisabled())
}
@Test
fun `turning on clears the explicit-disable flag`() {
val prefs = freshNode()
val settings = PreferencesNotificationSettings(prefs)
settings.setEnabled(false)
assertTrue(settings.wasExplicitlyDisabled())
settings.setEnabled(true)
assertFalse(
"Toggling back on must clear the flag so subsequent OFF->auto-enable cycles work",
settings.wasExplicitlyDisabled(),
)
}
@Test
fun `flag persists across new instances on the same prefs node`() {
val prefs = freshNode()
PreferencesNotificationSettings(prefs).setEnabled(false)
// Second instance opens the same node \u2014 flag must survive process restart.
val reopened = PreferencesNotificationSettings(prefs)
assertTrue(reopened.wasExplicitlyDisabled())
}
}
@@ -108,7 +108,9 @@ class ExplainedFilterTest {
*/
@Test
fun `copy preserves the purpose`() {
val advanced = explained().copy(since = 1_785_379_272)
// Typed as the base Filter so the is-check below stays a runtime assertion — with the
// override's covariant return type inferred, the compiler would prove it true statically.
val advanced: Filter = explained().copy(since = 1_785_379_272)
assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter)
assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull())
+16 -4
View File
@@ -246,18 +246,30 @@ compose.desktop {
// - amethyst.png 512x512 icon
//
// appimagetool binary is fetched by CI (SHA-verified) into
// desktopApp/packaging/appimage/ as appimagetool-x86_64.AppImage.
// desktopApp/packaging/appimage/ as appimagetool-<arch>.AppImage.
// The arch is selected at task-execution time from the host JVM's os.arch, so
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
// BUILDING.md documents local-dev fetch.
val createReleaseAppImage by tasks.registering(Exec::class) {
group = "compose desktop"
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
dependsOn("createReleaseDistributable")
// AppImage's ARCH env accepts the Linux kernel arch names: x86_64 / aarch64
// / armhf / i686. jpackage produces host-native binaries, so mirror the
// host JVM arch. Do not read the property inside doFirst — it needs to be
// resolved at configuration time so outputs.file() below is stable.
val hostArch = when (val a = System.getProperty("os.arch").lowercase()) {
"amd64", "x86_64" -> "x86_64"
"aarch64", "arm64" -> "aarch64"
else -> a
}
val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst")
val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-x86_64.AppImage")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-$hostArch.AppImage")
val toolRoot = layout.projectDirectory.dir("packaging/appimage")
val appimagetool = toolRoot.file("appimagetool-x86_64.AppImage")
val appimagetool = toolRoot.file("appimagetool-$hostArch.AppImage")
inputs.dir(distDir)
inputs.dir(toolRoot)
@@ -292,7 +304,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) {
appDir.get().asFile.absolutePath,
outFile.get().asFile.absolutePath,
)
environment("ARCH", "x86_64")
environment("ARCH", hostArch)
// Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2).
// AppImage standard env var: extracts + runs without mounting.
environment("APPIMAGE_EXTRACT_AND_RUN", "1")
+4 -1
View File
@@ -7,7 +7,10 @@
# (Equivalent packages on Fedora/Arch.)
set -eu
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
# Multiarch lib path is set by the host, not baked at build time — same
# AppRun works in both x86_64 and aarch64 AppImages.
GNU_TRIPLET="$(uname -m)-linux-gnu"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/${GNU_TRIPLET}:${LD_LIBRARY_PATH:-}"
export PATH="${HERE}/usr/bin:${PATH}"
export APPDIR="${HERE}"
exec "${HERE}/usr/bin/Amethyst" "$@"
+6 -2
View File
@@ -28,8 +28,12 @@ used two ways:
manifest (archive source pinned to the release tarball URL + sha256, with
`x-checker-data` so Flathub's update bot bumps it), its own metainfo
(carries the permanent `<releases>` history Flathub requires), desktop
entry, icon, and `flathub.json` (`only-arches: x86_64` — we publish no
aarch64 tarball, and jpackage can't cross-compile one)
entry, icon, and `flathub.json` — currently gated to `only-arches:
x86_64` so the Flathub build machinery never tries the aarch64 tarball
before we've validated it end-to-end on Flathub's aarch64 builders. GitHub
releases already ship aarch64 flatpak bundles (built from the same source
tree on `ubuntu-24.04-arm`); flipping `only-arches` to include `aarch64`
is the follow-up once we've smoke-tested a Flathub aarch64 build.
## Local build
@@ -221,7 +221,7 @@ class DesktopRelaySubscriptionsCoordinator(
val events =
client.fetchAll(
filters = indexRelays.associateWith { listOf(filter) },
timeoutMs = 8.seconds.inWholeMilliseconds,
idleTimeoutMs = 8.seconds.inWholeMilliseconds,
)
events.forEach { consumeEvent(it, null) }
}
@@ -132,6 +132,27 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
}
}
// Handle the still-broken case that the previous fix missed:
// the user granted OS permission in a prior session (either via
// the older "Enable OS notifications" button whose auto-enable
// guard I initially forgot, via System Settings directly, or on
// Windows/Linux where permissionState defaults to NotApplicable).
// When they come back to Settings, permissionState == Granted so
// the "Enable OS notifications" button doesn't render, the master
// switch is still OFF from first-launch defaults, and there is no
// affordance that both tells them what's wrong and fixes it in
// one click. Auto-enable once per screen entry when we detect
// "permission is fine, but master switch is off and the user
// has never explicitly disabled it". PreferencesNotificationSettings
// exposes [wasExplicitlyDisabled] so we don't overrule a deliberate
// opt-out.
androidx.compose.runtime.LaunchedEffect(permissionState, enabled) {
val allowed = permissionState == PermissionState.Granted || permissionState == PermissionState.NotApplicable
if (allowed && !enabled && !settings.wasExplicitlyDisabled()) {
settings.setEnabled(true)
}
}
PlatformStatusCard(
host = host,
nativeAvailable = nativeAvailable,
@@ -219,6 +240,17 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
}
}
PermissionState.Granted, PermissionState.NotApplicable -> {
// Turn-on button: renders only when master switch is
// off *and* the user explicitly disabled it before.
// The LaunchedEffect above auto-enables the switch
// for the common "never touched it" path; this button
// is the recovery for the deliberate-opt-out path.
if (!enabled) {
OutlinedButton(
onClick = { settings.setEnabled(true) },
enabled = true,
) { Text("Turn on desktop notifications") }
}
OutlinedButton(
onClick = {
if (sendingTest) return@OutlinedButton
+13 -10
View File
@@ -84,27 +84,34 @@
"Dutch"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "vitorpamplona",
"languages": [
"Czech",
"German",
"Polish",
"Portuguese, Brazilian",
"Swedish"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "greenart7c3",
"languages": []
@@ -265,10 +272,6 @@
"user": "D4rkFIow",
"languages": []
},
{
"user": "summoner001",
"languages": []
},
{
"user": "fiddleway",
"languages": []
+9
View File
@@ -112,6 +112,15 @@ require_auth = false
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
# Path for the JSON file that remembers what the [[mirror]] catch-up
# has already synced (per upstream, per scope), so a restart resumes
# instead of re-downloading each upstream's whole backfill window.
# Defaults to "<database file>.sync-coverage.json" next to the event
# store; only written when the store itself is file-backed (an
# in-memory store keeps no resume state — saved coverage would
# describe events that no longer exist).
# mirror_sync_state_file = "/var/lib/geode/events.db.sync-coverage.json"
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
@@ -28,6 +28,7 @@ import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.geode.mirror.MirrorDirection
import com.vitorpamplona.geode.mirror.MirrorUpstream
import com.vitorpamplona.geode.mirror.MirrorWorker
import com.vitorpamplona.geode.mirror.SyncCoverageFile
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -44,6 +45,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -223,7 +225,8 @@ private fun runImport(args: Array<String>) {
}
System.err.println(
"geode import: read=${stats.read} imported=${stats.imported} " +
"rejected=${stats.rejected} invalid-sig=${stats.invalid} malformed=${stats.malformed} " +
"rejected=${stats.rejected} failed=${stats.failed} " +
"invalid-sig=${stats.invalid} malformed=${stats.malformed} " +
"${ctx.dbFile ?: "(in-memory — not persisted; pass --db)"}",
)
} finally {
@@ -411,6 +414,27 @@ private fun serve(args: Array<String>) {
require(upstreams.none { it.url.displayUrl() == advertisedIdentity }) {
"[[mirror]] must not list this relay's own URL ($advertisedUrl)"
}
// Resume state for the mirror catch-up, following the admin state-file
// convention: next to the event database unless configured. Keyed to the
// store's ACTUAL persistence, not to `database.file` being set: a
// volatile store with a persistent coverage file would claim, on the
// next boot, that an empty database already holds the backfill window —
// and the mirror would never fetch it.
val sqliteBackend =
config.database.backend
.trim()
.lowercase() in StoreFactory.SQLITE_BACKEND_KEYWORDS
val persistentLocation =
a.opt("--db") ?: config.database.file?.takeUnless { sqliteBackend && config.database.in_memory }
val syncCoverage =
if (upstreams.isEmpty() || persistentLocation == null) {
if (upstreams.isNotEmpty() && config.options.mirror_sync_state_file != null) {
Log.w("Main") { "mirror_sync_state_file ignored: the event store is in-memory, so saved coverage would outlive the events it describes" }
}
null
} else {
SyncCoverageFile(File(config.options.mirror_sync_state_file ?: "$persistentLocation.sync-coverage.json"))
}
val mirror =
if (upstreams.isEmpty()) {
null
@@ -425,6 +449,9 @@ private fun serve(args: Array<String>) {
// for the historical window, then live REQ tail. Auto-falls back
// to paged REQ for upstreams without NIP-77.
negentropyBackfill = true,
// Without NIP-77 the catch-up is a paged re-download; the
// coverage bands remember what previous boots already walked.
coverage = syncCoverage?.coverage,
).also { it.start() }
}
@@ -462,6 +489,8 @@ private fun serve(args: Array<String>) {
// queue and store beneath them shut down.
runCatching { maintenanceScope.cancel() }
runCatching { mirror?.close() }
// After the mirror, so the final flush carries the last bands.
runCatching { syncCoverage?.close() }
runCatching { server.stop() }
runCatching { relay.close() }
},
@@ -185,6 +185,16 @@ data class StaticConfig(
* that don't offer NIP-50 at all (strfry, for example).
*/
val full_text_search: Boolean = true,
/**
* Where the mirror catch-up's resume state lives: the per-upstream
* `created_at` coverage bands (quartz's `SyncCoverage`). Without it
* every restart re-syncs each upstream's whole backfill window
* a full re-download for an upstream without NIP-77. Defaults to
* `<database file>.sync-coverage.json` when the store is
* file-backed; an in-memory store keeps no resume state (bands
* only pay off across restarts).
*/
val mirror_sync_state_file: String? = null,
)
/**
@@ -23,8 +23,11 @@ package com.vitorpamplona.geode.mirror
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
@@ -40,12 +43,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.time.Duration
import java.util.concurrent.atomic.AtomicLong
@@ -154,11 +160,18 @@ class MirrorWorker(
* historical window before the live REQ tail takes over. The geode binary
* turns this on (see `Main`); it defaults **off** so the many existing
* MirrorWorker tests keep exercising the pure live-REQ path unchanged.
* When on, `negentropySyncOrFetch` automatically falls back to paged REQ
* against an upstream that doesn't speak NIP-77 so "either mode" is
* transparent and needs no separate toggle.
* When on, the catch-up automatically falls back to paged REQ against an
* upstream that doesn't speak NIP-77 so "either mode" is transparent
* and needs no separate toggle.
*/
private val negentropyBackfill: Boolean = false,
/**
* Resume memory for the down catch-up, shared across upstreams and via
* [SyncCoverageFile] across restarts. Null keeps the old behavior:
* every boot re-syncs the whole backfill window, which for an upstream
* without NIP-77 is a full re-download.
*/
private val coverage: SyncCoverage? = null,
) : AutoCloseable {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -201,6 +214,14 @@ class MirrorWorker(
/** Events the store rejected — mostly duplicate replays after a reconnect. */
val rejected = AtomicLong(0)
/**
* Good events the store could not write ([IEventStore.InsertOutcome.Failed]).
* The store's fault, not the event's, and nothing re-offers them counted
* apart from [rejected] so a schema drift reads as store damage rather
* than as upstreams sending junk.
*/
val failed = AtomicLong(0)
/**
* Deliveries dropped before ever reaching the store: events outside
* the [MirrorUpstream.filter] scope (an upstream answering outside
@@ -300,6 +321,13 @@ class MirrorWorker(
rejected.incrementAndGet()
Log.d("MirrorWorker") { "rejected ${msg.event.id}: ${outcome.reason}" }
}
is IEventStore.InsertOutcome.Failed -> {
// The store's error, not the event's: the event
// was good and nothing will re-offer it. Louder
// than a rejection on purpose.
failed.incrementAndGet()
Log.w("MirrorWorker") { "store failed ${msg.event.id}: ${outcome.reason}" }
}
}
}
} catch (e: CancellationException) {
@@ -399,9 +427,9 @@ class MirrorWorker(
* **client-paced** so a fast upstream can't overrun the sink: a plain REQ
* backfill of a large set dies here (strfry kills a slow REQ client once its
* unsent-outbound buffer crosses `maxPendingOutboundBytes`), which is exactly
* why this uses negentropy. [INostrClient.negentropySyncOrFetch] falls back
* to paged REQ automatically when the upstream doesn't speak NIP-77, so the
* mirror is compatible with either kind of upstream with no config.
* why this uses negentropy. When the upstream doesn't speak NIP-77 the
* catch-up falls back to paged REQ, so the mirror is compatible with
* either kind of upstream with no config.
*
* A failure here is non-fatal: the live subscription keeps the mirror current
* and the reconnect watermark narrows any residual gap.
@@ -412,16 +440,26 @@ class MirrorWorker(
initialSince: Long,
until: Long,
) {
val catchUpFilter = scopedBase.copy(since = initialSince, until = until)
// Reconcile against what we already hold in this window → download only
// the diff (like `strfry sync`). No store wired → empty local set → the
// whole window is downloaded and the store's unique-id constraint dedups.
val localEntries = store?.snapshotIdsForNegentropy(listOf(catchUpFilter)) ?: emptyList()
// Resume memory: coverage is keyed on the STABLE scoped filter, never
// on the boot window — whose since/until change every start and would
// never match a stored band. The window itself rides along as the
// floor argument, so a band recorded against a shallower window
// re-opens the older span when the operator deepens the backfill.
val legs =
coverage
?.legs(up.url, scopedBase, initialSince)
?.mapNotNull { clampToWindow(it, initialSince, until) }
?: listOf(scopedBase.copy(since = initialSince, until = until))
if (legs.isEmpty()) {
Log.i("MirrorWorker") { "catch-up from ${up.url.url}: window already covered - nothing outside the synced band" }
return
}
// Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it
// blocks here when the sink falls behind; because negentropySyncOrFetch's
// own delivery pipeline is bounded, that backpressure reaches all the way
// to the upstream — no unbounded buffering (unlike the live-tail path).
// blocks here when the sink falls behind; because negentropySync's own
// delivery pipeline is bounded (and a paged REQ is paced by its pages),
// that backpressure reaches all the way to the upstream — no unbounded
// buffering (unlike the live-tail path).
val handoff = Channel<Event>(capacity = CATCHUP_HANDOFF)
val consumer =
scope.launch {
@@ -431,6 +469,10 @@ class MirrorWorker(
when (outcome) {
IEventStore.InsertOutcome.Accepted -> accepted.incrementAndGet()
is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet()
is IEventStore.InsertOutcome.Failed -> {
failed.incrementAndGet()
Log.w("MirrorWorker") { "store failed ${event.id}: ${outcome.reason}" }
}
}
}
} catch (e: CancellationException) {
@@ -443,24 +485,99 @@ class MirrorWorker(
}
try {
val result =
client.negentropySyncOrFetch(
relay = up.url,
filter = catchUpFilter,
localEntries = localEntries,
onEvent = { event ->
// Same containment as the live path: even a trusted
// upstream may only inject events inside the declared scope.
if (up.filter == null || up.filter.match(event)) {
handoff.trySendBlocking(event)
} else {
filtered.incrementAndGet()
var downloaded = 0
var paged = false
for (leg in legs) {
// Reconcile against what we already hold in this leg → download
// only the diff (like `strfry sync`). No store wired → empty
// local set → the whole leg is downloaded and the store's
// unique-id constraint dedups.
val localEntries = store?.snapshotIdsForNegentropy(listOf(leg)) ?: emptyList()
// Coverage is stamped from when the local ids were read — that
// is the state the relay is being compared against.
val syncStartedAt = TimeUtils.now()
var seenMin: Long? = null
var seenMax: Long? = null
fun observe(event: Event) {
// Same containment as the live path: even a trusted
// upstream may only inject events inside the declared scope.
if (up.filter == null || up.filter.match(event)) {
// Only plausible stamps widen a band — one
// misdated event must not discard the rest.
if (SyncCoverage.isPlausible(event.createdAt)) {
seenMin = minOf(seenMin ?: event.createdAt, event.createdAt)
seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt)
}
},
)
handoff.trySendBlocking(event)
} else {
filtered.incrementAndGet()
}
}
// The two phases run by hand rather than through
// negentropySyncOrFetch, for two reasons. The combinator keeps
// every delivered id for cross-phase dedup — a multi-million-
// event catch-up cannot afford that heap, and the store's
// unique-id constraint dedups anyway. And the fallback must
// reset the observed span: a half-finished reconcile delivers
// events scattered across the whole leg, and a band built from
// that scatter would claim interior ranges nobody walked.
val legPaged =
try {
downloaded +=
client
.negentropySync(
relay = up.url,
filter = leg,
localEntries = localEntries,
onEvent = ::observe,
).downloaded
false
} catch (e: NegentropySyncException) {
seenMin = null
seenMax = null
// The watchdog matches negentropySync's default rather
// than fetchAllPages' shorter one: a paged catch-up
// sits behind the same slow upstreams.
downloaded += client.fetchAllPages(up.url, listOf(leg), idleTimeoutMs = 120_000L) { observe(it) }
true
}
paged = paged || legPaged
// Recorded per leg, so a failure between legs keeps the ground
// the first one gained — and no more: a reconcile compared only
// its own leg, so completeness reaches the leg's ceiling, never
// "now" while a later leg is still pending. A paged fallback
// earns only the span it actually saw, capped at the snapshot
// instant so one future-dated event cannot lift the band's
// ceiling past what was asked.
if (legPaged) {
coverage?.record(
up.url,
scopedBase,
seenMin,
seenMax?.coerceAtMost(syncStartedAt),
paged = true,
)
} else {
val legFloor = leg.since ?: initialSince
coverage?.record(
up.url,
scopedBase,
// The compared range starts at the leg's floor whether
// or not anything was observed there — that is what a
// clean reconcile proves.
observedMin = minOf(seenMin ?: legFloor, legFloor),
observedMax = null,
paged = false,
reconciledThrough = minOf(leg.until ?: syncStartedAt, syncStartedAt),
)
}
}
Log.i("MirrorWorker") {
val how = if (result.pagedFallback) "paged REQ (upstream has no NIP-77)" else "negentropy"
"catch-up from ${up.url.url}: ${result.downloaded} events via $how"
val how = if (paged) "paged REQ (upstream has no NIP-77)" else "negentropy"
val resumed = if (coverage != null && legs.size > 1) " [resumed: ${legs.size} legs outside the synced band]" else ""
"catch-up from ${up.url.url}: $downloaded events via $how$resumed"
}
} catch (e: CancellationException) {
throw e
@@ -671,11 +788,21 @@ class MirrorWorker(
runCatching { client.close() }
inbound.close()
scope.cancel()
// Wait (bounded) for the workers to land: a coverage.record racing
// past the state file's final flush would be recorded and lost.
// Bounded because a worker parked in a blocking hand-off does not
// feel the cancel, and shutdown must not hang on it.
runBlocking {
withTimeoutOrNull(CLOSE_JOIN_MS) { scope.coroutineContext[Job]?.join() }
}
okhttp?.dispatcher?.executorService?.shutdown()
okhttp?.connectionPool?.evictAll()
}
private companion object {
/** How long [close] waits for the worker coroutines to land. */
const val CLOSE_JOIN_MS = 5_000L
/** Matches the Android app's relay-pool WebSocket ping interval. */
const val PING_INTERVAL_SECS = 120L
@@ -701,7 +828,7 @@ class MirrorWorker(
/**
* Depth of the catch-up hand-off between the negentropy download and the
* ingest consumer. Small: negentropySyncOrFetch is already internally
* ingest consumer. Small: the negentropy download is already internally
* backpressured, so this only smooths the seam the bounded IngestQueue
* behind `server.ingest` is the real limiter.
*/
@@ -720,3 +847,20 @@ class MirrorWorker(
const val UP_SYNC_SETTLE_MS = 1_500L
}
}
/**
* [leg] intersected with the boot window `[since, until]`, or null when the
* band already covers everything this window could ask. Coverage legs come
* off the stable scoped filter and are unbounded on one side; the catch-up
* only ever asks inside its own window.
*/
internal fun clampToWindow(
leg: Filter,
since: Long,
until: Long,
): Filter? {
val newSince = maxOf(leg.since ?: since, since)
val newUntil = minOf(leg.until ?: until, until)
if (newSince > newUntil) return null
return leg.copy(since = newSince, until = newUntil)
}
@@ -0,0 +1,158 @@
/*
* 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.geode.mirror
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* File persistence for [SyncCoverage], so the mirror's catch-up resumes
* across restarts instead of re-syncing each upstream's whole backfill
* window which, for an upstream without NIP-77, is a full re-download
* every boot.
*
* Same shape as the admin state file: JSON next to the event database,
* written via a temp file and an atomic move so a reader never sees a half
* map. A daemon timer flushes changed state so progress survives a hard
* kill; [close] flushes the rest. A corrupt file starts fresh the cost of
* losing it is one re-sync, the cost of refusing to start is the relay.
*/
class SyncCoverageFile(
private val file: File,
flushSeconds: Long = DEFAULT_FLUSH_SECONDS,
) : AutoCloseable {
@Volatile private var dirty = false
val coverage = SyncCoverage(onChange = { dirty = true })
private val flusher: Thread
init {
load()
// restore() bypasses onChange, but stay defensive: reopening a file
// must never count as a change, or every boot rewrites it.
dirty = false
flusher =
Thread {
while (!Thread.currentThread().isInterrupted) {
try {
Thread.sleep(flushSeconds * 1000)
} catch (_: InterruptedException) {
return@Thread
}
flush()
}
}.apply {
isDaemon = true
name = "mirror-sync-coverage-flush"
start()
}
}
/** Write the map if anything changed since the last write. */
@Synchronized
fun flush() {
if (!dirty) return
dirty = false
save()
}
override fun close() {
flusher.interrupt()
flush()
}
private fun load() {
if (!file.isFile) return
runCatching {
val root = Json.parseToJsonElement(file.readText()).jsonObject
coverage.restore(
root.mapValues { (_, v) ->
val o = v.jsonObject
SyncCoverage.Band(
o.getValue("min").jsonPrimitive.long,
o.getValue("max").jsonPrimitive.long,
o["complete"]?.jsonPrimitive?.boolean ?: false,
o["fullAt"]?.jsonPrimitive?.long ?: 0L,
)
},
)
}.onFailure {
Log.w("SyncCoverageFile") { "could not read ${file.path} (${it.message}); starting fresh" }
}
}
@Synchronized
private fun save() {
runCatching {
val doc =
buildJsonObject {
coverage.export().forEach { (key, band) ->
put(
key,
buildJsonObject {
put("min", band.minCreatedAt)
put("max", band.maxCreatedAt)
put("complete", band.complete)
put("fullAt", band.fullAt)
},
)
}
}
file.parentFile?.mkdirs()
val tmp = File(file.parentFile ?: File("."), "${file.name}.tmp")
tmp.writeText(json.encodeToString(JsonObject.serializer(), doc))
// ATOMIC_MOVE requested explicitly: without it the JVM may
// legally fall back to copy+delete, and a reader could see a
// half map. Same-directory rename, so support is the norm; a
// filesystem that truly can't gets the plain move (and the
// corrupt-file recovery absorbs the residual risk).
try {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}.onFailure {
Log.w("SyncCoverageFile") { "could not write ${file.path}: ${it.message}" }
}
}
companion object {
// Pretty-printed: this file is read by a human debugging why an
// upstream re-synced.
private val json = Json { prettyPrint = true }
// Often enough that a kill costs little, rare enough to be free.
private const val DEFAULT_FLUSH_SECONDS = 30L
}
}
@@ -0,0 +1,130 @@
/*
* 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.geode.mirror
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The catch-up's resume memory across restarts. Without it every boot
* re-syncs each upstream's whole backfill window a full re-download for an
* upstream without NIP-77. These pin the restart round-trip and the window
* clamping that keys bands on the stable filter rather than the sliding
* boot window.
*/
class SyncCoverageFileTest {
private val relay = RelayUrlNormalizer.normalize("wss://relay.example")
private val profiles = Filter(kinds = listOf(0))
private fun tempFile(): File {
val f = File.createTempFile("sync-coverage", ".json")
f.delete()
return f
}
@Test
fun `bands survive a restart`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
}
// A fresh instance, as a restart would build.
SyncCoverageFile(f).use { reopened ->
val band = reopened.coverage.band(relay, profiles)!!
assertEquals(1_700_001_000L, band.minCreatedAt)
assertEquals(1_700_002_000L, band.maxCreatedAt)
assertFalse(band.complete)
}
}
@Test
fun `a complete band survives with its completeness`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_005_000L)
}
SyncCoverageFile(f).use { reopened ->
assertTrue(reopened.coverage.band(relay, profiles)!!.complete)
}
}
@Test
fun `a corrupt file starts fresh instead of refusing to start`() {
val f = tempFile()
f.writeText("{ not json")
SyncCoverageFile(f).use {
assertNull(it.coverage.band(relay, profiles))
}
}
@Test
fun `recording does not write but closing does`() {
val f = tempFile()
val store = SyncCoverageFile(f)
store.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
assertFalse(f.isFile, "a record marks dirty; only a flush writes")
store.close()
assertTrue(f.isFile, "close flushes")
}
@Test
fun `reopening without new records does not rewrite the file`() {
val f = tempFile()
SyncCoverageFile(f).use {
it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true)
}
val written = f.lastModified()
SyncCoverageFile(f).close()
assertEquals(written, f.lastModified(), "restoring bands must not mark the store dirty")
}
// ---- the window clamp --------------------------------------------------
@Test
fun `an unbanded filter clamps to exactly the boot window`() {
val leg = clampToWindow(profiles, since = 1_000L, until = 2_000L)!!
assertEquals(1_000L, leg.since)
assertEquals(2_000L, leg.until)
}
@Test
fun `a leg outside the window is dropped rather than inverted`() {
// The band covers past the window's floor: the older leg would ask
// [since..band.min] with since above until — a range nothing can be in.
val olderLeg = profiles.copy(until = 500L)
assertNull(clampToWindow(olderLeg, since = 1_000L, until = 2_000L))
}
@Test
fun `a leg inside the window keeps its own tighter bound`() {
val newerLeg = profiles.copy(since = 1_500L)
val clamped = clampToWindow(newerLeg, since = 1_000L, until = 2_000L)!!
assertEquals(1_500L, clamped.since, "the band's ceiling wins over the window floor")
assertEquals(2_000L, clamped.until)
}
}
+19 -9
View File
@@ -259,15 +259,25 @@ q.domain // "example.com"
q.nsfwIncluded // false (NIP-50 default is true when the token is absent)
```
The SQLite store expects `Filter.search` to be plain FTS text: `:` is FTS5
column-filter syntax, so a raw `include:spam` reaching MATCH raises "no such
column: include" instead of matching. Strip the tokens before querying with
`SearchQuery.stripExtensions(raw)` or `filter.strippingSearchExtensions()`
an extensions-only query collapses to an empty search, which imposes no
constraint (NIP-50: unsupported extensions are ignored, not match-nothing).
The storage-backed server path (`NostrServer` / `LiveEventStore`) already does
this, so relays like geode comply out of the box; `EventSource` backends get
the raw string because a real search backend wants the extensions.
`Filter.search` reaches every backend **verbatim**, extension tokens included
— the relay layer never rewrites it. Which extensions are directives and which
are noise is a property of the store, so the `IEventStore` contract puts the
decision there: a store that implements an extension (rank profiles, trust
floors, observer-relative scoring) parses the raw string with
`SearchQuery.parse`; a store that doesn't must ignore the tokens per NIP-50
(not match them as literal text, not return nothing). The built-in SQLite and
filesystem stores do the latter by stripping at their own boundary with
`filter.strippingSearchExtensions()` — FTS5 treats `:` as column-filter syntax,
so a raw `include:spam` reaching MATCH would raise "no such column: include" —
and an extensions-only query collapses to an empty search, which imposes no
constraint. Relays like geode therefore comply out of the box, and
`EventSource` backends likewise get the raw string.
Observer-relative stores (web-of-trust ranking, "for-you" relevance) read the
caller's NIP-42-authenticated pubkeys from the coroutine context via
`StoreQueryContext``LiveEventStore` installs it around every REQ/COUNT store
call for authenticated connections. It is ranking context only: it may reorder
results, never change which events match.
A search/redirector relay is just a custom policy (or, for computed results, a
custom `IEventStore` whose `query` answers the REQ) that reads the parsed query:

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