mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-04 22:04:38 +00:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8713d825 | ||
|
|
21b02d780f | ||
|
|
d3bd7a45b9 | ||
|
|
a5d2d153c8 | ||
|
|
3a702add57 | ||
|
|
5ec35c7772 | ||
|
|
9a4f6c6cdd | ||
|
|
c767298368 | ||
|
|
ad3ce4d1a1 | ||
|
|
0d0117061a | ||
|
|
fd5bd994a1 | ||
|
|
a7eec1d605 | ||
|
|
d9a58950ec | ||
|
|
ba7cc72dd2 | ||
|
|
156176f5fe | ||
|
|
1a25557bdb | ||
|
|
b48b87a60b | ||
|
|
cdbd550405 | ||
|
|
c029b271e7 | ||
|
|
4081ef1681 | ||
|
|
804b850095 | ||
|
|
42fc73f6cb | ||
|
|
19d8e3069d | ||
|
|
9231195890 | ||
|
|
564cafedc4 | ||
|
|
e54a546d10 | ||
|
|
b46063713e | ||
|
|
d54e54b48a | ||
|
|
2b2e47256b | ||
|
|
7b29f57526 | ||
|
|
a110ce0a30 | ||
|
|
ba78eb599e | ||
|
|
5896ae5eff | ||
|
|
fdc68e801a | ||
|
|
30742ab352 | ||
|
|
1622bd7109 | ||
|
|
64073f8fa9 | ||
|
|
3ef61bafcb | ||
|
|
ba35a87a7c | ||
|
|
ac6034f764 | ||
|
|
53be8d1755 | ||
|
|
431ad153bc | ||
|
|
bf41e75b78 | ||
|
|
e10f76665f | ||
|
|
6cd91bb058 | ||
|
|
7922f9a4d1 | ||
|
|
0cc3f72f55 | ||
|
|
31c3eaf2b3 | ||
|
|
92ca11a583 | ||
|
|
dc5e4562fd | ||
|
|
e0ea365368 | ||
|
|
1dba215d4d | ||
|
|
d2c9593919 | ||
|
|
20149e2600 | ||
|
|
c0932e0330 | ||
|
|
dc1f0dd995 | ||
|
|
7d91931b75 | ||
|
|
f2e7a07a97 | ||
|
|
7936cab9d5 | ||
|
|
5311efe65e | ||
|
|
2e90cc24ba | ||
|
|
532b9e67fe | ||
|
|
3823eae11d | ||
|
|
9cb17a26e8 | ||
|
|
6d518adddb | ||
|
|
c3c20c6615 | ||
|
|
da009f36bd | ||
|
|
f7959571a7 | ||
|
|
529c114802 | ||
|
|
2741d32cfd | ||
|
|
ae61e69136 | ||
|
|
79f198c729 | ||
|
|
18d229be58 | ||
|
|
d6333989a4 | ||
|
|
cf75272202 | ||
|
|
5c24b003e7 | ||
|
|
729fb1bc17 | ||
|
|
77ff9e9699 | ||
|
|
166ebc755e | ||
|
|
d8bae8c628 | ||
|
|
9ea4b81c1f | ||
|
|
ebd9a163bc |
@@ -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 F01–F13, W01–W08, D01–D08, C01, S01–S06, 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 0–23, 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 0–100 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`.
|
||||
@@ -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` |
|
||||
| 1630–1633 | 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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`) | — |
|
||||
|
||||
|
||||
+25
-36
@@ -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
+2
-2
@@ -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."))
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+8
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+8
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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?
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+57
-57
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
+1
-1
@@ -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()
|
||||
},
|
||||
|
||||
+1
-1
@@ -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 },
|
||||
|
||||
+1
-1
@@ -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 },
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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}")
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+5
-5
@@ -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())
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+10
-10
@@ -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,
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+17
-17
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -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),
|
||||
),
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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"
|
||||
|
||||
|
||||
+86
@@ -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())
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+32
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user