mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 22:54:39 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29e665a7e0 |
@@ -85,30 +85,3 @@ 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.
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
---
|
||||
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/`.
|
||||
@@ -1,232 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,166 +0,0 @@
|
||||
# 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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
@@ -26,12 +26,8 @@ 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.
|
||||
#
|
||||
# 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
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -42,32 +38,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
|
||||
# ubuntu-24.04-arm label is a standard free public-repo runner as of
|
||||
# early 2025). Windows arm64 uses windows-11-arm, added to the free
|
||||
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
|
||||
# jpackage / jlink / Compose Multiplatform 1.11 all produce
|
||||
# host-native artifacts — no cross-compilation needed.
|
||||
#
|
||||
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
|
||||
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
|
||||
# windows-11-arm runner image ships no WiX (the windows-latest image
|
||||
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
|
||||
# MSI). Installing it here would mean pulling an archived, x86-only
|
||||
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
|
||||
# the candle/light CLI that JDK 21's jpackage requires) into the job
|
||||
# that publishes signed release assets. The portable zip is the
|
||||
# documented Windows install path for amy/geode already, so arm64
|
||||
# Windows users get that until either the runner image gains WiX or
|
||||
# jpackage learns the WiX 4+ CLI.
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { 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" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
@@ -78,7 +53,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -118,21 +93,13 @@ 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
|
||||
# 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"
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$DEST"
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
@@ -236,32 +203,17 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
|
||||
# against the bundled JRE under lib/runtime/, NOT the app payload under
|
||||
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
|
||||
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
|
||||
# install without EGL crashes at startup with:
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
if: matrix.family == 'linux' && matrix.arch == 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Build portable archives (windows + linux-portable)
|
||||
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ARCH="${{ matrix.arch }}"
|
||||
APP="desktopApp/build/compose/binaries/main-release/app"
|
||||
mkdir -p desktopApp/build/portable
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
|
||||
else
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
@@ -278,17 +230,6 @@ 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)\" />|" \
|
||||
@@ -300,7 +241,7 @@ jobs:
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
@@ -344,7 +285,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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -384,17 +325,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
|
||||
# and jpackageMsi for a CLI is deferred (the portable zip is the
|
||||
# documented Windows install path). The launcher script writes both
|
||||
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
|
||||
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
|
||||
# zips the image on Windows instead of tar.gz.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -405,7 +337,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -604,7 +536,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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -642,16 +574,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
|
||||
# deferred (portable zip covers the primary use — operators still
|
||||
# deploy geode via the Docker image or the tarball on Linux). The
|
||||
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
|
||||
# smoke test below runs under bash on the windows runner.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -662,7 +586,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -706,23 +630,12 @@ jobs:
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
#
|
||||
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
|
||||
# tmp path also differs between git-bash on Windows (which resolves /tmp
|
||||
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
|
||||
# because the workflow's `defaults.run.shell: bash` uses git-bash on
|
||||
# Windows and /tmp is a valid mingw path there.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
LAUNCHER="$IMG/bin/geode.bat"
|
||||
else
|
||||
LAUNCHER="$IMG/bin/geode"
|
||||
fi
|
||||
"$LAUNCHER" --version
|
||||
"$LAUNCHER" --port 17447 &
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
@@ -864,7 +777,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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -918,17 +831,17 @@ jobs:
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: geode/Dockerfile
|
||||
@@ -953,7 +866,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -1097,7 +1010,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Android assets to GH Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
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.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -49,24 +49,16 @@ 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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5.6.0
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -147,6 +139,5 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Artifact names must be unique across a run — disambiguate per arch.
|
||||
name: Release DEB (smoke-tested, ${{ matrix.os }})
|
||||
name: Release DEB (smoke-tested)
|
||||
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
+12
-33
@@ -35,12 +35,7 @@ All platforms:
|
||||
Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
|
||||
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
|
||||
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
|
||||
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
|
||||
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
|
||||
themselves are x86 and run under emulation).
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
@@ -62,12 +57,9 @@ 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
|
||||
|
||||
# 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"
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -118,8 +110,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-*-<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) |
|
||||
| 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) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -333,27 +325,14 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify** — the GH Release should hold **47 assets**:
|
||||
- **14 desktop**, one per matrix leg × format:
|
||||
- macOS arm64: `dmg` (1)
|
||||
- Windows x64: `msi` + portable `zip` (2)
|
||||
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
|
||||
- Linux x64 / arm64: `deb` + `rpm` (4)
|
||||
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
|
||||
|
||||
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
|
||||
and no Intel runner leg is configured, so macOS ships arm64-only.
|
||||
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
|
||||
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
|
||||
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
|
||||
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
|
||||
jpackage learns the WiX 4+ `wix build` CLI.
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
|
||||
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
|
||||
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
|
||||
- **10 geode** — same shape as amy.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Android flow unchanged
|
||||
|
||||
|
||||
+36
-25
@@ -66,37 +66,28 @@ 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 = controllerState,
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
videoUri = "https://streamstr.net/x/hls/live.m3u8",
|
||||
)
|
||||
}
|
||||
@@ -160,14 +151,24 @@ 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 = controllerState,
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
videoUri = "https://streamstr.net/x/hls/live.m3u8",
|
||||
)
|
||||
}
|
||||
@@ -195,14 +196,24 @@ 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 = controllerState,
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
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
@@ -1,580 +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.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)
|
||||
}
|
||||
}
|
||||
@@ -1,554 +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.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() }
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,6 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
@@ -503,18 +501,6 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
|
||||
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
|
||||
// Settings as hidden, which would leave no route back to the screen that hides rows.
|
||||
val sanitized = DrawerItemVisibility.sanitize(newItems)
|
||||
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
|
||||
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
|
||||
@@ -25,10 +25,6 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -87,7 +83,6 @@ class AccountSyncedSettings(
|
||||
val navigation =
|
||||
AccountNavigationPreferences(
|
||||
MutableStateFlow(internalSettings.navigation.bottomBarItems),
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
@@ -129,11 +124,7 @@ class AccountSyncedSettings(
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
),
|
||||
navigation =
|
||||
AccountNavigationPreferencesInternal(
|
||||
navigation.bottomBarItems.value,
|
||||
navigation.hiddenDrawerItems.value.toNames(),
|
||||
),
|
||||
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -230,11 +221,6 @@ class AccountSyncedSettings(
|
||||
if (navigation.bottomBarItems.value != newBottomBarItems) {
|
||||
navigation.bottomBarItems.tryEmit(newBottomBarItems)
|
||||
}
|
||||
|
||||
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
|
||||
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
|
||||
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -336,8 +322,6 @@ class AccountMediaPreferences(
|
||||
@Stable
|
||||
class AccountNavigationPreferences(
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
|
||||
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
|
||||
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
||||
-9
@@ -170,15 +170,6 @@ class AccountNavigationPreferencesInternal(
|
||||
// favorite apps, and individual joined chats/groups). Defaulted so blobs
|
||||
// written before this field existed decode to the app's current defaults.
|
||||
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
|
||||
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
|
||||
// Empty by default, which is what makes a newly shipped destination visible
|
||||
// to everyone without a migration — see DrawerItemVisibility.
|
||||
//
|
||||
// Stored as strings rather than the enum on purpose: an id written by a
|
||||
// newer client would fail the enum decoder and take the whole synced-settings
|
||||
// blob down with it, so unknown names are dropped on read instead (the same
|
||||
// approach AccountPoWPreferencesInternal.enabledCategories takes).
|
||||
var hiddenDrawerItems: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,337 +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.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) }
|
||||
}
|
||||
}
|
||||
@@ -1,492 +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.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")
|
||||
}
|
||||
}
|
||||
@@ -1,266 +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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +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.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?
|
||||
}
|
||||
@@ -1,431 +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.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
-4
@@ -67,9 +67,7 @@ class RoleBasedHttpClientBuilder(
|
||||
normalizedUrl: String,
|
||||
final: Boolean,
|
||||
): Boolean =
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
|
||||
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
|
||||
// interface — Tor cannot route the range, so proxying only breaks the fetch.
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
|
||||
false
|
||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||
true
|
||||
@@ -115,7 +113,7 @@ class RoleBasedHttpClientBuilder(
|
||||
isOnionRelaysActive: Boolean,
|
||||
final: Boolean,
|
||||
): Boolean =
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
|
||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
|
||||
false
|
||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||
isOnionRelaysActive
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class NappletLiveSubscriptions {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -255,7 +255,7 @@ class AccountNappletGateways(
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = 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.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
account.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) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
}.getOrDefault(emptyList())
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.zaps.sendNwcRequest(request) { response: Response? ->
|
||||
account.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.zaps.createZapRequestFor(
|
||||
account.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = null,
|
||||
message = message,
|
||||
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
account.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.zaps.defaultWalletSupportsBolt12Pay()
|
||||
account.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
val bolt12Recipients =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
|
||||
|
||||
val zapRequest =
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
account.zaps.createZapRequestFor(
|
||||
account.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
|
||||
return mapNotNullAsync(
|
||||
items = payables,
|
||||
runRequestFor = { payable: Payable ->
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
account.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.zaps.sendBolt12Zap(
|
||||
account.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.pruner.cleanMemory()
|
||||
cache.pruner.cleanObservers()
|
||||
cache.pruner.pruneExpiredEvents()
|
||||
cache.pruner.prunePastVersionsOfReplaceables()
|
||||
cache.cleanMemory()
|
||||
cache.cleanObservers()
|
||||
cache.pruneExpiredEvents()
|
||||
cache.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.pruner.pruneHiddenEvents(it)
|
||||
cache.pruner.pruneHiddenMessages(it)
|
||||
cache.pruneHiddenEvents(it)
|
||||
cache.pruneHiddenMessages(it)
|
||||
}
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruner.pruneOldMessages()
|
||||
cache.pruner.pruneRepliesAndReactions(accounts)
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
persistOwn = false,
|
||||
)
|
||||
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onIncomingMessage(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
|
||||
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
|
||||
}
|
||||
|
||||
override suspend fun onIncomingMessage(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class NotifyCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onIncomingMessage(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
newEose(TimeUtils.now(), relay, forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.notificationHistory
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.chatroomList.giftWrapHistory
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+3
-8
@@ -84,14 +84,9 @@ class UserCardsSubAssembler(
|
||||
add(it, account.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
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)
|
||||
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
|
||||
if (account != null) {
|
||||
add(account.relayUrl, account.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class UserWatcherSubAssembler(
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
override suspend fun onIncomingMessage(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ class RelayUsageListener(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onIncomingMessage(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
|
||||
|
||||
val preimageResult = CompletableDeferred<String?>()
|
||||
try {
|
||||
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
|
||||
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Dao
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
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
|
||||
@@ -257,3 +258,11 @@ 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.concord.peekConcordInvite(linkText)
|
||||
value = accountViewModel.account.peekConcordInvite(linkText)
|
||||
}
|
||||
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
|
||||
-142
@@ -1,142 +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.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
/**
|
||||
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
|
||||
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
|
||||
* returns null for.
|
||||
*
|
||||
* It exists so those files have somewhere to land other than the video player: an unknown blob
|
||||
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
|
||||
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
|
||||
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
|
||||
* which would try to download the blob just to rediscover the type the event already declared.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentCard(
|
||||
url: String,
|
||||
description: String?,
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val filename = remember(url) { extractFilename(url) }
|
||||
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(url) },
|
||||
) {
|
||||
FileAttachmentRow(
|
||||
symbol = MaterialSymbols.AttachFile,
|
||||
// The alt/content text names the file for a human ("Webxdc app: Quake");
|
||||
// the hashed URL basename is the fallback when the event omits it.
|
||||
title = description?.ifBlank { null } ?: filename,
|
||||
subtitle = subtitle,
|
||||
titleMaxLines = 2,
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon + title + subtitle row shared by every card that stands in for a file it can't
|
||||
* render inline: this one and the PDF placeholder/skeleton in
|
||||
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
|
||||
*/
|
||||
@Composable
|
||||
internal fun FileAttachmentRow(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
titleMaxLines: Int = 1,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
|
||||
private fun fileSubtitle(
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
): String? =
|
||||
listOfNotNull(
|
||||
mimeType?.ifBlank { null }?.let(::prettyMime),
|
||||
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
|
||||
).joinToString(" · ").ifEmpty { null }
|
||||
+39
-6
@@ -26,29 +26,38 @@ import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -198,11 +207,35 @@ private fun PdfSkeletonCard(filename: String) {
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) = FileAttachmentRow(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
title = filename,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
|
||||
@@ -263,7 +263,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
|
||||
@@ -579,7 +578,6 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
+28
@@ -20,11 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@@ -56,3 +58,29 @@ fun keyboardAsState(): State<KeyboardState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BackHandler] that steps aside while the soft keyboard is on screen.
|
||||
*
|
||||
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
|
||||
* When that pop happens while the keyboard is still up, it races the predictive-back window
|
||||
* animation against the IME's close animation. On release builds — fast enough that the window
|
||||
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
|
||||
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
|
||||
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
|
||||
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
|
||||
* screen).
|
||||
*
|
||||
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
|
||||
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
|
||||
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
|
||||
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyboardAwareBackHandler(
|
||||
enabled: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val keyboardState by keyboardAsState()
|
||||
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
|
||||
}
|
||||
|
||||
+66
-22
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -28,9 +29,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
|
||||
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
|
||||
* Order in this enum has no semantic meaning.
|
||||
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
|
||||
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
|
||||
*/
|
||||
@Serializable
|
||||
enum class NavBarItem {
|
||||
@@ -84,18 +84,6 @@ enum class NavBarItem {
|
||||
FAVORITE_ALGO_FEEDS,
|
||||
}
|
||||
|
||||
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
|
||||
|
||||
/**
|
||||
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
|
||||
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
|
||||
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
|
||||
*/
|
||||
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
|
||||
|
||||
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
|
||||
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
|
||||
|
||||
data class NavBarItemDef(
|
||||
val id: NavBarItem,
|
||||
val labelRes: Int,
|
||||
@@ -455,6 +443,34 @@ val DefaultBottomBarItems: List<NavBarItem> =
|
||||
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
|
||||
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
|
||||
|
||||
// Ordered membership lists for each drawer section. The drawer renders these by looking up
|
||||
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
|
||||
// matching section list below — not two separate files.
|
||||
val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
/**
|
||||
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
|
||||
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
|
||||
@@ -463,7 +479,6 @@ val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map {
|
||||
*/
|
||||
data class NavBarCategory(
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
)
|
||||
|
||||
@@ -476,7 +491,6 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_main,
|
||||
MaterialSymbols.Home,
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
@@ -487,7 +501,6 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_chats,
|
||||
MaterialSymbols.Group,
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
@@ -497,7 +510,6 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_you,
|
||||
MaterialSymbols.AccountCircle,
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
@@ -515,7 +527,6 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_feeds,
|
||||
MaterialSymbols.Subscriptions,
|
||||
listOf(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.LONGS,
|
||||
@@ -542,7 +553,6 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_apps,
|
||||
MaterialSymbols.Apps,
|
||||
listOf(
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.FAVORITE_APPS,
|
||||
@@ -553,9 +563,43 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_other,
|
||||
MaterialSymbols.Settings,
|
||||
listOf(
|
||||
NavBarItem.SETTINGS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
+44
-59
@@ -63,7 +63,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -106,6 +105,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
|
||||
@@ -582,17 +584,42 @@ fun ListContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
|
||||
// Side Menu settings screen. Empty (the default) means the full stock drawer.
|
||||
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier) {
|
||||
DrawerSections.forEach { section ->
|
||||
// Keyed by section: hiding the last row of a section removes it from the drawer
|
||||
// entirely, and without a key the sections below would slide up into its slots and
|
||||
// inherit its CollapsibleSection expanded/collapsed state.
|
||||
key(section.id) {
|
||||
CatalogSection(section, hidden, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_create) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_system) {
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
|
||||
NavBarCatalog[NavBarItem.SETTINGS]?.let {
|
||||
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,64 +634,22 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
|
||||
@Composable
|
||||
private fun CreateRows(nav: INav) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
|
||||
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
*
|
||||
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
|
||||
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
|
||||
* System carries the relay-status row (not a catalog destination — it shows a live counter).
|
||||
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
|
||||
* Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogSection(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
titleRes: Int,
|
||||
ids: List<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
|
||||
if (visible.isEmpty() && !section.hasFixedRows) return
|
||||
|
||||
CollapsibleSection(title = section.titleRes) {
|
||||
when (section.id) {
|
||||
DrawerSectionId.CREATE -> CreateRows(nav)
|
||||
DrawerSectionId.SYSTEM ->
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
visible.forEach { id ->
|
||||
CollapsibleSection(title = titleRes) {
|
||||
ids.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||
|
||||
-104
@@ -1,104 +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.ui.navigation.drawer
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* Which drawer rows the user cannot hide.
|
||||
*
|
||||
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
|
||||
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
|
||||
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
|
||||
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
|
||||
* so it is present by construction and never appears in the hidden set.
|
||||
*/
|
||||
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
|
||||
|
||||
/**
|
||||
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
|
||||
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
|
||||
*
|
||||
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
|
||||
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
|
||||
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
|
||||
* account's drawer at the moment they first touched the setting, and every later release would have
|
||||
* to migrate saved lists to introduce a screen.
|
||||
*/
|
||||
object DrawerItemVisibility {
|
||||
fun isVisible(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Boolean = item in MandatoryDrawerItems || item !in hidden
|
||||
|
||||
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
|
||||
fun toggle(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Set<NavBarItem> =
|
||||
when {
|
||||
item in MandatoryDrawerItems -> hidden
|
||||
item in hidden -> hidden - item
|
||||
else -> hidden + item
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
|
||||
* it runs on decode, on an external sync, and on every write — so a value synced from another
|
||||
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
|
||||
*
|
||||
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
|
||||
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
|
||||
* preserving it means editing the drawer on that device doesn't silently clear the choice the
|
||||
* user made on another one.
|
||||
*/
|
||||
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
|
||||
|
||||
/** The rows of [section] to render, in the section's fixed order. */
|
||||
fun visibleItems(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
|
||||
|
||||
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
|
||||
fun hiddenCount(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): Int = section.items.count { !isVisible(hidden, it) }
|
||||
|
||||
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
|
||||
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
|
||||
fun hideAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Shows every row of [section] again. */
|
||||
fun showAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden - section.items.toSet()
|
||||
|
||||
/** Total hidden rows across every section — the count the settings screen shows at the top. */
|
||||
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
|
||||
}
|
||||
-153
@@ -1,153 +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.ui.navigation.drawer
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* The drawer's layout: which destinations it lists, under which heading, in which order.
|
||||
*
|
||||
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
|
||||
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
|
||||
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
|
||||
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
|
||||
* filed into exactly one section.
|
||||
*
|
||||
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
|
||||
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
|
||||
* which rows are visible — see [DrawerItemVisibility].
|
||||
*/
|
||||
@Immutable
|
||||
data class DrawerSection(
|
||||
val id: DrawerSectionId,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
/**
|
||||
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
|
||||
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
|
||||
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
|
||||
*/
|
||||
val hasFixedRows: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
|
||||
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
|
||||
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
|
||||
* compile error and nothing to fail a test.
|
||||
*/
|
||||
enum class DrawerSectionId {
|
||||
YOU,
|
||||
NAVIGATE,
|
||||
FEEDS,
|
||||
|
||||
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
|
||||
CREATE,
|
||||
|
||||
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
private val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
private val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.FAVORITE_ALGO_FEEDS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
private val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
val DrawerSections: List<DrawerSection> =
|
||||
listOf(
|
||||
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
|
||||
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
|
||||
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
|
||||
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
|
||||
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
|
||||
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
|
||||
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
|
||||
* missing, and fails on anything else — that's what keeps a newly added destination from silently
|
||||
* skipping both the drawer and its settings screen.
|
||||
*/
|
||||
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
|
||||
@@ -1,89 +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.ui.navigation.navs
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before navigating anyway. */
|
||||
const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
|
||||
* in the app serializes the IME and window animations instead of overlapping them.
|
||||
*
|
||||
* Navigating while the keyboard is up races the window animation against the IME's close animation.
|
||||
* On release builds — fast enough that the window animation wins — the IME
|
||||
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
|
||||
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
|
||||
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
|
||||
* freezes at the keyboard height until some later inset pass happens to rebalance it.
|
||||
*
|
||||
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
|
||||
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
|
||||
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
|
||||
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
|
||||
* has done anything, and every way out of it is a navigation.
|
||||
*/
|
||||
fun interface ImeSettler {
|
||||
suspend fun settle()
|
||||
|
||||
companion object {
|
||||
/** For [EmptyNav] and previews, where there is no window to read insets from. */
|
||||
val None = ImeSettler { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
|
||||
* and the padding can never disagree about whether the keyboard is gone.
|
||||
*
|
||||
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
|
||||
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
|
||||
* guards against, navigation still proceeds rather than stranding the user on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberImeSettler(): ImeSettler {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager) {
|
||||
ImeSettler {
|
||||
if (imeInsets.getBottom(density) > 0) {
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,6 @@ import kotlin.reflect.KClass
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
override val navigationScope: CoroutineScope,
|
||||
/**
|
||||
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
|
||||
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
|
||||
* through this class, so this is the one place that has to get it right — no screen, top bar
|
||||
* or back handler needs to think about the keyboard on its way out.
|
||||
*/
|
||||
private val ime: ImeSettler = ImeSettler.None,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
@@ -70,7 +63,6 @@ class Nav(
|
||||
|
||||
override fun nav(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
if (getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
@@ -79,7 +71,6 @@ class Nav(
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
val route = computeRoute()
|
||||
if (route != null && getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
@@ -89,7 +80,6 @@ class Nav(
|
||||
|
||||
override fun newStack(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
@@ -101,7 +91,6 @@ class Nav(
|
||||
|
||||
override fun navBottomBar(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
// Clear sibling bottom-nav entries but keep Home (the start
|
||||
// destination) below, so back-swipe from any tab returns to
|
||||
@@ -160,7 +149,6 @@ class Nav(
|
||||
|
||||
override fun popBack() {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -171,7 +159,6 @@ class Nav(
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(klass) { inclusive = true }
|
||||
}
|
||||
|
||||
+2
-3
@@ -29,10 +29,9 @@ import androidx.navigation.compose.rememberNavController
|
||||
fun rememberNav(): Nav {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val ime = rememberImeSettler()
|
||||
|
||||
return remember(navController, scope, ime) {
|
||||
Nav(navController, scope, ime)
|
||||
return remember(navController, scope) {
|
||||
Nav(navController, scope)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,8 +457,6 @@ sealed class Route {
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
|
||||
@Serializable object DrawerSettings : Route()
|
||||
|
||||
@Serializable object HomeTabsSettings : Route()
|
||||
|
||||
@Serializable object ProfileUiSettings : Route()
|
||||
|
||||
@@ -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.concord.concordBanTarget(note) != null }
|
||||
val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) }
|
||||
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
|
||||
val concordAdmin = remember(note) { accountViewModel.account.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.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0)
|
||||
wasZappedByLoggedInAccount = account.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.search.findUsersStartingWith(prefix, account),
|
||||
account.cache.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.concord.concordAdminTarget(note)
|
||||
val concordAdmin = accountViewModel.account.concordAdminTarget(note)
|
||||
if (concordAdmin != null) {
|
||||
val isAdmin = concordAdmin.third
|
||||
add(
|
||||
@@ -384,7 +384,7 @@ fun noteActionSections(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) {
|
||||
if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) {
|
||||
add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -46,103 +43,50 @@ fun FileHeaderDisplay(
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
// The sensitivity gate wraps both branches: a content warning is about the file, not about
|
||||
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
if (content == null) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
} else {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val thumbHash = event.thumbhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
|
||||
*
|
||||
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
|
||||
* video event the kind itself asserts nothing about how to render the payload. A null here means
|
||||
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
|
||||
*/
|
||||
internal fun FileHeaderEvent.toMediaContent(
|
||||
note: Note,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): BaseMediaContent? {
|
||||
val blurHash = blurhash()
|
||||
val thumbHash = thumbhash()
|
||||
val hash = hash()
|
||||
val dimensions = dimensions()
|
||||
val description = fileDescription()
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
return when (RichTextParser.classifyMedia(url, mimeType)) {
|
||||
MediaContentKind.IMAGE ->
|
||||
MediaUrlImage(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.VIDEO ->
|
||||
MediaUrlVideo(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.PDF ->
|
||||
MediaUrlPdf(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
|
||||
@Composable
|
||||
internal fun FileHeaderAttachmentCard(
|
||||
event: FileHeaderEvent,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val description = remember(event) { event.fileDescription() }
|
||||
val sizeInBytes = remember(event) { event.size()?.toLong() }
|
||||
|
||||
FileAttachmentCard(
|
||||
url = url,
|
||||
description = description,
|
||||
mimeType = mimeType,
|
||||
sizeInBytes = sizeInBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
|
||||
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
|
||||
|
||||
@@ -150,7 +150,7 @@ fun GoalProgressBar(
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
|
||||
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
|
||||
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
|
||||
if (percentage > 1) percentage = 1f
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -767,6 +766,27 @@ fun RenderSoftwareAsset(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
"application/x-apple-diskimage" -> "DMG"
|
||||
"application/vnd.apple.installer+xml" -> "PKG"
|
||||
"application/x-msi" -> "MSI"
|
||||
"application/vnd.appimage" -> "AppImage"
|
||||
"application/vnd.flatpak" -> "Flatpak"
|
||||
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
|
||||
"application/x-executable" -> "ELF"
|
||||
"application/x-mach-binary" -> "Mach-O"
|
||||
"application/vnd.microsoft.portable-executable" -> "EXE"
|
||||
"application/vsix" -> "VSIX"
|
||||
"application/x-chrome-extension" -> "CRX"
|
||||
"application/x-xpinstall" -> "XPI"
|
||||
"application/wasm" -> "WASM"
|
||||
"application/webbundle" -> "Web Bundle"
|
||||
else -> mime
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024L) return "$bytes B"
|
||||
val kb = bytes / 1024.0
|
||||
|
||||
@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -89,9 +88,7 @@ fun VideoDisplay(
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val description = videoEvent.content.ifBlank { null } ?: event.alt()
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
|
||||
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
if (isImage) {
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -56,9 +55,7 @@ fun JustVideoDisplay(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+57
-67
@@ -67,7 +67,6 @@ 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
|
||||
@@ -89,11 +88,11 @@ 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
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
@@ -549,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.concord.reactToConcordMessage(note, reaction) }
|
||||
launchSigner { account.reactToConcordMessage(note, reaction) }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -607,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.concord.concordBanTarget(note) ?: return
|
||||
launchSigner { account.concord.banConcordMember(communityId, member) }
|
||||
val (communityId, member) = account.concordBanTarget(note) ?: return
|
||||
launchSigner { account.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.concord.concordAdminTarget(note) ?: return
|
||||
val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return
|
||||
launchSigner {
|
||||
if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member)
|
||||
if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +624,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
makeAdmin: Boolean,
|
||||
) = launchSigner {
|
||||
if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member)
|
||||
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -641,7 +640,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
roleIds: List<String>,
|
||||
) = launchSigner {
|
||||
if (!account.concord.grantConcordRole(communityId, member, roleIds)) {
|
||||
if (!account.grantConcordRole(communityId, member, roleIds)) {
|
||||
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
|
||||
}
|
||||
}
|
||||
@@ -652,7 +651,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
ban: Boolean,
|
||||
) = launchSigner {
|
||||
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
|
||||
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -664,7 +663,7 @@ class AccountViewModel(
|
||||
communityId: String,
|
||||
member: HexKey,
|
||||
) = launchSigner {
|
||||
account.concord.refoundConcordCommunity(communityId, setOf(member))
|
||||
account.refoundConcordCommunity(communityId, setOf(member))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -684,7 +683,7 @@ class AccountViewModel(
|
||||
else -> emptyList()
|
||||
}
|
||||
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
account.concord.importConcordCommunities(pinnedRelays)
|
||||
account.importConcordCommunities(pinnedRelays)
|
||||
}
|
||||
|
||||
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
|
||||
@@ -692,12 +691,12 @@ class AccountViewModel(
|
||||
communityId: String,
|
||||
channelIdHex: String,
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
account.concord.sendConcordTyping(communityId, channelIdHex)
|
||||
account.sendConcordTyping(communityId, channelIdHex)
|
||||
}
|
||||
|
||||
fun sendBuzzTyping(channel: RelayGroupChannel) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.relayGroups.sendBuzzTyping(channel)
|
||||
account.sendBuzzTyping(channel)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@@ -844,7 +843,7 @@ class AccountViewModel(
|
||||
afterTimeInSeconds: Long,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
|
||||
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
|
||||
}
|
||||
|
||||
suspend fun calculateZapAmount(zappedNote: Note): String {
|
||||
@@ -855,7 +854,7 @@ class AccountViewModel(
|
||||
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
|
||||
return if (zappedNote.zapPayments.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val nwc = account.zaps.calculateZappedAmount(zappedNote)
|
||||
val nwc = account.calculateZappedAmount(zappedNote)
|
||||
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
|
||||
}
|
||||
} else {
|
||||
@@ -867,7 +866,7 @@ class AccountViewModel(
|
||||
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
|
||||
return if (zappedNote.zapPayments.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newZapAmount = account.zaps.calculateZappedAmount(zappedNote)
|
||||
val newZapAmount = account.calculateZappedAmount(zappedNote)
|
||||
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
|
||||
|
||||
if (percentage > 1) {
|
||||
@@ -1203,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.zaps.defaultWalletSupportsBolt12Pay()
|
||||
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
@@ -1215,7 +1214,7 @@ class AccountViewModel(
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
) = launchSigner {
|
||||
account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
account.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 ->
|
||||
@@ -1669,12 +1668,12 @@ class AccountViewModel(
|
||||
fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) }
|
||||
) = launchSigner { account.joinRelayGroup(channel, code) }
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) }
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
|
||||
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) }
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
|
||||
@@ -1683,7 +1682,7 @@ class AccountViewModel(
|
||||
fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) }
|
||||
) = launchSigner { account.archiveRelayGroup(channel, archived) }
|
||||
|
||||
/**
|
||||
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
|
||||
@@ -1722,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.relayGroups.hideBuzzDm(channel) }
|
||||
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
|
||||
|
||||
/**
|
||||
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
|
||||
@@ -1732,7 +1731,7 @@ class AccountViewModel(
|
||||
fun unhideBuzzDm(
|
||||
relay: NormalizedRelayUrl,
|
||||
participants: List<HexKey>,
|
||||
) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) }
|
||||
) = launchSigner { account.openBuzzDm(relay, participants) }
|
||||
|
||||
/**
|
||||
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
|
||||
@@ -1746,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.relayGroups.leaveRelayGroup(channel)
|
||||
account.leaveRelayGroup(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
@@ -1757,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.concord.leaveConcordCommunity(communityId) }
|
||||
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
|
||||
|
||||
fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -1772,7 +1771,7 @@ class AccountViewModel(
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.relayGroups.createRelayGroup(
|
||||
account.createRelayGroup(
|
||||
relay,
|
||||
groupId,
|
||||
name,
|
||||
@@ -1790,47 +1789,47 @@ class AccountViewModel(
|
||||
fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) }
|
||||
) = launchSigner { account.createRelayGroupInvite(channel, code) }
|
||||
|
||||
fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) }
|
||||
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
|
||||
|
||||
fun pinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
note: Note,
|
||||
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) }
|
||||
) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) }
|
||||
|
||||
fun unpinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
note: Note,
|
||||
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) }
|
||||
) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) }
|
||||
|
||||
fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
||||
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
|
||||
|
||||
fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) }
|
||||
) = launchSigner { account.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.relayGroups.addCommunityMember(relay, pubkey, role) }
|
||||
) = launchSigner { account.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.relayGroups.removeCommunityMember(relay, pubkey) }
|
||||
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
|
||||
|
||||
fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
@@ -1844,7 +1843,7 @@ class AccountViewModel(
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.relayGroups.editRelayGroupMetadata(
|
||||
account.editRelayGroupMetadata(
|
||||
channel,
|
||||
name,
|
||||
about,
|
||||
@@ -1987,15 +1986,6 @@ class AccountViewModel(
|
||||
|
||||
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
|
||||
|
||||
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
|
||||
|
||||
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
|
||||
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
|
||||
if (account.applyHiddenDrawerItems(items)) {
|
||||
launchSigner { account.sendNewAppSpecificData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun changeBottomBarItems(items: List<BottomBarEntry>) {
|
||||
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
|
||||
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
|
||||
@@ -2340,8 +2330,8 @@ class AccountViewModel(
|
||||
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
|
||||
)
|
||||
?: return
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
}
|
||||
|
||||
suspend fun sendMarmotGroupMediaMessage(
|
||||
@@ -2366,21 +2356,21 @@ class AccountViewModel(
|
||||
account.signer.pubKey,
|
||||
template,
|
||||
)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
}
|
||||
|
||||
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
|
||||
|
||||
suspend fun createMarmotGroup(nostrGroupId: String) {
|
||||
account.marmot.createMarmotGroup(nostrGroupId)
|
||||
account.createMarmotGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
suspend fun publishMarmotKeyPackage() {
|
||||
account.marmot.publishMarmotKeyPackage()
|
||||
account.publishMarmotKeyPackage()
|
||||
}
|
||||
|
||||
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
|
||||
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
|
||||
|
||||
/**
|
||||
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
|
||||
@@ -2404,12 +2394,12 @@ class AccountViewModel(
|
||||
}
|
||||
|
||||
suspend fun leaveMarmotGroup(nostrGroupId: String) {
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.leaveMarmotGroup(nostrGroupId, relays)
|
||||
}
|
||||
|
||||
suspend fun resetMarmotState() {
|
||||
account.marmot.resetMarmotState()
|
||||
account.resetMarmotState()
|
||||
}
|
||||
|
||||
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
|
||||
@@ -2417,30 +2407,30 @@ class AccountViewModel(
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: String,
|
||||
memberPubKey: String,
|
||||
): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
|
||||
): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
|
||||
|
||||
suspend fun removeMarmotGroupMember(
|
||||
nostrGroupId: String,
|
||||
targetLeafIndex: Int,
|
||||
) {
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
|
||||
}
|
||||
|
||||
suspend fun grantMarmotGroupAdmin(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
suspend fun revokeMarmotGroupAdmin(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2496,8 +2486,8 @@ class AccountViewModel(
|
||||
imageUploadKey = icon.upload.imageUploadKey,
|
||||
)
|
||||
}
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -2755,7 +2745,7 @@ class AccountViewModel(
|
||||
onSent: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
) = launchSigner {
|
||||
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
onSent()
|
||||
}
|
||||
|
||||
@@ -2811,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.zaps.createZapRequestFor(
|
||||
account.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.pruner.unlinkAndRemove(listOf(eventNote))
|
||||
cache.unlinkAndRemove(listOf(eventNote))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow(
|
||||
|
||||
// Rotate KeyPackages if needed
|
||||
if (result.needsKeyPackageRotation) {
|
||||
account.marmot.publishMarmotKeyPackages()
|
||||
account.publishMarmotKeyPackages()
|
||||
}
|
||||
|
||||
// Fire the "You've been added to <group>" notification. Welcomes
|
||||
|
||||
+1
-4
@@ -436,10 +436,7 @@ private fun AgentKeyPicker(
|
||||
delay(150)
|
||||
suggestions =
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.search
|
||||
.findUsersStartingWith(query.trim(), accountViewModel.account)
|
||||
.map { it.pubkeyHex }
|
||||
.take(8)
|
||||
LocalCache.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 },
|
||||
idleTimeoutMs = 8_000,
|
||||
timeoutMs = 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.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
|
||||
account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
|
||||
} else {
|
||||
account.relayGroups.fileBuzzJob(relay, channelId, text) != null
|
||||
account.fileBuzzJob(relay, channelId, text) != null
|
||||
}
|
||||
}
|
||||
|
||||
fun approve(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null }
|
||||
) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null }
|
||||
|
||||
fun deny(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null }
|
||||
) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null }
|
||||
|
||||
fun upvote(
|
||||
jobId: HexKey,
|
||||
jobAuthor: HexKey?,
|
||||
) = act({}) { account, relay, channelId ->
|
||||
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
true
|
||||
}
|
||||
|
||||
fun cancel(jobId: HexKey) =
|
||||
act({}) { account, relay, channelId ->
|
||||
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
|
||||
account.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 },
|
||||
idleTimeoutMs = 8_000,
|
||||
timeoutMs = 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, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 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.relayGroups.addBuzzDmMember(channel, hex)
|
||||
accountViewModel.account.addBuzzDmMember(channel, hex)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+4
-4
@@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
)
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { filters },
|
||||
idleTimeoutMs = 8_000,
|
||||
timeoutMs = 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, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 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.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
|
||||
account.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.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
|
||||
account.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.search
|
||||
LocalCache
|
||||
.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.relayGroups.openBuzzDm(relay, others)
|
||||
val channelId = account.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() {
|
||||
),
|
||||
),
|
||||
),
|
||||
idleTimeoutMs = 8_000,
|
||||
timeoutMs = 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())),
|
||||
),
|
||||
),
|
||||
idleTimeoutMs = 8_000,
|
||||
timeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
}
|
||||
|
||||
+3
-3
@@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() {
|
||||
|
||||
fun file(request: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.relayGroups.fileBuzzJob(relay, channelId, request)
|
||||
account.fileBuzzJob(relay, channelId, request)
|
||||
}
|
||||
|
||||
fun upvote(
|
||||
jobId: String,
|
||||
jobAuthor: String?,
|
||||
) = act { account, relay, channelId ->
|
||||
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
}
|
||||
|
||||
fun cancel(jobId: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
|
||||
account.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.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
|
||||
account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
|
||||
}
|
||||
|
||||
fun approve(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ ->
|
||||
account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null
|
||||
account.approveBuzzWorkflowRun(relay, runId) != null
|
||||
}
|
||||
|
||||
fun deny(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ ->
|
||||
account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null
|
||||
account.denyBuzzWorkflowRun(relay, runId) != null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml)
|
||||
val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml)
|
||||
withContext(Dispatchers.Main) { onResult(newId) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
|
||||
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
|
||||
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
|
||||
onSendNewMessage: () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
BackHandler {
|
||||
KeyboardAwareBackHandler {
|
||||
if (channelScreenModel.message.text.isNotBlank()) {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
|
||||
+4
-4
@@ -190,9 +190,9 @@ fun ConcordChannelListScreen(
|
||||
channelEditor = null
|
||||
scope.launch {
|
||||
if (editor.channelIdHex == null) {
|
||||
account.concord.createConcordChannel(communityId, newName)
|
||||
account.createConcordChannel(communityId, newName)
|
||||
} else {
|
||||
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
|
||||
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -208,7 +208,7 @@ fun ConcordChannelListScreen(
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
channelToDelete = null
|
||||
scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) }
|
||||
scope.launch { account.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.concord.mintConcordInvite(communityId)
|
||||
inviteLink = account.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.concord.sendConcordChannelImageMessage(community, channel, "", imetas)
|
||||
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
|
||||
}
|
||||
onUpload()
|
||||
},
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ fun ConcordCreateScreen(
|
||||
scope.launch {
|
||||
val communityId =
|
||||
try {
|
||||
accountViewModel.account.concord.createConcordCommunity(
|
||||
accountViewModel.account.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.concord.editConcordMetadata(
|
||||
account.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.concord.joinConcordViaInvite(link)) {
|
||||
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
|
||||
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
|
||||
is ConcordInviteResult.InvalidLink ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler(
|
||||
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return@LaunchedEffect
|
||||
account.concord.warmConcordChannelPreviews(listOf(entry))
|
||||
account.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.concord.warmConcordChannelPreviews(communities)
|
||||
account.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.concord.syncConcordControlPlanes(communities)
|
||||
if (communities.isNotEmpty()) account.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.concord.syncConcordControlPlanes(liveCommunities)
|
||||
account.syncConcordControlPlanes(liveCommunities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
|
||||
val editing = editingMessage.value
|
||||
if (editing != null) {
|
||||
account.concord.editConcordChannelMessage(editing, text)
|
||||
account.editConcordChannelMessage(editing, text)
|
||||
editingMessage.value = null
|
||||
} else {
|
||||
val parent = replyTo.value
|
||||
account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
|
||||
account.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.relayGroups.createRelayGroup(
|
||||
account.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.relayGroups.editRelayGroupMetadata(
|
||||
account.editRelayGroupMetadata(
|
||||
channel = existing,
|
||||
name = name,
|
||||
about = about,
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel :
|
||||
val pk = user.pubkeyHex
|
||||
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
||||
try {
|
||||
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
|
||||
accountViewModel.account.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}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user