diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 1af746f6dd..35eafb646f 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -85,3 +85,30 @@ skills verified clean): `ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed); Event Store section corrected from "Android only" to commonMain/all platforms with the real `store.sqlite.EventStore` import and suspend generic `query`. + +## 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. diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md new file mode 100644 index 0000000000..1c99e335a9 --- /dev/null +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -0,0 +1,325 @@ +--- +name: event-store-semantics +description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely. +--- + +# Event Store Semantics — the `IEventStore` / SQLite-store contract + +The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`) +is the de-facto **reference implementation** of what a Quartz event store must do. Other +implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by +`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed +store) — reimplement its *observable behavior* and assert parity in CI. This skill states that +behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an +archaeology session through `QueryBuilder`/`MergeQueryExecutor`. + +Every rule below was verified against the code as of this skill's last update. When you change +store behavior, **update the rule here in the same PR** and add a line to the +[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and +review pin bumps against this file. + +## Key files + +| Concern | File | +|---|---| +| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` | +| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` | +| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` | +| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` | +| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` | +| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` | +| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` | +| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) | +| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) | +| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) | + +Executable spec: the test suites in +`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`, +`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`, +`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`, +`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test, +the test wins — and this file has a bug to fix. + +## Kind classes (used throughout) + +- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`. +- **Ephemeral**: `20000 ≤ kind < 30000`. +- **Addressable**: `30000 ≤ kind < 40000`. +- Everything else is a regular event. + +--- + +## Filter matching (STORE-F) + +**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to +`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses +`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with +`created_at == since == until` matches. + +**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the +full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store. +(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they +simply never match.) + +**STORE-F03 — tag filter combination.** Within one tag name, values are **OR** +(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND** +(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands +**every listed value** be present on the event — one join + equality per value — and composes by +AND with any plain `tags` in the same filter. + +**STORE-F04 — only single-letter tag names are indexed (by default).** +`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`. +A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store. +Deployments can widen `shouldIndex`, but the stock contract is single-letter-only. + +**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the +`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences: +`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the +query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via +plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other +tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and +when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored. +(An event has one d-tag, so AND-across-values could never match anyway.) + +**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a +64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`, +`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification** +of hash matches, so a hash collision would return a false positive. Probability is negligible in +practice but nonzero — a parity harness comparing against an exact-match engine should know this +is the one place the reference can (theoretically) over-match. + +**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter +becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with +SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits +10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45 +counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`). + +**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC` +tiebreak on equal `created_at` is applied **only when +`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default +**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite +returns them in storage order). Any newest-N is valid; a parity suite must not assert +same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the +`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors +run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte +from the single-SQL ordering. + +**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape +"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through +`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind) +index cursors with dedup by id on the tag shape. This is an optimization, not a semantics +change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the +single-SQL plan (ordering caveat per STORE-F08). + +**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything** +(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric: +it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented +on `QueryBuilder.delete`). + +**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent +handling; don't rely on either outcome.** On the single-filter simple path an empty list +renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as +`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of +*only* empty-list filters degrades to the match-everything query. Known quirk; treat +empty-list filters as invalid input rather than replicating this shape. + +**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows. +`limit = null` means unbounded. Negative limits are not defended against (don't send them). + +**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.** +`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries; +it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work +targets the SQL semantics above, not `FilterMatcher`. + +--- + +## Write path (STORE-W) + +Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration +row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the +messages quoted below (they surface as the NIP-01 `OK false` reason). + +**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable +kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning +`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 +lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored +row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: +exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. + +**STORE-W02 — addressable supersession.** Same as W01 with unique index +`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the +*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't +parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique +indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""` +(empty string), which *does* dedupe normally. + +**STORE-W03 — ephemeral events are never stored but are acked as accepted.** +`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000` +without writing (the live relay stream still broadcasts them). A DB-level backstop trigger +(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check. + +**STORE-W04 — expired events are rejected at insert.** App-level check +(`event.isExpired()`) plus a trigger on the expiration-row insert +(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert` +**throws**; `batchInsert` returns `Rejected`. + +**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a +future `expiration` store a row in `event_expirations`. Nothing filters them out of queries +after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep +returns the expired event.** Operators run the sweep periodically (README recommends ~15 min). +Re-inserting an already-expired event after the sweep is rejected per W04. + +**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes +`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if +absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and +blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps +addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps +are also excluded from `authorsMissingOutbox()`.) + +**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated +(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`, +`event_expirations`, `event_vanish`, and the FTS row follow the header by +`ON DELETE CASCADE` / trigger. + +**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls +back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every +entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned +in input order; OK frames pair by event id, not order. + +--- + +## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D) + +**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose +owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06). +The id path has **no timestamp condition**: it deletes the target regardless of the relative +`created_at` values. + +**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that +`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at` — **inclusive**; a +version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's +author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same +`created_at <=` treatment against `(kind, pubkey)`. + +**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's +events is inserted like any regular event (it may be useful to other relays/clients) but its +delete pass removes zero rows and creates no blocking. + +**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects +(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is +named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`. +Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target) +still deletes on arrival, but would not block a later re-insert. + +**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.** +Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows +from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a +considered decision.** NIP-09 leaves it open; at least one external implementation +(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is +the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and +the changelog — parity suites key off it. + +**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when +`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`. +(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope +vanish events are stored as regular events with no side effects. + +**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose +**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the +vanish event itself survives), and blocks inserts of owned events with +`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is +inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey +(unique on `pubkey_hash`). + +**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking +created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10 +empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62 +events do that. + +--- + +## NIP-45 count (STORE-C) + +**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single +filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so +`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed +(dedup) **before** counting — an event matching several filters counts once. FTS-off + search +term → 0 (F-series search rules apply). + +--- + +## NIP-50 search inside the store (STORE-S) + +The indexing surface (which kinds are searchable, what text they contribute) is the +`searchable-events` skill; these rules are the store's query-side contract. + +**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs +`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are +removed before FTS. Unsupported extensions are **ignored, never matched as literal text and +never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores +that *do* implement extensions receive the raw string through the relay layer and parse it with +`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc). + +**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match +first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not +the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a +search term (best/min rank per event across branches); mixing search and non-search filters +falls back to `created_at DESC`. + +**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/ +since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions. + +**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to +FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics. +Tokenization details live in `FullTextSearchModule` (see `searchable-events`). + +**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a +non-empty search term matches **nothing** (query/count/delete alike); an empty-string search +imposes no constraint. Everything else is unchanged. + +**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does): +tokenization moves off the insert path to a watermark-driven catch-up +(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50 +results are exactly as fresh as the synchronous path. + +--- + +## Negentropy / NIP-77 (STORE-N) + +**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the +**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order +unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow +sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when +`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact +across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the +non-itemizable cases). + +--- + +## Configuration presets + +- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off, + `useAndIndexIdOnOrderBy` off, no live negentropy index. +- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and + tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves + `useAndIndexIdOnOrderBy` off. +- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the + index on next open (`ensureOptionalIndexes`), no migration. + +## For parity implementers + +- Treat the rule ids above as the vocabulary for divergence notes + (e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion"). +- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for + holding a second engine to it. +- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when + diffing results byte-for-byte — both are places where a "divergence" may be the reference's + own slack, not your bug. + +## Semantics changelog + +Add one line per behavior change, newest first: `YYYY-MM-DD — 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/`. diff --git a/.claude/skills/nip85-trusted-assertions/SKILL.md b/.claude/skills/nip85-trusted-assertions/SKILL.md new file mode 100644 index 0000000000..b0b17f22ea --- /dev/null +++ b/.claude/skills/nip85-trusted-assertions/SKILL.md @@ -0,0 +1,232 @@ +--- +name: nip85-trusted-assertions +description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store. +--- + +# NIP-85 Trusted Assertions — the Quartz model + +Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`. +NIP-85 is still an evolving spec; **this package is the operative definition** of what +Amethyst-family software writes and reads. This skill states the model (who signs what about +whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume. + +## The model in one paragraph + +An **assertion is signed by the asserting party** (a trust provider service, or the user +themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so +"latest card by provider P about subject S" is just the addressable coordinate +`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's +**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch +their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal +event signatures; the 10040→assertion link is **consumer-side convention** (see +"Authorization" below). + +## Kind map + +| Kind | Class | Kind class | d-tag = the subject | Content | +|---|---|---|---|---| +| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) | +| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) | +| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` | +| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` | +| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` | + +Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)` +(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses +`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers. + +`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary +tags plus topics; the encrypted card content is intentionally never indexed. + +## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`) + +There is **no fixed tag name**: `tag[0]` *is* the service string. + +```json +["30382:rank", "", "wss://nip85.brainstorm.world"] +``` + +- `ServiceType(kind, type)` parses/renders `":"` — 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\ | 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 = 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": "", + "tags": [ + ["30382:rank", "

", "wss://nip85.brainstorm.world"], + ["30382:followers", "

", "wss://nip85.brainstorm.world"] + ], + "content": "" } +``` + +Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:

:`): + +```json +{ "kind": 30382, "pubkey": "

", + "tags": [ + ["d", ""], + ["rank", "87"], ["followers", "1234"], ["hops", "2"] + ], + "content": "" } +``` + +`P` asserts about an event `E` (kind 30383, addressable at `30383:

:`): + +```json +{ "kind": 30383, "pubkey": "

", + "tags": [["d", ""], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]], + "content": "" } +``` + +Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe +`{kinds:[30382], authors:["

"], "#d":["", …]}` 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::`). + +## 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` — + 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`. diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index c1019cde8f..1b72fb6977 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -94,6 +94,32 @@ class MetadataFilterAssembler( Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey. +## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`) + +Prefer these over hand-rolled "load metadata for this list" calls. They are the shared, +KMP way to load data **only for what's on screen** — a composable subscribes while it is in +composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in +`commons/relayClient/`: + +- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)` + each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return + reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced + into one batched REQ per relay for the whole visible set. +- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a + note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's + `observeNote*` display observers layer on top of the same subscription. + +Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount` +(reused by the event finder) / `LocalEventFinder` — provided once near the composition root +(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam +is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`. +`error()` defaults mean these must never be reached from a composition without a relay client +(e.g. the Android `:napplet` sandbox). + +The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` / +`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only +as an optional off-screen background warmer. + ## Preloaders `MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks. diff --git a/.claude/skills/searchable-events/SKILL.md b/.claude/skills/searchable-events/SKILL.md new file mode 100644 index 0000000000..aad4744f68 --- /dev/null +++ b/.claude/skills/searchable-events/SKILL.md @@ -0,0 +1,117 @@ +--- +name: searchable-events +description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …). +--- + +# Searchable Events — the NIP-50 indexing surface + +## The contract + +`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`: + +```kotlin +interface SearchableEvent { + fun indexableContent(): String +} +``` + +One method; marker and extractor in one. An event kind is searchable **iff** its event class +implements this interface **and** the class is wired into `EventFactory` (the stores probe +searchability by kind through `EventFactory.create` — an unwired implementor is invisible). + +Rules every implementation follows (keep them when adding one): + +- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of + metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content + goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse + first and join the extracted fields**, never the raw JSON. +- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts + the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`. +- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only + the public petname/summary/topics, never the NIP-44 payload). +- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`; + `listOfNotNull(title(), summary(), content)`; lists index `title() + description()`. + +## The full kind table + +**`references/searchable-kinds.md`** in this skill holds the authoritative table — every +implementor with its kind number, class, and the exact `indexableContent()` expression +(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to +answer "did the searchable set or any kind's indexed text change?". + +Notables that surprise people: + +- **Kind 9735 (zap receipt) indexes the embedded zap request's content** + (`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment. +- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16, + website, picture URL, …). +- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental + `SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so + `title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()` + is dead on the store path. +- Poll kinds (1068, 6969) append each option label on its own line. + +## MANDATORY maintenance when you touch this surface + +Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body: + +1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g. + the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent + change ships them stale search results). +2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or + missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method + is the contract. App-side, schedule the resumable overload after shipping such a change. +3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind + pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them. + +Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful +text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps +minus their comment, relay lists) stay out to keep the index small. + +## How the stores consume it + +**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`): +`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` — +contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On +insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind +`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's +default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated), +ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering, +extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the +`event-store-semantics` skill. + +**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes +`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase); +the same tokenizer runs on queries so drift cancels. + +## NIP-50 client side + +**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string +into `terms` + `extensions`. A whitespace token is an extension iff it looks like +`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys +keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors: +`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` / +`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported +extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained +query, never match-nothing. A server-side store that implements its own extensions +(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should +parse with `SearchQuery.parse` so its syntax stays compatible with what clients send. + +**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public +tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption: +`commons/.../actions/SearchActions.kt`, bootstrap defaults in +`commons/.../account/AccountBootstrapEvents.kt`. + +Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed +query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string. + +## Tests (executable spec) + +- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token. +- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields, + 40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex, + FTS cleanup on replaceable rotation. +- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering, + limit-after-score, multi-filter rank union. +- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract. +- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store. diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md new file mode 100644 index 0000000000..ccae95ebfb --- /dev/null +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -0,0 +1,166 @@ +# Searchable kinds — the authoritative implementor table + +Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()` +expression. **Update this file in the same PR as any change to the searchable set or to an +`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04. + +Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds; +kind 30063 has a collision — see the footnote). File paths are under +`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. + +Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. + +| Kind | Class | Package | `indexableContent()` | +|---|---|---|---| +| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) | +| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL | +| 9 | ChatEvent | nipC7Chats | `content` | +| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL | +| 14 | ChatMessageEvent | nip17Dm/messages | `content` | +| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL | +| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL | +| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL | +| 24 | PublicMessageEvent | nipA4PublicMessages | `content` | +| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) | +| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) | +| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` | +| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL | +| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) | +| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL | +| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL | +| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` | +| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL | +| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL | +| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL | +| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL | +| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` | +| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL | +| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` | +| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL | +| 1617 | GitPatchEvent | nip34Git/patch | `content` | +| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL | +| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL | +| 1622 | GitReplyEvent | nip34Git/reply | `content` | +| 1630–1633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) | +| 1808 | AudioHeaderEvent | experimental/audio/header | `content` | +| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL | +| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL | +| 2004 | TorrentCommentEvent | nip35Torrents | `content` | +| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL | +| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` | +| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) | +| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) | +| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` | +| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` | +| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` | +| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` | +| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` | +| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL | +| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL | +| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` | +| 9734 | LnZapRequestEvent | nip57Zaps | `content` | +| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content | +| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` | +| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` | +| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL | +| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | +| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` | +| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL | +| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL | +| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL | +| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | +| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL | +| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | +| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL | +| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL | +| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL | +| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL | +| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL | +| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL | +| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL | +| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` | +| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` | +| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` | +| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` | +| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL | +| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL | +| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL | +| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL | +| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL | +| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` | +| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` | +| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` | +| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL | +| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL | +| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL | +| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL | +| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL | +| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL | +| 30315 | StatusEvent | nip38UserStatus | `content` | +| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content | +| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL | +| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL | +| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL | +| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL | +| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL | +| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL | +| 31871 | AttestationEvent | experimental/attestations/attestation | `content` | +| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` | +| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL | +| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` | +| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL | +| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL | +| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL | +| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` | +| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) | +| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL | +| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL | +| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL | +| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL | +| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL | +| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | +| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL | +| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` | +| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL | +| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) | +| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL | +| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL | +| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL | +| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL | +| 40002 | StreamMessageV2Event | buzz/stream | `content` | +| 40100 | CanvasEvent | buzz/stream | `content` | +| 45001 | ForumPostEvent | buzz/forum | `content` | +| 45003 | ForumCommentEvent | buzz/forum | `content` | +| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` | + +† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also +declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps +30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes +`title()\ndescription()`. If the factory mapping ever changes, this table changes with it. + +## Abstract bases (no kind of their own) + +| Base class | Body | Concrete kinds | +|---|---|---| +| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 | +| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 | +| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 | + +## How to regenerate / verify this table + +```bash +# All implementor files: +grep -rln "override fun indexableContent" quartz/src/commonMain +# For each, pair the KIND constant with the indexableContent() body. +# Searchability on the store path additionally requires EventFactory registration: +grep -n "" 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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a0da455f5..105856cd45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -69,7 +69,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -126,7 +126,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -161,7 +161,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -220,7 +220,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 19ca1146e8..72643e9e40 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -26,8 +26,12 @@ env: # bundle deps — that fights jpackage's self-contained JRE (libjvm.so has # $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only # embeds the AppDir as-is, which is what we actually want. - APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage - APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + # + # Both arch binaries come from the same appimagetool release so their SHA256 + # values move in lockstep on version bumps. + APPIMAGETOOL_VERSION: '1.9.0' + APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96 jobs: # --------------------------------------------------------------------------- @@ -38,11 +42,32 @@ jobs: strategy: fail-fast: false matrix: + # Linux legs run on x64 and arm64 GitHub-hosted runners (the + # ubuntu-24.04-arm label is a standard free public-repo runner as of + # early 2025). Windows arm64 uses windows-11-arm, added to the free + # public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64). + # jpackage / jlink / Compose Multiplatform 1.11 all produce + # host-native artifacts — no cross-compilation needed. + # + # The arm64 Windows leg builds the portable .zip ONLY — no MSI. + # jpackage --type msi shells out to WiX 3's heat/candle/light, and the + # windows-11-arm runner image ships no WiX (the windows-latest image + # has WiX 3.14 preinstalled, which is why the x64 leg can package an + # MSI). Installing it here would mean pulling an archived, x86-only + # toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped + # the candle/light CLI that JDK 21's jpackage requires) into the job + # that publishes signed release assets. The portable zip is the + # documented Windows install path for amy/geode already, so arm64 + # Windows users get that until either the runner image gains WiX or + # jpackage learns the WiX 4+ CLI. include: - - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } + - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } runs-on: ${{ matrix.os }} timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle defaults: @@ -53,7 +78,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -93,13 +118,21 @@ jobs: set -euo pipefail # appimagetool 1.9.0 validates the .desktop file via desktop-file-validate. sudo apt-get update && sudo apt-get install -y desktop-file-utils - curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage - actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}') - if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then - echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual" + # Map runner arch → upstream AppImage suffix (x86_64 / aarch64). + case "${{ matrix.arch }}" in + x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;; + arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;; + *) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;; + esac + URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage" + DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage" + curl -fsSL --retry 3 "$URL" -o "$DEST" + actual=$(sha256sum "$DEST" | awk '{print $1}') + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual" exit 1 fi - chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + chmod +x "$DEST" # Flatpak tooling + the freedesktop runtime/sdk the manifest pins # (runtime-version is greped from the manifest so this never drifts). @@ -203,17 +236,32 @@ jobs: chmod +x scripts/relax-deb-libicu.sh scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + # jpackage --type deb only auto-generates Depends from dpkg-shlibdeps + # against the bundled JRE under lib/runtime/, NOT the app payload under + # lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike + # the x64 skiko which only links libGL.so.1), so a minimal aarch64 + # install without EGL crashes at startup with: + # UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file + # Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched. + - name: Add libegl1 dep to arm64 .deb + if: matrix.family == 'linux' && matrix.arch == 'arm64' + run: | + set -euo pipefail + chmod +x scripts/add-deb-libegl-dep.sh + scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Build portable archives (windows + linux-portable) if: matrix.family == 'windows' || matrix.family == 'linux-portable' run: | set -euo pipefail VER="${{ steps.ver.outputs.version }}" + ARCH="${{ matrix.arch }}" APP="desktopApp/build/compose/binaries/main-release/app" mkdir -p desktopApp/build/portable if [[ "${{ matrix.family }}" == "windows" ]]; then - ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ ) + ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ ) else - ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ ) fi # Flatpak bundle: wraps the same createReleaseDistributable tree the @@ -230,6 +278,17 @@ jobs: PKG="desktopApp/packaging/flatpak" APP_ID="com.vitorpamplona.amethyst.Desktop" OUT="desktopApp/build/flatpak" + # AppImage-style arch names for the bundle filename. + case "${{ matrix.arch }}" in + x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;; + arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;; + *) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;; + esac + # Rewrite the arch-specific GStreamer plugin path in the manifest + # (checked-in default is x86_64-linux-gnu). Idempotent — the sed only + # matches the original triplet. + sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \ + "${PKG}/${APP_ID}.yml" # Inject the AppStream entry for this build (the checked-in # metainfo deliberately carries none — CI is the source of truth). sed -i "s||\n |" \ @@ -241,7 +300,7 @@ jobs: "${OUT}/build-dir" \ "${PKG}/${APP_ID}.yml" flatpak build-bundle "${OUT}/repo" \ - "${OUT}/Amethyst-${VER}-x86_64.flatpak" \ + "${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \ "$APP_ID" \ --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo ls -la "$OUT" @@ -285,7 +344,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -325,8 +384,17 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + # Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types + # and jpackageMsi for a CLI is deferred (the portable zip is the + # documented Windows install path). The launcher script writes both + # `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs + # under bash on GH windows runners (git-bash is on PATH). collect_cli_assets + # zips the image on Windows instead of tar.gz. + - { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -337,7 +405,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -536,7 +604,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -574,8 +642,16 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + # Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is + # deferred (portable zip covers the primary use — operators still + # deploy geode via the Docker image or the tarball on Linux). The + # image writes both `bin/geode` (sh) and `bin/geode.bat`, and the + # smoke test below runs under bash on the windows runner. + - { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -586,7 +662,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -630,12 +706,23 @@ jobs: # module list is complete for the real relay path (Ktor CIO + SQLite + # NIP-11 serialization) — a too-tight module list links fine but fails # here with NoClassDefFound instead of on an operator's machine. + # + # On the Windows legs we invoke bin/geode.bat instead of bin/geode. The + # tmp path also differs between git-bash on Windows (which resolves /tmp + # to a mingw path that curl -o accepts) and POSIX runners; kept identical + # because the workflow's `defaults.run.shell: bash` uses git-bash on + # Windows and /tmp is a valid mingw path there. - name: Smoke-test the geode image run: | set -euo pipefail IMG="geode/build/geode-image/geode" - "$IMG/bin/geode" --version - "$IMG/bin/geode" --port 17447 & + if [[ "${{ matrix.family }}" == "windows" ]]; then + LAUNCHER="$IMG/bin/geode.bat" + else + LAUNCHER="$IMG/bin/geode" + fi + "$LAUNCHER" --version + "$LAUNCHER" --port 17447 & PID=$! ok=0 for i in $(seq 1 20); do @@ -777,7 +864,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -831,17 +918,17 @@ jobs: echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: geode/Dockerfile @@ -866,7 +953,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -1010,7 +1097,7 @@ jobs: fi - name: Upload Android assets to GH Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 0b51683681..57b992ae73 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -49,16 +49,24 @@ jobs: # package, installs it, and verifies the process stays alive for 10s. # Catches ProGuard stripping (JNI, reflection), missing jlink modules # (java.management, java.prefs), and native lib bundling issues. + # + # Runs on both x64 and arm64 hosted runners so release-time arm64 breakage + # (e.g. ProGuard rules missing an arch-specific reflection root) is caught + # at PR time instead of on the tag build. # ------------------------------------------------------------------------- release-deb-launch: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -84,13 +92,29 @@ jobs: chmod +x scripts/relax-deb-libicu.sh scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + # Mirrors the same step in create-release.yml so this job exercises the + # exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in + # DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without + # this the arm64 app dies at startup with + # UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file + # See scripts/add-deb-libegl-dep.sh for the full rationale. + - name: Add libegl1 dep to arm64 .deb + 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: Install .deb run: | + # Installed via apt (not `dpkg -i`) so the .deb's declared Depends are + # actually resolved — that is what pulls in libegl1 on the arm64 + # runner, which does not ship it preinstalled. + # # jpackage's post-install script runs xdg-desktop-menu which fails # on CI runners ("No writable system menu directory"). The files are # extracted successfully; only the menu registration fails. Allow the - # dpkg error, then verify the binary was actually installed. - sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true + # install error, then verify the binary was actually installed. + sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true echo "Installed files:" dpkg -L amethyst | head -30 # Fail if the binary wasn't actually extracted @@ -139,5 +163,6 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: Release DEB (smoke-tested) + # Artifact names must be unique across a run — disambiguate per arch. + name: Release DEB (smoke-tested, ${{ matrix.os }}) path: desktopApp/build/compose/binaries/main-release/deb/*.deb diff --git a/BUILDING.md b/BUILDING.md index 5a04173603..aa29c7ba11 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -35,7 +35,12 @@ All platforms: Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) -- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` +- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`. + Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner + and produce the portable `.zip` only — that image ships no WiX, so CI cannot + package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with + WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries + themselves are x86 and run under emulation). - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + `flatpak-builder` for the Flatpak bundle (see @@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified): # Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry sudo apt-get install -y desktop-file-utils -curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \ - https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage -chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage +# createReleaseAppImage picks appimagetool-.AppImage matching the JVM's +# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM). +ARCH="$(uname -m)" +curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage" +chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" ``` --- @@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources. | Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` | | Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | | Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | -| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | -| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) | +| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-.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-*-.flatpak` (CI; x86_64 or aarch64) | | Windows `.zip` portable | See below (inline `7z`) | — | | Linux `.tar.gz` portable | See below (inline `tar`) | — | @@ -325,14 +333,27 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify** — the GH Release should hold **31 assets**: - - **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`, - `AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS - DMG** — `jpackage` cannot cross-compile and no Intel runner leg is - configured, so macOS ships arm64-only. +4. **Verify** — the GH Release should hold **47 assets**: + - **14 desktop**, one per matrix leg × format: + - macOS arm64: `dmg` (1) + - Windows x64: `msi` + portable `zip` (2) + - Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below + - Linux x64 / arm64: `deb` + `rpm` (4) + - Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6) + + There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile + and no Intel runner leg is configured, so macOS ships arm64-only. + There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to + WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image + ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why + the x64 leg gets an MSI). Revisit if that image gains WiX, or if + jpackage learns the WiX 4+ `wix build` CLI. - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid `.apks` set built for Accrescent. - - **5 amy** + **5 geode** bundles. + - **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64), + `deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the + one arch-independent no-JRE `amy--jvm.tar.gz` for Homebrew-core. + - **10 geode** — same shape as amy. - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Android flow unchanged diff --git a/amethyst/plans/2026-07-29-location-foreground-gate-impl.md b/amethyst/plans/2026-07-29-location-foreground-gate-impl.md new file mode 100644 index 0000000000..2f4afdb777 --- /dev/null +++ b/amethyst/plans/2026-07-29-location-foreground-gate-impl.md @@ -0,0 +1,1457 @@ +# Location Foreground Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop Amethyst holding a location registration when no activity is started, register on one appropriate provider instead of four, and make `location.ms` measure real listening time. + +**Architecture:** A three-state gate over *permission × foreground* inside `LocationState` replaces the permission-only gate, so the registration is released whenever the app is backgrounded. `LocationFlow` selects one provider from an ordered ladder instead of shotgunning `allProviders`, and owns the `onListening` hook so accounting cannot start without a live registration. A `RefCountedSession` serialises the meter's refcount with the transition it drives. + +**Tech Stack:** Kotlin, kotlinx.coroutines Flow/StateFlow, `android.location.LocationManager` (no Play Services), JUnit 4 + MockK + kotlinx-coroutines-test. + +**Design spec:** `amethyst/plans/2026-07-29-location-foreground-gate.md` — read it before starting. This plan implements it; where the two disagree, the spec is wrong and should be corrected. + +## Global Constraints + +- Module is `amethyst` (Android only). Package root `com.vitorpamplona.amethyst`. +- `minSdk = 26`, `targetSdk = 37`, `compileSdk = 37` (`gradle/libs.versions.toml:12-14`). +- Every new `.kt` file starts with the MIT licence header — copy it verbatim from `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt:1-20`, changing nothing. +- Logging uses `com.vitorpamplona.quartz.utils.Log`, never `android.util.Log`. Use the lambda overload (`Log.d("Tag") { "msg $x" }`) when the message interpolates **and there is no throwable**. When a throwable must be logged, use the three-argument form `Log.w("Tag", "message", e)` — the lambda overloads take no `Throwable` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt:74-79`), so "converting" such a call to a lambda silently discards the stack trace. That is the exact anti-pattern the repo's `find-non-lambda-logs` skill flags. +- Never pass `--no-verify` to `git commit`. The pre-commit hook runs `./gradlew spotlessCheck` and nothing else — no compilation — so it passes even at the two points in this plan where the module is temporarily red. +- No new third-party dependencies. All libraries used here are already declared. +- Never write a fully-qualified class name inline in a function body — add an `import`. +- Run `./gradlew spotlessApply` before every commit. +- Do not push and do not open a PR. Commit locally only. +- Unit tests live in `amethyst/src/test/java/...`, run with `./gradlew :amethyst:testPlayDebugUnitTest`. + +## Naming contract + +These names are used across tasks. Use them exactly. + +| Name | Defined in | Signature | +|---|---|---| +| `RefCountedSession` | Task 2 | `class RefCountedSession(setSessionActive: (Boolean) -> Unit)`, method `fun setActive(active: Boolean)` | +| `LocationProviderLadder.chooseProviders` | Task 3 | `fun chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): List` | +| `LocationFlow` | Task 4 | `class LocationFlow(locationManager: LocationManager, sdkInt: Int = Build.VERSION.SDK_INT, hasFine: Boolean = false)`, method `fun get(minTimeMs: Long, minDistanceM: Float, onListening: ((Boolean) -> Unit)? = null): Flow` | +| `LocationState` | Task 5 | `class LocationState(context: Context, scope: CoroutineScope, isForeground: StateFlow, onListening: ((Boolean) -> Unit)? = null, locationSource: (Long, Float) -> Flow = …)` | +| `LocationState.COARSE_MIN_TIME` | Task 5 | `const val = 60_000L` | +| `LocationState.COARSE_MIN_DISTANCE` | Task 5 | `const val = 500.0f` | +| `LocationState.PRECISE_MIN_TIME` | Task 5 | `const val = 10_000L` | +| `LocationState.PRECISE_MIN_DISTANCE` | Task 5 | `const val = 100.0f` | +| `LocationState.BACKGROUND_GRACE_MS` | Task 5 | `const val = 5_000L` | + +`LocationState.MIN_TIME` and `LocationState.MIN_DISTANCE` are **deleted** in Task 5. Their only readers are `LocationState.kt:80`, `:119` and `LocationFlow.kt:29-30,42-43`, all rewritten by Tasks 4–5. + +## File structure + +| File | Task | Responsibility | +|---|---|---| +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` | 2 | Serialise a refcount with the boolean transition it drives | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` | 2 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` | 3 | Pure provider-selection policy | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` | 3 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` | 4 | Own the OS registration and the paired listening hook | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` | 4 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` | 5 | Gate on permission × foreground; expose the two geohash StateFlows | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` | 5 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt` | 6 | Wire the foreground signal and the refcounted meter | + +--- + +### Task 1: Verify Hypothesis H1 on an API 26 emulator + +The spec's §H1 predicts that today's `allProviders` loop throws `SecurityException` on `gps`, `passive` and `fused` below API 31, because those required `ACCESS_FINE_LOCATION` before Android 12 and Amethyst holds coarse only. If true, location is **already broken** on Android 8–11 and this change fixes it — which belongs in the PR description. If false, the PR must not claim it. + +This task changes no production code. It is first because the spec says to verify before implementing, and because the answer decides one paragraph of the PR. + +**Files:** +- Modify (temporarily, reverted in Step 5): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt:55` + +**Interfaces:** +- Consumes: nothing +- Produces: a written observation recorded in Step 6; no code + +- [ ] **Step 1: Boot the API 26 emulator** + +```bash +emulator -avd Medium_Phone_API_26_8_ -no-snapshot-load & +adb wait-for-device +adb shell getprop ro.build.version.sdk +``` + +Expected: prints `26`. + +If the AVD fails to boot, stop and report it. Do not substitute a different API level without saying so — the claim is specifically about API 26–30. + +- [ ] **Step 2: Add a temporary log of the provider order** + +`LocationFlow.kt`, immediately before the `locationManager.allProviders.forEach {` line (currently line 55), insert: + +```kotlin + Log.w("LocationFlowH1") { "allProviders order = ${locationManager.allProviders}" } +``` + +This is the ordering evidence the spec requires: the leak consequence only occurs if `network` precedes a fine-only provider, so the PR cannot claim a leak without seeing the order. + +- [ ] **Step 3: Install and grant coarse location only** + +```bash +./gradlew :amethyst:installPlayDebug +adb shell pm grant com.vitorpamplona.amethyst android.permission.ACCESS_COARSE_LOCATION +adb logcat -c +``` + +- [ ] **Step 4: Trigger a location subscription and capture the result** + +Launch the app, sign in to any account, and open the top-nav filter spinner on Home, selecting "Around Me". Then: + +```bash +adb logcat -d | grep -E "LocationFlowH1|SecurityException|LocationFlow" +``` + +Record verbatim: +1. the `allProviders order = [...]` line +2. whether a `SecurityException` appears, and which provider it names +3. whether any `Requesting Updates` line precedes the exception + +- [ ] **Step 5: Revert the temporary log** + +```bash +git checkout -- amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +git status --short +``` + +Expected: no changes to `LocationFlow.kt`. + +- [ ] **Step 6: Record the observation in the spec** + +Append to the `## Hypothesis H1` section of `amethyst/plans/2026-07-29-location-foreground-gate.md`, replacing `<...>` with what Step 4 actually showed: + +```markdown +### H1 verification result (2026-07-29, Medium_Phone_API_26_8_, API 26) + +- `allProviders` order: `` +- `SecurityException`: `` +- Registration attempted before the throw: `` + +Conclusion: location is `` on API 26 with coarse-only +permission; registrations `` leak. +``` + +- [ ] **Step 7: If H1 is FALSE, amend the plan before continuing** + +The spec says the design is "correct either way", which is true only in one +direction. Task 3 hard-codes H1's conclusion in `COARSE_ONLY_LEGACY_LADDER` and +two of its tests. If Step 4 showed **no** `SecurityException`, then coarse +permission does reach `gps`/`fused`/`passive` below API 31 on this build, and +restricting pre-31 devices to `network` would be a conclusion drawn from +evidence that contradicts it. It is safe for `geohashStateFlow` — `network` is +the right provider for a 5 km cell either way — but it needlessly denies +`preciseGeohashStateFlow` any provider capable of building-level precision on +Android 8–11. + +So, **only if no `SecurityException` appeared**, make these three amendments +before starting Task 3, and say in the commit message that H1 was disproved: + +1. In Task 3's implementation, delete `COARSE_ONLY_LEGACY_LADDER` and the + `sdkInt`/`hasFine` branch. `chooseProviders` becomes + `fun chooseProviders(exists: (String) -> Boolean): List = FULL_LADDER.filter(exists)`. +2. In Task 3's test, delete `coarseOnlyBelowApi31GetsNetworkOnly` and + `coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing`, and drop the + `sdkInt`/`hasFine` arguments from the remaining four. +3. In Task 4, drop the `sdkInt` and `hasFine` constructor parameters from + `LocationFlow` and the corresponding arguments from its six tests. +4. In Task 4's `get`, update the call site to match amendment 1: + `LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers }` + becomes `LocationProviderLadder.chooseProviders { it in providers }`. + +The per-rung `SecurityException` fall-through stays in **both** cases — it is +what makes the ladder robust to whatever the runtime check actually does, and +it is why H1 being wrong costs nothing. + +- [ ] **Step 8: Commit** + +```bash +git add amethyst/plans/2026-07-29-location-foreground-gate.md +git commit -m "docs(amethyst): record H1 verification result on API 26" +``` + +--- + +### Task 2: RefCountedSession + +**Files:** +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: `class RefCountedSession(setSessionActive: (Boolean) -> Unit)` with `fun setActive(active: Boolean)`. Task 6 constructs it as `RefCountedSession(locationSession::setActive)`. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` with the MIT header (copy `LocationState.kt:1-20` verbatim), then: + +```kotlin +package com.vitorpamplona.amethyst.service.resourceusage + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RefCountedSessionTest { + @Test + fun overlappingHoldersKeepTheSessionOpenAndReportOnlyTransitions() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) // holders 1 — inactive -> active + session.setActive(true) // holders 2 — a second listener joins, no transition + session.setActive(false) // holders 1 — the first one leaves, still active + + assertEquals("only the 0 -> 1 edge is a transition", listOf(true), calls) + + session.setActive(false) // holders 0 — the last one leaves + + assertEquals(listOf(true, false), calls) + } + + @Test + fun unmatchedReleaseDoesNotDriveTheCountNegative() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(false) + session.setActive(false) + + assertEquals("releasing an idle session is a no-op", emptyList(), calls) + + // If the count had gone to -2, one acquire would leave it at -1 and + // report inactive. It must open the session instead. + session.setActive(true) + + assertEquals(listOf(true), calls) + } + + @Test + fun aSingleHolderOpensAndClosesTheSession() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) + session.setActive(false) + + assertEquals(listOf(true, false), calls) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*RefCountedSessionTest*' +``` + +Expected: FAIL — compilation error, `Unresolved reference: RefCountedSession`. + +- [ ] **Step 3: Write the implementation** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.resourceusage + +/** + * Refcounts a boolean session so overlapping holders don't close each other's + * segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState] + * exposes two independent location flows that can both be listening at once — + * the "Around Me" feed plus an open geohash chat — and a bare + * [SessionTimeIntegrator] would close the segment when either one stops. + * + * The count and the transition it drives are taken under one lock. An + * [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is + * not enough: two threads can leave the counter at 1 while the last + * `setActive(false)` lands after the `setActive(true)`, latching the session + * off with a holder still active. + * + * Takes the setter as a lambda rather than a [SessionTimeIntegrator] because + * that is all it needs — and because constructing a real integrator drags in a + * [ResourceUsageAccountant] and a store file to observe one boolean. + * + * Reports **transitions only**, not every call. A 1 -> 2 acquire would otherwise + * re-enter [SessionTimeIntegrator.setActive] with the session already open, + * splitting one segment into two. That happens to be arithmetically harmless + * (`account()` adds each piece, and the pieces are contiguous), and it does not + * inflate a `*.starts` counter either, because [SessionTimeIntegrator] already + * guards its starts increment on `prev == null`. Transition-only is simply the + * contract the name implies, and it keeps the class honest for a future caller + * that reacts to the callback rather than integrating it. + * + * Releases must be paired with acquires. This class cannot tell an unpaired + * release from a real one, so callers guarantee the pairing; see `LocationFlow`, + * which throws rather than reaching `awaitClose` when nothing registered. + */ +class RefCountedSession( + private val setSessionActive: (Boolean) -> Unit, +) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) { + synchronized(lock) { + val wasActive = holders > 0 + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + val isActive = holders > 0 + if (isActive != wasActive) setSessionActive(isActive) + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*RefCountedSessionTest*' +``` + +Expected: PASS, 3 tests. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt +git commit -m "feat(amethyst): add RefCountedSession for overlapping ledger holders" +``` + +--- + +### Task 3: Provider ladder + +**Files:** +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: `LocationProviderLadder.chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): List`. Task 4 calls it. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocationProviderLadderTest { + private val all = setOf("fused", "network", "gps", "passive") + + @Test + fun modernDevicePrefersFusedThenFallsBackInOrder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 31, hasFine = false) { it in all }, + ) + } + + @Test + fun missingProvidersAreFilteredOutButOrderIsKept() { + val present = setOf("network", "passive") + + assertEquals( + listOf("network", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { it in present }, + ) + } + + @Test + fun coarseOnlyBelowApi31GetsNetworkOnly() { + // gps, passive and fused all required ACCESS_FINE_LOCATION before + // Android 12 (see Hypothesis H1 in the design spec). + assertEquals( + listOf("network"), + LocationProviderLadder.chooseProviders(sdkInt = 30, hasFine = false) { it in all }, + ) + } + + @Test + fun fineBelowApi31GetsTheFullLadder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 26, hasFine = true) { it in all }, + ) + } + + @Test + fun coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing() { + val present = setOf("gps", "passive") + + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present }, + ) + } + + @Test + fun noProvidersAtAllGetsNothing() { + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false }, + ) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationProviderLadderTest*' +``` + +Expected: FAIL — compilation error, `Unresolved reference: LocationProviderLadder`. + +- [ ] **Step 3: Write the implementation** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.location.LocationManager +import android.os.Build + +/** + * Picks which location providers to try, in order. + * + * Deliberately selects on **provider existence**, never on + * [LocationManager.isProviderEnabled]. A registration on a disabled provider + * goes live by itself when the user enables location — including from the + * quick-settings shade without leaving the app, which is exactly what someone + * does after seeing an empty "Around Me" feed. An enabled-state guard evaluated + * once at subscription start would lose that. + * + * Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`; + * only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which + * lets a coarse-only app request any provider and receive a fuzzed result, is an + * Android 12 change. Amethyst declares coarse only, so [hasFine] is always false + * in production — it is a parameter so the function is total over the permission + * axis and both sides of the API branch are testable, not because fine access is + * anticipated. + * + * Returns the ordered candidate list rather than a single choice so the caller + * can fall through to the next rung if a registration is refused. An empty list + * means no compatible provider exists. + */ +object LocationProviderLadder { + // Compile-time String constants, inlined by the compiler, so naming + // FUSED_PROVIDER (added in API 31) is safe on older runtimes. + private val FULL_LADDER = + listOf( + LocationManager.FUSED_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER) + + fun chooseProviders( + sdkInt: Int, + hasFine: Boolean, + exists: (String) -> Boolean, + ): List { + val ladder = + if (sdkInt >= Build.VERSION_CODES.S || hasFine) { + FULL_LADDER + } else { + COARSE_ONLY_LEGACY_LADDER + } + + return ladder.filter(exists) + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationProviderLadderTest*' +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt +git commit -m "feat(amethyst): add location provider ladder" +``` + +--- + +### Task 4: LocationFlow — one provider, paired listening hook + +**Files:** +- Modify (full rewrite of the class body): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` + +**Interfaces:** +- Consumes: `LocationProviderLadder.chooseProviders` (Task 3) +- Produces: `class LocationFlow(locationManager: LocationManager, sdkInt: Int = Build.VERSION.SDK_INT, hasFine: Boolean = false)` with `fun get(minTimeMs: Long, minDistanceM: Float, onListening: ((Boolean) -> Unit)? = null): Flow`. Task 5 constructs it. + +The constructor takes a `LocationManager`, **not** a `Context`, so it can be tested with a MockK stub. `LocationState` does the `getSystemService` lookup. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationFlowTest { + /** + * A LocationManager that reports [providers] and refuses [denied] with a + * SecurityException, mimicking the pre-API-31 fine-location requirement. + */ + private fun manager( + providers: List, + denied: Set = emptySet(), + ): LocationManager { + val lm = mockk(relaxed = true) + every { lm.allProviders } returns providers + every { lm.getLastKnownLocation(any()) } returns null + every { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } answers { + val provider = firstArg() + if (provider in denied) throw SecurityException("denied: $provider") + } + return lm + } + + @Test + fun firesNeitherEdgeWhenNoProviderExists() = + runTest { + val edges = mutableListOf() + val flow = LocationFlow(manager(providers = emptyList()), sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun firesNeitherEdgeWhenEveryRungIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused", "network")) + val flow = LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun fallsThroughToTheNextRungWhenOneIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + + assertEquals(listOf(true), edges) + verify { lm.requestLocationUpdates("network", 60_000L, 500f, any(), any()) } + + job.cancelAndJoin() + } + + @Test + fun pairsTheListeningEdgesAroundASuccessfulRegistration() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("network")) + val job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + assertEquals(listOf(true), edges) + + job.cancelAndJoin() + + assertEquals(listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun releasesTheRegistrationWhenCancelledDuringTheSeed() = + runTest { + // `send` in the seed suspends, so a collector cancelling while the + // getLastKnownLocation sweep is in flight unwinds the producer + // there. Cleanup must still run, or the refcount sticks at >= 1 + // forever and the OS registration leaks. + val edges = mutableListOf() + val lm = mockk(relaxed = true) + every { lm.allProviders } returns listOf("network") + + lateinit var job: Job + every { lm.getLastKnownLocation(any()) } answers { + // Cancel from inside the sweep, so the subsequent send() throws. + job.cancel() + mockk { every { time } returns 1L } + } + + job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + runCurrent() + job.join() + + assertEquals("the acquire must be released even on cancellation", listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun registersOnExactlyOneProvider() = + runTest { + val lm = manager(providers = listOf("fused", "network", "gps", "passive")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f).collect { } } + + runCurrent() + + verify(exactly = 1) { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } + + job.cancelAndJoin() + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationFlowTest*' +``` + +Expected: FAIL — compilation error. `LocationFlow` currently takes a `Context`, and `get` has no `onListening` parameter. + +- [ ] **Step 3: Rewrite LocationFlow** + +Replace everything **below** the MIT header in `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` with: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Looper +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch + +/** + * Wraps [LocationManager] update registration as a cold [Flow]. + * + * Registers on **one** provider, chosen by [LocationProviderLadder], rather than + * on every provider the device reports. The previous shotgun cost four + * simultaneous registrations — passive, network, fused and gps, the last at + * HIGH_ACCURACY — to produce a 5 km geohash. + * + * Takes a [LocationManager] rather than a `Context` so the registration + * behaviour is unit-testable; the caller does the `getSystemService` lookup. + * + * [onListening] is fired from inside the flow, after a registration succeeds and + * again from `awaitClose`, never as an `onStart`/`onCompletion` pair on the + * returned flow. The distinction matters: an `onStart` fires on collection even + * when nothing registered, so a device with no usable provider would accrue + * location time with no location running, and — because the ledger refcounts the + * two [LocationState] flows together — the unpaired close would steal the other + * flow's holder. + * + * The pair is kept honest from both ends. The acquire cannot fire without a + * registration, because a failure to register throws before reaching it. The + * release cannot be skipped, because everything after the acquire runs inside a + * `try`/`finally` rather than inside `awaitClose` — `send` suspends, so a + * collector that cancels mid-seed would otherwise unwind past an `awaitClose` + * that never ran. + */ +class LocationFlow( + private val locationManager: LocationManager, + private val sdkInt: Int = Build.VERSION.SDK_INT, + private val hasFine: Boolean = false, +) { + @SuppressLint("MissingPermission") + fun get( + minTimeMs: Long, + minDistanceM: Float, + onListening: ((Boolean) -> Unit)? = null, + ): Flow = + callbackFlow { + val locationCallback = + LocationListener { location -> + Log.d("LocationFlow") { "onLocationChanged $location" } + launch { send(location) } + } + + // One binder call, reused for both the ladder filter and the seed. + val providers = locationManager.allProviders + + val candidates = LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers } + + var registered: String? = null + for (provider in candidates) { + try { + locationManager.requestLocationUpdates( + provider, + minTimeMs, + minDistanceM, + locationCallback, + Looper.getMainLooper(), + ) + registered = provider + break + } catch (e: SecurityException) { + Log.w("LocationFlow", "Provider $provider refused the update request", e) + } + } + + if (registered == null) { + throw SecurityException("No usable location provider. Candidates: $candidates") + } + + Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" } + onListening?.invoke(true) + + // Everything after the acquire runs under try/finally, not under + // awaitClose. `send` below suspends, so it is a cancellation point: + // if the collector cancels while the seed is mid-flight, the + // producer throws there and `awaitClose` is never entered. Cleanup + // parked inside awaitClose would then never run — the registration + // would leak and the refcount would stick at >= 1 for the life of + // the process, so location.ms would accrue forever with nothing + // listening. The finally covers normal close and + // cancellation-during-send alike. + try { + // Seeded after registration so the no-provider path throws + // without having emitted anything; seeding first would show the + // consumer Success -> LackPermission on a device with no + // compatible provider. + freshestLastKnownLocation(providers)?.let { + Log.d("LocationFlow") { "Last known location is $it" } + send(it) + } + + awaitClose { } + } finally { + Log.i("LocationFlow") { "Stopped listening on $registered" } + locationManager.removeUpdates(locationCallback) + onListening?.invoke(false) + } + } + + /** + * The freshest cached fix across every provider. Permission-checked per + * provider like the update request is, so each lookup is guarded — on a + * device where a provider refuses us, the others should still seed. + */ + @SuppressLint("MissingPermission") + private fun freshestLastKnownLocation(providers: List): Location? = + providers + .mapNotNull { provider -> + try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + Log.w("LocationFlow", "No permission to read the last known location of $provider", e) + null + } + }.maxByOrNull { it.time } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationFlowTest*' +``` + +Expected: PASS, 6 tests. + +If `releasesTheRegistrationWhenCancelledDuringTheSeed` fails to *fail* against a version without the `try`/`finally` — i.e. it passes either way — the cancellation is not reaching `send` as expected. Do not delete the test: replace the in-answer `job.cancel()` with a `trySend` on a channel the test awaits, or fall back to asserting the same invariant from `LocationStateTest` by cancelling the collector mid-gate. The invariant is what matters, not this particular provocation. + +`LocationState.kt` will not compile yet — it still calls the old `LocationFlow(context)` and `MIN_TIME`. That is Task 5. If the Gradle run fails on `LocationState.kt` compilation rather than on the tests, that is expected; proceed to Task 5 and re-run both suites there. + +- [ ] **Step 5: Commit** + +The module is red until Task 5 lands. That does not block the commit: the pre-commit hook runs `spotlessCheck` only, which does not compile. + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt +git commit -m "feat(amethyst): register one location provider with a paired listening hook" +``` + +--- + +### Task 5: LocationState — the foreground gate + +**Files:** +- Modify (full rewrite of the class body): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` + +**Interfaces:** +- Consumes: `LocationFlow` (Task 4) +- Produces: `class LocationState(context, scope, isForeground, onListening, locationSource)` and the five `const val`s in the naming contract. Task 6 constructs it. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Location +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationStateTest { + private fun locationAt( + lat: Double, + lon: Double, + ): Location = + mockk { + every { latitude } returns lat + every { longitude } returns lon + } + + /** Counts subscriptions and completions of the underlying location source. */ + private class SourceProbe( + private val body: suspend FlowCollector.() -> Unit, + ) { + var subscriptions = 0 + private set + var completions = 0 + private set + + val live: Int get() = subscriptions - completions + + fun source(): (Long, Float) -> Flow = + { _, _ -> + flow(body) + .onStart { subscriptions++ } + .onCompletion { completions++ } + } + } + + private fun neverEmits() = SourceProbe { awaitCancellation() } + + private fun emitsOnceThenHangs( + lat: Double, + lon: Double, + ) = SourceProbe { + emit(locationAt(lat, lon)) + awaitCancellation() + } + + private fun stateWith( + scope: CoroutineScope, + foreground: MutableStateFlow, + probe: SourceProbe, + ) = LocationState( + context = mockk(relaxed = true), + scope = scope, + isForeground = foreground, + locationSource = probe.source(), + ) + + @Test + fun doesNotListenWhileBackgrounded() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(false) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(0, probe.subscriptions) + } + + @Test + fun listensOnceWhileForegrounded() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun releasesTheSourceAfterTheGracePeriodAndKeepsTheLastFix() = + runTest { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + val fixWhileForeground = state.geohashStateFlow.value + assertTrue("expected a Success, got $fixWhileForeground", fixWhileForeground is LocationState.LocationResult.Success) + + foreground.value = false + advanceUntilIdle() + + assertEquals("source must be released once backgrounded", 0, probe.live) + assertEquals( + "the last geohash must survive the release for the ~60 synchronous .value readers", + fixWhileForeground, + state.geohashStateFlow.value, + ) + } + + @Test + fun keepsListeningAcrossABackgroundEdgeShorterThanTheGracePeriod() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + assertEquals(1, probe.subscriptions) + + foreground.value = false + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS / 2) + foreground.value = true + advanceUntilIdle() + + assertEquals("a brief app switch must not tear down the registration", 1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun doesNotReemitLoadingWhenReturningToForegroundWithACachedFix() = + runTest { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + foreground.value = false + advanceUntilIdle() + val afterBackground = seen.size + + foreground.value = true + advanceUntilIdle() + + assertTrue( + "returning to foreground must not flash Loading — AroundMeFeedFlow renders an empty feed for it. Saw: ${seen.drop(afterBackground)}", + seen.drop(afterBackground).none { it is LocationState.LocationResult.Loading }, + ) + } + + @Test + fun emitsLoadingOnTheFirstForegroundWhenThereIsNoCachedFix() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + assertTrue("expected Loading, saw $seen", seen.any { it is LocationState.LocationResult.Loading }) + } + + @Test + fun reportsLackPermissionRegardlessOfForeground() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(false) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(LocationState.LocationResult.LackPermission, state.geohashStateFlow.value) + assertEquals(0, probe.subscriptions) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationStateTest*' +``` + +Expected: FAIL — compilation error. `LocationState` has no `isForeground` or `locationSource` parameter and no `BACKGROUND_GRACE_MS`. + +- [ ] **Step 3: Rewrite LocationState** + +Replace everything **below** the MIT header in `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` with: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Location +import android.location.LocationManager +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same +// package, so it needs no import. + +/** + * Turns the device's location into geohashes, listening **only while the app is + * in the foreground**. + * + * The gate is not an optimisation of last resort: `Account` builds 30 + * `SharingStarted.Eagerly` top-nav filter states on the account scope, and + * `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so + * without it every user with location permission holds a registration for the + * life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`. + * + * Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60 + * call sites read `account.live*FollowLists.value` synchronously rather than + * collecting, and would silently serve a stale or initial value. + */ +class LocationState( + context: Context, + scope: CoroutineScope, + private val isForeground: StateFlow, + /** + * Resource-ledger hook: true while location updates are actively + * registered. Reaches the OS only through the default [locationSource], + * which hands it to [LocationFlow] — a caller that overrides + * [locationSource] (the tests do) is responsible for firing it, or not. + */ + private val onListening: ((Boolean) -> Unit)? = null, + private val locationSource: (Long, Float) -> Flow = { minTimeMs, minDistanceM -> + LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) + .get(minTimeMs, minDistanceM, onListening) + }, +) { + companion object { + /** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */ + const val COARSE_MIN_TIME: Long = 60_000L + const val COARSE_MIN_DISTANCE: Float = 500.0f + + /** Building-level geohashes need the tighter profile. */ + const val PRECISE_MIN_TIME: Long = 10_000L + const val PRECISE_MIN_DISTANCE: Float = 100.0f + + /** + * How long to keep listening after the last activity stops, so a + * one-second app switch doesn't destroy and rebuild the registration. + * Matches the `WhileSubscribed` window below, and is the same intent. + */ + const val BACKGROUND_GRACE_MS: Long = 5_000L + } + + sealed class LocationResult { + data class Success( + val geoHash: GeoHash, + ) : LocationResult() + + object LackPermission : LocationResult() + + object Loading : LocationResult() + } + + private enum class Gate { NoPermission, Paused, Listen } + + private var hasLocationPermission = MutableStateFlow(false) + + // Volatile: R1 below reads these to decide whether to emit Loading, from a + // different coroutine than the onEach that writes them. + @Volatile private var latestLocation: LocationResult = LocationResult.Loading + + @Volatile private var latestPreciseLocation: LocationResult = LocationResult.Loading + + fun setLocationPermission(newValue: Boolean) { + if (newValue != hasLocationPermission.value) { + hasLocationPermission.tryEmit(newValue) + } + } + + /** + * Foreground with an asymmetric delay: leaving the foreground waits out + * [BACKGROUND_GRACE_MS], returning to it is immediate. + * + * `debounce(5000)` would delay both edges, and the duration-selector + * overload that allows an asymmetric delay is `@FlowPreview`. + * `transformLatest` cancels the pending `delay` when foreground returns + * first, which is exactly the semantics wanted, with no preview opt-in. + * + * Known and harmless: [ForegroundTracker] starts at `false`, so on a + * process that starts backgrounded the first emission — and therefore the + * first gate verdict, including `LackPermission` — is delayed by + * [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process + * that starts into the foreground emits immediately, because the activity's + * `onStart` cancels the pending delay. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val settledForeground: Flow = + isForeground.transformLatest { foreground -> + if (!foreground) delay(BACKGROUND_GRACE_MS) + emit(foreground) + } + + private val gate: Flow = + combine(hasLocationPermission, settledForeground) { permitted, foreground -> + when { + !permitted -> Gate.NoPermission + foreground -> Gate.Listen + else -> Gate.Paused + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun geohashFlow( + tag: String, + charsCount: Int, + minTimeMs: Long, + minDistanceM: Float, + latest: () -> LocationResult, + setLatest: (LocationResult) -> Unit, + ): Flow = + gate.transformLatest { state -> + when (state) { + // Deliberately does NOT write to the cache. Today's code emits + // LackPermission without touching latestLocation, and wiping it + // here would cost a Loading emission — and so an empty-feed + // flash — on every permission flap, which is the regression R1 + // exists to prevent. Consumers already see LackPermission from + // the StateFlow; the cache is internal and only decides whether + // Loading is emitted. + Gate.NoPermission -> emit(LocationResult.LackPermission) + + // Emit nothing: stateIn keeps the last value, so every + // synchronous .value reader still sees the last known geohash + // while the OS registration is released. + Gate.Paused -> Unit + + Gate.Listen -> { + // Only when there is nothing cached. Emitting Loading on + // every foreground return would flash the "Around Me" feed + // empty, because AroundMeFeedFlow.convert maps anything + // that is not Success to an empty geotag set. + if (latest() !is LocationResult.Success) emit(LocationResult.Loading) + + emitAll( + locationSource(minTimeMs, minDistanceM) + .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } + .onEach { setLatest(it) } + .catch { e -> + Log.w(tag, "Exception in the flow", e) + setLatest(LocationResult.LackPermission) + emit(LocationResult.LackPermission) + }, + ) + } + } + } + + val geohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "GeohashStateFlow", + charsCount = GeohashPrecision.KM_5_X_5.digits, + minTimeMs = COARSE_MIN_TIME, + minDistanceM = COARSE_MIN_DISTANCE, + latest = { latestLocation }, + setLatest = { latestLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestLocation) + } + + /** + * Like [geohashStateFlow] but at building-level precision + * ([GeohashChannelLevel.BUILDING] = 8 chars). Location channels truncate this + * to every coarser level (a geohash is a prefix code), so one fix yields the + * whole region→building ladder. Kept separate so the coarser + * [geohashStateFlow] the "around me" feed relies on is unchanged. + * + * Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android + * fuzzes every fix to roughly a 3 km grid and this is not in fact + * building-level today. The profile is kept so the intent survives if the + * app ever requests `ACCESS_FINE_LOCATION`. + */ + val preciseGeohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "PreciseGeohashStateFlow", + charsCount = GeohashChannelLevel.BUILDING.chars, + minTimeMs = PRECISE_MIN_TIME, + minDistanceM = PRECISE_MIN_DISTANCE, + latest = { latestPreciseLocation }, + setLatest = { latestPreciseLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestPreciseLocation) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationStateTest*' --tests '*LocationFlowTest*' +``` + +Expected: PASS, 7 + 6 tests. `AppModules.kt` still will not compile — it calls the three-argument `LocationState` constructor. That is Task 6. + +- [ ] **Step 5: Commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt +git commit -m "feat(amethyst): gate location listening on foreground" +``` + +`AppModules.kt` lands in Task 6, and the module compiles from there on. + +--- + +### Task 6: Wire it up in AppModules + +**Files:** +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt` — two edits, anchored on declaration text rather than line numbers, because Step 1 shifts everything below it + +**Interfaces:** +- Consumes: `RefCountedSession` (Task 2), `LocationState` (Task 5) +- Produces: nothing downstream + +- [ ] **Step 1: Add the refcounted session next to the integrator** + +Find the line beginning `private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS)` (currently line 369) and insert immediately after it: + +```kotlin + + // LocationState exposes two independent flows that can both be listening at + // once (the "Around Me" feed plus an open geohash chat). Refcounting keeps + // either one stopping from closing the other's segment. + private val locationRefCount = RefCountedSession(locationSession::setActive) +``` + +Add the import alongside the other `service.resourceusage` imports: + +```kotlin +import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession +``` + +- [ ] **Step 2: Pass the foreground signal and the refcount into LocationState** + +Find the `val locationManager by lazy` declaration (near line 249, unchanged by Step 1 since that insert was below it) and replace this exact block: + +```kotlin + // App services that should be run as soon as there are subscribers to their flows + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) + } +``` + +with: + +```kotlin + // App services that should be run as soon as there are subscribers to their + // flows. Location additionally releases its OS registration whenever no + // activity is started — see the foreground gate inside LocationState. + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState( + appContext, + applicationIOScope, + isForeground = foregroundTracker.isForeground, + onListening = { locationRefCount.setActive(it) }, + ) + } +``` + +Both `foregroundTracker` (line 333) and `locationRefCount` (added in Step 1) are declared after `locationManager`, which is fine — `locationManager` is `by lazy`, so the references resolve on first access. `locationSession` was already referenced this way. + +- [ ] **Step 3: Verify the whole module compiles, tests pass, and lint is clean** + +```bash +./gradlew :amethyst:compilePlayDebugKotlin +./gradlew :amethyst:testPlayDebugUnitTest +./gradlew :amethyst:lintPlayDebug +``` + +Expected: BUILD SUCCESSFUL for all three. + +Lint is not optional here. This change names `LocationManager.FUSED_PROVIDER` (API 31) on `minSdk = 26`, and keeps `@SuppressLint("MissingPermission")` on a rewritten method. The ladder's KDoc argues the constant is inlined by the compiler and therefore safe on older runtimes — correct, but an argument, and `NewApi` is exactly the check that settles it. If lint flags `NewApi` on `FUSED_PROVIDER`, replace the constant with the literal `"fused"` and keep the explanatory comment. + +If `compilePlayDebugKotlin` reports an unresolved `MIN_TIME` or `MIN_DISTANCE`, a caller was missed — grep for it and fix. + +```bash +grep -rn --include='*.kt' "MIN_TIME\|MIN_DISTANCE" amethyst/src commons/src desktopApp/src +``` + +Expected: only the four `COARSE_*`/`PRECISE_*` constants in `LocationState.kt` and their uses. + +- [ ] **Step 4: Commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +git commit -m "feat(amethyst): wire the location foreground gate and refcounted meter" +``` + +--- + +### Task 7: Verify the acceptance criteria on device + +**Files:** none — this is measurement. + +**Interfaces:** +- Consumes: the whole change +- Produces: the evidence block for the PR description + +- [ ] **Step 1: Install on the Pixel 9a** + +```bash +adb devices -l +./gradlew :amethyst:installPlayDebug +``` + +Expected: one device listed (`model:Pixel_9a`), BUILD SUCCESSFUL. + +- [ ] **Step 2: Record the foreground baseline** + +Open the app to Home, leave it in the foreground, then: + +```bash +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -c "com.vitorpamplona.amethyst" +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -A1 "com.vitorpamplona.amethyst" +``` + +Expected: the count is **1** in the steady state where only the "Around Me" feed is live (at most 2 if a geohash chat is also open — the two flows are independent, which is why the ledger refcounts). Before this change it was 4. + +Expected in the request line: `@+60s0ms` and `minUpdateDistance=500.0`. Before this change: `@+10s0ms` and `minUpdateDistance=100.0`. + +- [ ] **Step 3: Verify the registration is released when backgrounded** + +Press Home, wait more than `BACKGROUND_GRACE_MS` (5 s), then: + +```bash +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -c "com.vitorpamplona.amethyst" +adb shell dumpsys location | grep "com.vitorpamplona.amethyst" | tail -5 +``` + +Expected: the count is **0**, and the last recent-event lines are `-registration` entries timestamped when you pressed Home. + +- [ ] **Step 4: Verify the feed does not flash empty** + +Foreground the app on Home with the top-nav filter set to "Around Me". Note the geohash shown in the spinner. Press Home, wait 10 s, reopen the app. + +Expected: the same geohash is displayed immediately, with no "Loading" state and no empty feed. This is R1; a flash here means the cached-`Success` check is wrong. + +- [ ] **Step 5: Check the ledger invariant after a day of use** + +Open the in-app Resource Usage Report and compare: + +``` +location.ms ≤ app.fgms + 5000 × (number of times the app was backgrounded) +``` + +Both counters are driven by the same `foregroundTracker.isForeground`, so the grace period is the only expected slack. A violation much larger than that indicts `app.fgms` (Finding 4 of the source analysis suspects it of under-reporting) rather than this change. + +- [ ] **Step 6: Record the evidence** + +Append the observed numbers from Steps 2, 3 and 5 to the `## Acceptance criteria` section of `amethyst/plans/2026-07-29-location-foreground-gate.md` under a `### Verified` heading, then: + +```bash +git add amethyst/plans/2026-07-29-location-foreground-gate.md +git commit -m "docs(amethyst): record on-device verification of the location gate" +``` + +--- + +## Self-review notes + +Spec coverage: §A gate → Task 5 (R1 cached-`Success` check, R2 `settledForeground`, R3 `Gate.Paused` emitting nothing, R4 `@Volatile`). §B request shape → Tasks 3 and 4. §C meter → Tasks 2, 4 (R5 pairing via throw) and 6 (R6 wiring). H1 → Task 1. Testing → the test steps of Tasks 2–5. Acceptance criteria → Task 7. + +Not covered by design, and correctly so: the two `Unavailable`-state and Products-default items are Non-goals; the `GeohashChatScreen.kt:161-163` permission latch is a Follow-up. + +Tasks 4 and 5 leave the module temporarily uncompilable. That is a deliberate split — one task spanning `LocationFlow`, `LocationState` and `AppModules` would review far worse — and it costs nothing, because the pre-commit hook is `spotlessCheck` alone. Task 6 restores a green build. diff --git a/amethyst/plans/2026-07-29-location-foreground-gate.md b/amethyst/plans/2026-07-29-location-foreground-gate.md new file mode 100644 index 0000000000..20272b5e3f --- /dev/null +++ b/amethyst/plans/2026-07-29-location-foreground-gate.md @@ -0,0 +1,808 @@ +# Location: foreground-gate the listener, trim the request, fix the meter + +Date: 2026-07-29 +Module: `amethyst` +Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY, +Pixel 9a / Android 17) +Revision: 2 — incorporates spec review of 2026-07-29 + +## Context + +The 1.13.0 resource report showed **7.13 h of location listening against 9.1 +seconds of app foreground**, and the accompanying analysis called it the leading +suspect for that day's 11 pp of background battery drain. Root cause given: 30 +`SharingStarted.Eagerly` top-nav filter states on the account scope +(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because +`AccountSettings.kt:256` ships that as the Products default. The `AroundMe` +branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds +`LocationState`'s `WhileSubscribed(5000)` open for the life of the process. + +That chain is real. **The battery conclusion drawn from it is not**, and this +spec is written against the measurement rather than the inference. + +### What the device reports + +`amethyst/src/main/AndroidManifest.xml:80` declares **only** +`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no +`ACCESS_BACKGROUND_LOCATION` — and no service declares +`foregroundServiceType="location"` (the declared types are `mediaPlayback`, +`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`). +`targetSdk = 37`. + +`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the +`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate +Location Provider Data* block — not system-wide totals: + +| provider | registration held | **active** | **foreground** | fixes | +|---|---|---|---|---| +| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 | +| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 | +| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 | +| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 | + +Roughly **11 minutes of background-active location in three weeks**, and about +100 delivered fixes per provider. With the app backgrounded at the time of the +dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware: +mStarted=false`. + +The cleanest assumption-free comparison: the ledger's **two-day** `location.ms` +total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total** +(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies. + +**One figure does not reconcile, and is left open.** Registration-held is +9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day +across the two ledger days. If `location.ms` measured subscription existence +these should agree; they are ~2× apart. Candidates: segments open at process +death are lost (the pre-flush hook does not run on a kill, and the report shows +6 process starts across the two days); the ledger covers 2 days while dumpsys +spans 21 with different usage. Not investigated. It does not affect the +conclusion below, which rests on `location.ms` versus OS-*active* time, not +versus registration-held. + +### How far the "no background location" claim generalises + +This matters because the "no behaviour change" argument rests on it, so it is +scoped rather than asserted universally: + +- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background + access, and for `targetSdk ≥ 29` an FGS additionally needs + `foregroundServiceType="location"`. Amethyst has neither, so background + registrations are suspended. This is the case the Pixel 9a measurement above + covers. +- **API 26–28 (Android 8–9):** `ACCESS_BACKGROUND_LOCATION` does not exist. + Amethyst *can* receive background location there, throttled by the platform to + a few updates per hour. The gate is a genuine, if small, improvement on these + releases rather than a no-op. + +So "structural" applies to API 29+; on 26–28 the change has real effect. + +### What is actually wrong + +1. **The meter lies.** `location.ms` measures how long a *subscription* existed, + not how long anything listened. That is what made Finding 1 read as the + top-priority battery bug. +2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates + `locationManager.allProviders` and calls `requestLocationUpdates` on each — + passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every + one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash. + This burns during the 8 h 14 m the app genuinely is foreground. +3. **The subscription is held for 45 % of device uptime** doing nothing, because + `Eagerly` never lets go. +4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no + opt-in. +5. **Location may be entirely broken on Android 8–11** — see Hypothesis H1. + +## Hypothesis H1 — location is dead below API 31 (unverified) + +Through Android 11, AOSP's `getMinimumPermissionForProvider` required +`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only +`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a +coarse-only app request any provider and receive a fuzzed result, is an Android +12 (API 31) change. + +Amethyst holds coarse only. So on API 26–30 today's `allProviders` loop should +throw `SecurityException` on three of the four providers. Two consequences: + +- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState` → + `LackPermission`. Location would be **non-functional** on Android 8–11. +- The builder aborting means `awaitClose` never runs, so `removeUpdates` is + never called and any registration made before the throw **leaks** for the life + of the process. + +**The leak is iteration-order dependent, and may not occur at all.** +`getLastKnownLocation` is permission-checked per provider too, and +`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each +iteration. If a fine-only provider comes first in `allProviders` — `passive` +does, in the common AOSP ordering — the throw lands before any registration +exists: dead, but not leaking. A leak requires `network` to precede a fine-only +provider. + +`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint +warning, not the runtime check, so this would not have been caught statically. + +**Not reproduced.** Verify before implementing, on the existing +`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes, +and capture **both** the `SecurityException` *and* the actual +`locationManager.allProviders` order (log it). The PR should claim only what that +run observed — "location is dead on Android 8–11" and "registrations leak" are +separate claims and the second may not hold. + +The design below is written to be correct either way (§B excludes +permission-incompatible providers by API level, and catches `SecurityException` +per provider). If H1 holds, this change also **fixes location on Android 8–11**, +which should be called out in the PR. + +### H1 verification result (2026-07-30, Pixel 9a, API 37) + +Partial. The API-level claim could **not** be tested on this hardware: API 31+ +grants coarse-only apps access to every provider, so no `SecurityException` can +appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it. + +What *was* settled is the ordering, which decides the leak sub-claim. +`dumpsys location` recent-events shows the same iteration order on every +registration cycle across two days, all four sharing one registration id +(`88A8E679`), confirming a single `LocationFlow` subscription: + +``` +07-30 07:09:58.282: passive provider +registration .../88A8E679 +07-30 07:09:58.291: network provider +registration .../88A8E679 +07-30 07:09:58.293: fused provider +registration .../88A8E679 +07-30 07:09:58.299: gps provider +registration .../88A8E679 +``` + +`allProviders` yields **passive first**, and `passive` is one of the fine-only +providers below API 31. So on Android 8–11 the throw would land on the first +iteration, before any registration exists: + +- "location is dead on Android 8–11" — **still unverified**, needs API ≤ 30. +- "registrations leak" — **disproved for this ordering**. Dead, but not leaking. + +The PR must not claim the leak. Caveat: the ordering is observed on API 37 and +`getAllProviders()` could order differently on API 26. + +**Unrelated but decisive "before" datum, same session:** with +`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in +`mLastPausedActivity`, Amethyst held **four** live registrations at +`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to +eliminate, captured on the owner's daily-driver device rather than an emulator. + +## Goals + +- Release the location registration whenever no activity is started. +- Register on one appropriate, permission-compatible provider at an interval + matched to the precision actually needed. +- Make `location.ms` reflect real listening time, correctly, under concurrency. +- No user-visible behaviour change to the "Around Me" feed or geohash chats. + +## Non-goals + +- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the + gate in place its cost is bounded to foreground use. Worth revisiting + separately as a product decision. +- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`. +- Findings 2–6 of the source analysis. Finding 2 (the relay reconnect storm, + 1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for + the background battery drain and should be taken next. + +## Design + +### A. The gate + +`LocationState` gains an `isForeground: StateFlow` parameter, wired in +`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`, +registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so +initialisation order is safe. + +Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate +over *permission × foreground*, applied identically to `geohashStateFlow` and +`preciseGeohashStateFlow`: + +| gate state | behaviour | +|---|---| +| no permission | emit `LackPermission` (unchanged) | +| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` | +| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value | + +**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses +to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate +would make the "Around Me" feed flash empty on **every** return to foreground — a +new, frequent, user-visible regression introduced by this change. (It also fixes +the same flash on permission grant, which exists today.) + +**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's +code emits `LackPermission` without touching `latestLocation` +(`LocationState.kt:94-96`), and that stays. Clearing it is superficially +attractive — a revoked permission arguably should not leave a fix readable — but +consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is +private and its only jobs are seeding `stateIn` and deciding whether `Loading` is +emitted. Clearing it would therefore buy no privacy and would cost an +empty-feed flash on every permission flap, which is precisely what R1 exists to +prevent. The cache is in-memory and dies with the process regardless. + +The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry +looks arbitrary next to the paragraph above, so to be explicit: it is inherited, +not introduced. Both branches preserve today's behaviour exactly +(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing +permission). This corollary argues against *adding* a clear, not for removing +the existing one — changing it would be an unmotivated behaviour change. The +asymmetry is also defensible on its own terms: a source that failed mid-stream +says something about the fix's provenance, whereas a permission known to be +absent says nothing about a fix already taken. + +**R2 — grace period on the background edge.** The gate must delay the +`foreground → background` transition by **5 s** before tearing down. Without it a +one-second app switch destroys and rebuilds the registration, including a full +`getLastKnownLocation` sweep, so a user flipping between apps pays more than the +steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same +intent. The `background → foreground` edge is **not** delayed. + +Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)` +delays both edges, and the duration-selector overload that would allow an +asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file +and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`: + +```kotlin +isForeground.transformLatest { fg -> + if (!fg) delay(BACKGROUND_GRACE_MS) + emit(fg) +} +``` + +`transformLatest` cancels the pending `delay` if foreground returns first, which +is exactly the stated semantics, with no preview opt-in. + +**R3 — the retained-value contract.** The "emit nothing" branch is what keeps +this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60 +synchronous `.value` reads across the feed filters +(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`, +`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km +cell does not meaningfully decay while backgrounded. + +**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation` +(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial +values. R1 promotes them to control flow, read from a different coroutine than +the `onEach` that writes them. They must become `@Volatile` (or +`MutableStateFlow`). + +**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly` +to `WhileSubscribed`. Roughly 60 call sites read +`account.live*FollowLists.value` synchronously rather than collecting; under +`WhileSubscribed` those reads would silently serve a stale or initial value +whenever no collector happened to be active. That is a correctness regression, +not a battery fix. + +**Rejected alternative:** gating only at the `AppModules` wiring point +(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw +`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for +`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the +same data. + +### B. Request shape + +`LocationFlow.get` registers on **one** provider, chosen by a ladder over +**provider existence and permission compatibility** — both static facts: + +``` +chooseProviders(sdkInt, hasFine, exists) -> List: + API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists + API < 31, coarse → [NETWORK] filtered by exists (see H1) +``` + +It returns the **ordered candidate list**, not a single choice, because the +per-provider `SecurityException` fall-through below needs somewhere to fall to. +An empty list means no compatible provider exists. + +**The ladder deliberately does not consult `isProviderEnabled`.** Today's code +registers regardless of enabled state, and such a registration goes live by +itself when the user enables location — including from the quick-settings shade +without leaving the app, which is exactly what someone does after seeing "Around +Me" empty. A guard evaluated once at subscription start would lose that, and the +foreground-transition restart does not cover the in-app path. Selecting on +existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If +field reports show dead feeds on devices where the chosen provider exists but is +disabled while another is enabled, adding that receiver is the follow-up. + +`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch +that falls through to the next rung, so H1 cannot abort the builder and leak +registrations regardless of how the AOSP check actually behaves. + +**When no provider can be registered** — the candidate list was empty, or every +rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit +`LackPermission`: the seam is `(Long, Float) -> Flow`, and +`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no +way to express. Throwing routes it through the `.catch` already present in +`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets +`latestLocation = LackPermission` and emits it — the existing, unchanged path. + +Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag +guard: the throw happens before the acquire, so `onListening(true)` cannot fire +without a live registration and no separate flag is needed. That is only half of +R5's pairing, though — see R5 for the release half, which the throw does **not** +cover and which needs `try`/`finally`. + +**Stated decision: `LackPermission` stays conflated with "no usable provider".** +That value renders `R.string.lack_location_permissions` — "No Location +Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`, +which is wrong for a coarse-only pre-31 device that has no `network` provider. +The conflation is accepted rather than introduced: if H1 holds, today's +`SecurityException` already lands in the same `.catch` and shows the same wrong +message. Adding an `Unavailable` state would ripple through four UI `when`s plus +`LocationState` (10 references across 5 files) and belongs with the H1 fix +messaging, not here. Recorded as a follow-up. + +The `getLastKnownLocation` seed stays a sweep across all providers, taking the +freshest result. It requires no registration and is what makes the first geohash +appear immediately rather than after a fix. + +`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call: + +| flow | precision | interval / distance | provider set | +|---|---|---|---| +| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 | +| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 | + +Both rows change: the ladder narrows the precise flow's provider set too, and +below API 31 that means `network` only, no GPS. Academic while the app holds +coarse only (see Follow-ups), but it is not "unchanged". + +At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no +observable effect on the feed. + +**Rejected alternative — one shared source at the fine profile,** deriving the +coarse geohash by prefix truncation. It halves registrations and removes the need +for `RefCountedSession` entirely, but it upgrades the **common** case — the +"Around Me" feed alone, which is always on via the Products default — from +60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one. + +**Rejected alternative — one shared source whose profile tracks the finest +active subscriber.** Recovers the above and is the best of the three on both +axes, but it is refcounting with the counter moved from the meter into the +request path, for a benefit bounded by how often the two flows overlap. They +overlap only while one of three composable-scoped, foreground-only screens is +open (`GeohashChatScreen`, `NewGeohashChatScreen`, +`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes. + +### C. The meter + +`AppModules.kt:251` hands both flows the same non-refcounted +`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment +while the other is still listening. Both can be live at once — the "Around Me" +feed plus an open geohash chat. + +**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today +`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`, +so it fires on *collection* whether or not anything was registered — meaning a +device with no usable provider accrues `location.ms` with nothing listening, +reintroducing the exact defect this section exists to fix. The hook must instead +fire from inside the `callbackFlow`, after `requestLocationUpdates` returns +without throwing, and again on the way out. + +The obvious "way out" is `awaitClose`, and that would introduce a worse bug than +it fixes — twice over. First, `awaitClose` runs on every normal +completion, including one where no rung ever registered, so it would fire an +**unpaired** `onListening(false)`. With R6 that does not merely under-count — it +decrements a holder it never acquired, stealing another flow's. Concretely: +`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to +register and closes (`holders = 0`), and the session latches off while the coarse +flow is still listening. `coerceAtLeast(0)` does not help; the count never went +negative. + +Second — and this is the one that survives fixing the first — `awaitClose` also +fails to run at all on some paths that *did* register. See below. + +The pair therefore has to be guaranteed from **both** ends, and the two ends need +different mechanisms. + +*No acquire without a registration* is §B's **throw**: if no rung registers, the +builder throws before reaching the acquire at all. + +*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This +is the subtlest point in the document, so the justification below is the one that +was **demonstrated**, not the one that sounds most obvious. + +Anything between the acquire and `awaitClose` that unwinds skips cleanup parked +inside `awaitClose`, because `awaitClose` is never reached to register it. The +registration then leaks and the refcount sticks at ≥ 1 for the life of the +process, so `location.ms` accrues forever with nothing listening — this exact +defect, arrived at from the other direction, and unrecoverable once hit. + +The **proven** path is the seed throwing a non-cancellation exception: +`getLastKnownLocation` is a binder call and can fail. The regression test +`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched +failing against an `awaitClose`-only implementation +(`expected:<[true, false]> but was:<[true]>`). + +A cancellation during the seed is *in principle* a second such path, since `send` +is a suspending call. Recorded honestly: **this one could not be reproduced.** +Two attempts during implementation both produced tests that passed against a +deliberately broken implementation, because `callbackFlow`'s channel is buffered, +so `send` returns without suspending and never observes the cancel. Do not treat +the cancellation story as the reason for the `try`/`finally`; a future reader who +tries to reproduce it, fails, and concludes the guard is unnecessary would +reintroduce the leak. + +```kotlin +var registered: String? = null +for (provider in candidates) { + try { + locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper()) + registered = provider + break + } catch (e: SecurityException) { /* next rung */ } +} +if (registered == null) throw SecurityException("no usable location provider") + +onListening?.invoke(true) // cannot fire without a registration +try { + freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable + awaitClose { } // only to satisfy callbackFlow's contract +} finally { + locationManager.removeUpdates(callback) + onListening?.invoke(false) // cannot be skipped +} +``` + +`onListening?.invoke(true)` sits immediately before the `try`, with no suspension +between them, so the acquire cannot happen outside the block that guarantees its +release. + +`trySend` for the seed would also close this particular hole, being +non-suspending. It is rejected because it leaves the invariant resting on nobody +adding a suspending call to that block later — vigilance rather than +impossibility, which is the standard the rest of R5 is held to. + +**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not +sufficient: two threads can leave the counter at 1 while the last +`setActive(false)` lands after the `setActive(true)`, latching the session off. +The count and the transition must move under one lock. New class in +`service/resourceusage/`: + +```kotlin +class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) = + synchronized(lock) { + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + setSessionActive(holders > 0) + } +} +``` + +It takes the setter as a lambda rather than a `SessionTimeIntegrator` because +that is all it needs, and because constructing a real integrator in a unit test +would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp +file to observe one boolean. + +`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes +`onListening = { locationRefCount.setActive(it) }`. The outer +lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is +then nested but never acquired in the reverse order, so there is no deadlock. +`coerceAtLeast(0)` guards an unmatched release. + +### What `location.ms` means after this change + +Stated plainly, because the finding that opened this spec is "the meter lies" +and the next reader should not over-trust the fixed number the way the last one +over-trusted the broken one: + +> `location.ms` measures **how long a location registration was held while the +> app was in the foreground**. It is not radio-on time and not an energy +> figure. A `network`-provider registration at 60 s costs close to nothing; a +> `gps` registration at 10 s costs a great deal. The counter cannot tell them +> apart. + +Reading it as a battery signal requires knowing which provider was chosen — +which the ledger does not record. Recording the chosen provider as a separate +counter is a possible follow-up. + +## Testing + +JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric, +alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`. + +**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow`, +defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get` +(see *Registration pairing* for why `LocationFlow` now takes the manager rather +than the `Context`). That is the seam: +`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure +Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a +`mockk` with stubbed `latitude`/`longitude` suffices. Against a +counting fake source: + +- backgrounded + permitted → source never subscribed +- foreground + permitted → subscribed exactly once +- foreground → background → subscription released after the R2 grace period, + last `Success` still readable via `.value` +- background edge shorter than the grace period → subscription **not** torn down +- return to foreground with a cached `Success` → **no** `Loading` emission (R1) +- return to foreground with no cached fix → `Loading` first +- permission revoked → `LackPermission` regardless of foreground state + +**Provider ladder.** Extracted as a pure function +`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): +List` so §B is covered rather than sitting below the seam. Cases: rungs +returned in order; missing rungs filtered out; API < 31 coarse-only yields +`[network]`; API < 31 with fine yields the full ladder; no compatible provider +yields an empty list. + +`hasFine` is **always `false` in production** — the non-goals rule out ever +requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so +that the function is total over the permission axis and the API < 31 branch can +be tested from both sides, not because fine access is anticipated. If that +changes, the ladder is already correct. + +R5 sits below the `locationSource` seam, so a fake source never fires it. It gets +its own tests against a mocked `LocationManager` — see *Registration pairing* +below. + +**Meter.** `RefCountedSession`: overlapping holders keep the session open; +balanced pairs close it; an unmatched release does not drive the count negative. + +Note what this class **cannot** do: it cannot distinguish an unpaired release +from a legitimate one, so `acquire → unpaired release` closes the session even +while another holder is listening. That is precisely the R5 bug, and +`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to +`LocationFlow`, not here** — which is why it needs its own test below. + +**Registration pairing (R5).** To make this testable rather than device-only, +`LocationFlow` takes a `LocationManager` instead of a `Context` +(`LocationFlow(context)` → `LocationFlow(locationManager)`; the caller in +`LocationState` does the `getSystemService` lookup). A `mockk` +then covers: + +- every rung throws `SecurityException` → the flow throws and `onListening` fires + **neither** edge +- `chooseProviders` returns an empty list → same: throws, neither edge +- an earlier rung throws and a later one succeeds → registration falls through, + exactly one `true` +- a successful registration → exactly one `true`, and exactly one `false` plus + `removeUpdates` on cancellation +- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight + → both edges still fire and `removeUpdates` is still called. This is the case + the `try`/`finally` exists for; without it the test fails by hanging the + refcount at 1 rather than by throwing, so assert on the edges, not on the + absence of an exception. +These cover the *semantics* only — the two-thread interleaving that motivates the +lock is made unobservable by the lock itself and is not reproduced by any test +here. + +## Acceptance criteria + +On device, re-running the measurement above: + +- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location` + shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`, + and a `-registration` in the recent-events log. +- **Foregrounded:** **one registration per actively-collected flow — at most + two**, and one in the steady state where only the "Around Me" feed is live + (§C exists precisely because the two flows may overlap). Not four. The coarse + registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than + `@+10s0ms` / `100.0`. +- The historical aggregates are cumulative since boot; compare deltas across a + foreground/background cycle, not absolute totals. +- **Invariant:** a subsequent in-app Resource Usage Report shows + + ``` + location.ms ≤ app.fgms + 5 s × (background transitions) + ``` + + Both counters are driven by the same `foregroundTracker.isForeground` flow, so + without R2 this would hold exactly. R2 is deliberately the error term: the + registration really *is* live during the grace period, so counting it is the + honest reading, and a stated fudge factor beats an invariant quietly known to + be false. The term is not negligible — the source report shows 6 process + starts across two days, and app switches are far more frequent than that — so + writing `location.ms ≤ app.fgms` would guarantee that the first person to + check it files a bug against this change. + + Second caveat: Finding 4 of the source analysis suspects `app.fgms` of + under-reporting, so a violation beyond the grace term indicts that counter + rather than this one. +- If H1 holds: location works on the API 26 AVD after the change and did not + before. + +### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37) + +Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with +the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark` +beside the untouched Play install. The Play install was force-stopped for the +duration so its own (unfixed) registrations could not be mistaken for these. + +| criterion | before | after | +|---|---|---| +| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) | +| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` | +| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** | + +Event trace for one full cycle, process alive throughout (pid 28682): + +``` +17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0 +17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace +17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0 + (HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives) +``` + +- **§A gate** — zero registrations while backgrounded, with the process still + alive. That is the state the change exists to create; before, four + registrations survived screen-off and doze. +- **§B request shape** — one provider, top of the ladder (`fused`), at exactly + `COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and + coalesces the effective service request to `@+10m0s0ms LOW_POWER`. +- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild + pair, so a brief switch no longer costs a re-registration and a fresh + `getLastKnownLocation` sweep. + +**The OS aggregate after four foreground/background cycles is the headline +result**, because it is the same counter shape `location.ms` measures: + +``` +com.vitorpamplona.amethyst.benchmark: + min/max interval = 60s/60s + total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms + locations = 4 +``` + +Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's +`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while +only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the +aggregate exactly, so nothing is held outside them. Registration-held time now +*equals* foreground time, which is precisely what makes `location.ms` honest: the +counter measures registration lifetime, and that quantity is no longer divorced +from reality. + +**A fix arrives within milliseconds of every re-registration**, which bounds the +staleness the coarser profile was feared to introduce: + +``` +17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms) +17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms) +17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms) +18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms) +``` + +The `fused` provider hands over its cached fix on registration, so the window in +which a returning user could act on a stale geohash is milliseconds, not the 60 s +poll interval. Caveat: that cache is warm on this device because Maps and GMS +keep it fresh; on a device with no other location consumer it could be colder, +which is what `freshestLastKnownLocation` exists to cover. + +**Side-by-side A/B, same device, same instant, both clients backgrounded and +running.** The unmodified release client (1.13.1, installed via Obtainium, pid +5552) and the benchmark build of this branch (pid 28682) were sampled together: + +``` +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) + ← com.vitorpamplona.amethyst.benchmark: no rows at all +``` + +Four held registrations versus zero. Note the release client's rows are all +`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10 +minutes and suspended delivery, exactly as §"What the device reports" describes — +but the **registration is still held**, and registration-held time is precisely +what `location.ms` counts. That is the inflation, visible in one frame. + +Naming note for anyone re-reading the numbers above: both artifacts are `play` +**flavor** builds and differ only by buildType, so "the Play install" is an +ambiguous label. The unmodified client here is the *release* build, and on this +device it came from Obtainium rather than Google Play. + +### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report) + +The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks +out against accumulated data: + +| | `location.ms` | `app.fgms` | ratio | +|---|---|---|---| +| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** | +| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× | +| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** | + +Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which +requires ≥ 3 background transitions to fall inside the grace allowance — met by +the report navigation plus an `am start`. Day 20664 independently reconciles with +the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace = +~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the +measurement ended. **The ledger and the OS now agree**, where before they were +irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks). + +**Limitation:** this is not a within-package before/after. `location.ms` is +absent from days 20648–20663 because the benchmark client had location permission +*denied* until 2026-07-30; the "before" is no data, not inflated data. + +### The same data closes the battery question + +Over days 20659–20665 on this device: **5m18s** of location listening against +**596 pp** of background battery drain (~85 pp/day). Location cannot be a +meaningful contributor at that ratio — Finding 1 is settled, and not in the +direction the original analysis assumed. + +Three consumers visible in the same report, none of them location: + +- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on + foreground service running essentially continuously. Largest structural + difference from a stock client; worth confirming it is deliberately enabled. +- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone: + 9,831 successful dials against 25,133 failures = **71.9 %**, matching the + original report's 75 %. +- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems = + 52,392,613` — **14.6 h** of background mobile active time with **0 requests and + 0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so + the fg/bg split in this report still cannot be trusted. + +Caveat: the benchmark client's round-the-clock always-on service makes its +battery figures non-comparable to a stock install. The relay and `net.other` +figures do match the release client's original report closely. + +### Smoke test on a clean install (2026-07-31, benchmark client) + +Uninstalled and reinstalled from branch HEAD so the account, ledger and +permission grant all started empty — which is what makes the first-grant and +empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the +new data. + +- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly: + granted the permission, set a feed to Around Me, backgrounded for 10 s, + reopened — the geohash was present immediately with no blank feed. This was the + last open acceptance criterion on the branch and the only one no test could + cover. `dumpsys` shows why it works: the fix is delivered 1–6 ms after each + re-registration. +- **Precise profile live and distinct: PASS.** Geohash screens produce + `@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max + interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in + production, not just in the unit tests. +- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4` + came up at 15:49:10 and survived **four complete precise-flow cycles** + untouched (15:49:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), with + both live simultaneously during the first. Opening and closing geohash chats + never closes the "Around Me" registration — `RefCountedSession` doing on a real + device what `LocationLedgerCompositionTest` asserts. +- **No hang in the location picker.** Every precise registration received a fix + within ~1 ms; none stuck open. That path does + `preciseGeohashStateFlow.first { it is Success }`, which would hang rather than + crash if the gate failed to yield. +- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground + 5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions, + ≈ 2.6 s each — the grace period, visible at a scale where it is easy to check. + +**Measurement note:** counting listeners with `grep -c` on the package name is +unreliable — the `service: ProviderRequest[…]` summary line also names the +package when it is the only requester, inflating the count by one. List the rows +and exclude that line instead. A count of zero is unambiguous either way. + +Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms + +5 s × transitions` invariant was separately confirmed from accumulated ledger +data (see above); the clean install reset that counter, so it will need another +day of use to re-check at scale. + +## Follow-ups (not in this change) + +- **`preciseGeohashStateFlow` is not actually building-level.** With only + `ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so + the 8-char geohash and the location chat channels built on it are far coarser + than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`, + `NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent + survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request + it, or to stop advertising building-level precision, is a separate decision. +- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls + `setLocationPermission(true)` inside an `if (isGranted)` rather than passing + the boolean, as every other caller does (`LoggedInPage.kt:144`, + `LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`, + `GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never + reflected back into the shared `LocationState`. Small, in the blast radius, + and cheap. +- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a + device with no usable provider stops being told "No Location Permissions" when + it has them. Ten references across five files + (`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`, + `NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix + messaging — see the stated decision in §B. +- Recording the chosen provider as a ledger counter, so `location.ms` can be + read as a cost signal. +- Whether Products should still default to `TopFilter.AroundMe`. +- Finding 2 — the relay reconnect storm. diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index c869020eea..f7ebb414f5 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -86,7 +85,6 @@ class NotificationFeedFilterModeOverrideTest { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 2cc4fcd1e9..68da55b297 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -77,7 +76,6 @@ class ThreadDualAxisChartAssemblerTest { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt index dcd1867d03..5ea16ce0b6 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt @@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest { private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + /** + * Built outside composition on purpose: the mock and its error state are fixtures for the whole + * test, not per-composition state. Creating them inside `setContent` would rebuild both on every + * recomposition (and trips Compose's UnrememberedMutableState lint). + */ + private fun failedControllerState() = + MediaControllerState( + controller = mockk(relaxed = true), + playbackError = + mutableStateOf( + PlaybackException( + "Malformed HLS manifest", + null, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, + ), + ), + ) + private fun renderInBox( width: Dp, height: Dp, fontScale: Float = 1f, ) { + val controllerState = failedControllerState() + rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) { Box(Modifier.width(width).height(height)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } @@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest { // button is measured before the weighted text block that absorbs the shortfall. Measure // the same button roomy and then at its tightest, and require the two to agree. val boxHeight = mutableStateOf(400.dp) + val controllerState = failedControllerState() rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, 2f)) { Box(Modifier.width(322.dp).height(boxHeight.value)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } @@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest { // was just tall enough to keep the icon and not tall enough to pay for it, so the title // rendered sliced. Decoration must yield before words do. val boxHeight = mutableStateOf(400.dp) + val controllerState = failedControllerState() rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, 2f)) { Box(Modifier.width(322.dp).height(boxHeight.value)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt rename to amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt index b27d1517dc..1388e9a5bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -18,11 +18,15 @@ * 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.topNavFeeds.relay +package com.vitorpamplona.amethyst.ui.components -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -class RelayTopNavPerRelayFilterSet( - val relayUrl: NormalizedRelayUrl, -) : IFeedTopNavPerRelayFilterSet +/** + * No translation service in this flavor, so no note is ever translated and the copy-text + * menus never need to offer a "Copy Translated" option. + */ +fun cachedTranslation( + content: String, + accountViewModel: AccountViewModel, +): String? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6b87d3e391..0c3936c413 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger @@ -105,6 +106,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator +import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant @@ -246,10 +248,17 @@ class AppModules( OtsSharedPreferences(appContext, applicationIOScope) } - // App services that should be run as soon as there are subscribers to their flows + // App services that should be run as soon as there are subscribers to their + // flows. Location additionally releases its OS registration whenever no + // activity is started — see the foreground gate inside LocationState. val locationManager by lazy { Log.d("AppModules", "LocationManager Init") - LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) + LocationState( + appContext, + applicationIOScope, + isForeground = foregroundTracker.isForeground, + onListening = { locationRefCount.setActive(it) }, + ) } val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -374,6 +383,11 @@ class AppModules( private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + // LocationState exposes two independent flows that can both be listening at + // once (the "Around Me" feed plus an open geohash chat). Refcounting keeps + // either one stopping from closing the other's segment. + private val locationRefCount = RefCountedSession(locationSession::setActive) + // Time-per-screen (route base names only — arguments never reach the // ledger). Fed by the navigation listener in AppNavigation; foreground // gating means backgrounding on a screen closes its segment. @@ -709,6 +723,7 @@ class AppModules( torManager.status, client, applicationIOScope, + onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) }, ) // Verifies and inserts in the cache from all relays, all subscriptions @@ -871,7 +886,6 @@ class AppModules( AccountCacheState( geolocationFlow = { locationManager.geohashStateFlow }, nwcFilterAssembler = { sources.nwc }, - cashuWalletFilterAssembler = { sources.cashuWallet }, cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory }, okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney, contentResolverFn = { appContext.contentResolver }, @@ -957,6 +971,12 @@ class AppModules( ) } + // Gives every account that opted into running in the background its own + // account-level subscriptions (notifications, DMs, gift wraps, wallet), with no + // AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below, + // which already tracks which accounts opted in. + val accountSubscriptions = AccountSubscriptionRegistry(sources.account) + // Manages always-on notification service lifecycle. Preloads every saved // writable account while enabled so GiftWraps for non-active accounts still // get unwrapped by their owning account's newNotesPreProcessor. @@ -966,6 +986,8 @@ class AppModules( scope = applicationIOScope, accountsCache = accountsCache, localPreferences = LocalPreferences, + subscriptions = accountSubscriptions, + isForeground = foregroundTracker.isForeground, activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index bfdc4466ed..f46e2e7f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -165,27 +165,17 @@ object FavoriteAppLauncher { return when (event) { is RootNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - "", - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: "Napplet", - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = "", ) is NamedNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - event.identifier(), - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: event.identifier(), - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = event.identifier(), ) is RootSiteEvent -> NappletLauncher.buildLaunchParams( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7580f2b6cd..4cfab50126 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -24,20 +24,17 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.actions.ConcordActions -import com.vitorpamplona.amethyst.commons.actions.ConcordModeration -import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -50,10 +47,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -66,11 +61,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent -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.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -162,43 +153,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute -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.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot -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.concord.cord02Community.ConcordCommunityListEntry -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent -import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot -import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity -import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity -import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite -import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus -import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -import com.vitorpamplona.quartz.concord.crypto.GroupKey -import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -226,24 +187,12 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address -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.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -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.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -274,7 +223,6 @@ import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -295,20 +243,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify 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.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -316,19 +252,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent -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.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -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.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -345,12 +270,10 @@ import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta @@ -366,8 +289,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -390,8 +312,6 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log @@ -413,27 +333,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.math.BigDecimal -import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash -private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" - -/** Name of the default Concord community Admin role minted by "Make admin". */ -private const val CONCORD_ADMIN_ROLE = "Admin" - -/** - * How often a joined Concord community's stored invite link is re-resolved to check whether - * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is - * rare and silent, so this trades detection latency for not turning the revision tick into a - * relay-fetch loop. - */ -private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -441,7 +345,6 @@ class Account( override val signer: NostrSigner, val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val otsResolverBuilder: () -> OtsResolver, @@ -455,7 +358,8 @@ class Account( relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), -) : IAccount { +) : IAccount, + UserFinderAccount { private var userProfileCache: User? = null override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } @@ -467,6 +371,34 @@ class Account( override val hiddenUsersHashCodes: Set get() = hiddenUsers.flow.value.hiddenUsersHashCodes override val spammersHashCodes: Set get() = hiddenUsers.flow.value.spammersHashCodes + // UserFinderAccount — narrow, read-only relay-hint view used by the shared + // per-user metadata + per-note event finders (moved to commons). Snapshot + // getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex + // doubles as the attribution pubkey for ExplainedFilter.accountPubKeys. + override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex + + override fun indexRelays(): Set = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList } + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value + + // searchRelayList.flow already applies the DefaultSearchRelayList fallback internally + // (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here. + override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet() + + override fun searchOnlyRelays(): Set = searchRelayList.flow.value + + override fun followPlusAllMineWithSearchRelays(): Set = followPlusAllMineWithSearch.flow.value + + override fun commonRelays(): Set = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays } + + override fun cardHomeRelays(): Set = homeRelays.flow.value + + override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value + + override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value + + override fun declaredFollowsByOutboxRelay(): Map> = declaredFollowsPerOutboxRelay.value + val userMetadata = UserMetadataState(signer, cache, scope, settings) // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is @@ -741,7 +673,6 @@ class Account( signer = signer, cache = cache, scope = scope, - assembler = cashuWalletFilterAssembler(), outboxRelaysFlow = outboxRelays.flow, inboxRelaysFlow = notificationRelays.flow, dmRelaysFlow = dmRelays.flow, @@ -804,6 +735,25 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** Concord community orchestration (join/create/messages/moderation). */ + val concord = AccountConcordActions(this) + + /** Marmot/MLS group orchestration (create/members/admins/messages/key packages). */ + val marmot = AccountMarmotActions(this) + + /** NIP-29 relay-group + Buzz workspace orchestration. */ + val relayGroups = AccountRelayGroupActions(this) + + /** Zap/payment orchestration (NIP-57, NWC, BOLT12, onchain). */ + val zaps = AccountZapActions(this) + + /** + * Relay routing + sign-and-publish choke point: computes which relays an event + * should go to (outbox model, hints, channels, broadcast lists) and owns every + * publish path. All Account send helpers delegate here. + */ + val broadcaster = EventBroadcaster(this) + val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } @@ -1039,6 +989,9 @@ class Account( */ fun applyBottomBarItems(items: List): Boolean = settings.changeBottomBarItems(items) + /** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */ + fun applyHiddenDrawerItems(items: Set): Boolean = settings.changeHiddenDrawerItems(items) + suspend fun toggleChatroomPin(room: ChatroomKey) { settings.toggleChatroomPin(room) sendNewAppSpecificData() @@ -1410,278 +1363,6 @@ class Account( cache.justConsumeMyOwnEvent(event) } - suspend fun createZapRequestFor( - event: Event, - pollOption: Int?, - message: String = "", - zapType: LnZapEvent.ZapType, - toUser: User?, - additionalRelays: Set? = null, - amountMillisats: Long? = null, - lnurl: String? = null, - ) = LnZapRequestEvent.create( - zappedEvent = event, - relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), - signer = signer, - pollOption = pollOption, - message = message, - zapType = zapType, - toUserPubHex = toUser?.pubkeyHex, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - suspend fun calculateIfNoteWasZappedByAccount( - zappedNote: Note?, - afterTimeInSeconds: Long, - ): Boolean = zappedNote?.isZappedBy(userProfile(), afterTimeInSeconds, this) == true - - suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) - - suspend fun sendNwcRequest( - request: Request, - onResponse: (Response?) -> Unit, - ) { - val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) - client.publish(event, setOf(relay)) - } - - suspend fun sendNwcRequestToWallet( - walletUri: Nip47WalletConnect.Nip47URINorm, - request: Request, - onResponse: (Response?) -> Unit, - ): HexKey { - val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse) - 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) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) - 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 = nip47SignerState.defaultWalletUri.value ?: return false - return 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 -> - 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 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 -> - 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 (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { - cache.justConsumeMyOwnEvent(zap) - client.publish(zap, 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 = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), - signer = signer, - message = message, - zapType = zapType, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - 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? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.send( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipientPubKey = recipientPubKey, - amountSats = amountSats, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> 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 = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendToAddress( - backend = backend, - signer = signer, - senderPubKey = 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, - feeRateSatPerVByte: Double, - comment: String = "", - zappedEvent: EventHintBundle? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendSplit( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipients = recipients, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> signAndComputeBroadcast(template) } - } - suspend fun report( note: Note, type: ReportType, @@ -1931,239 +1612,57 @@ class Account( relaysItCameFrom } - private fun computeRelayListForLinkedUser(user: User): Set = - if (user == userProfile()) { - notificationRelays.flow.value - } else { - user.inboxRelays()?.ifEmpty { null }?.toSet() - ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.allUsedRelays()) - } + // ------------------------------------------------------------------ + // Broadcast / relay-routing delegates (logic lives in EventBroadcaster). + // ------------------------------------------------------------------ - private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = - if (pubkey == userProfile().pubkeyHex) { - notificationRelays.flow.value - } else { - cache - .getUserIfExists(pubkey) - ?.inboxRelays() - ?.ifEmpty { null } - ?.toSet() - ?: cache.relayHints.hintsForKey(pubkey).toSet() - } + fun computeRelayListToBroadcast(event: Event): Set = broadcaster.computeRelayListToBroadcast(event) - private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() + fun computeRelayListToBroadcast(note: Note): Set = broadcaster.computeRelayListToBroadcast(note) - // 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 + suspend fun broadcast(note: Note) = broadcaster.broadcast(note) - val channelRelays = cache.getAnyChannel(event)?.relays() - if (channelRelays != null && channelRelays.isNotEmpty()) return false + fun sendAutomatic(events: List) = broadcaster.sendAutomatic(events) - return true - } + fun sendAutomatic(event: Event?) = broadcaster.sendAutomatic(event) - fun computeRelayListToBroadcast(event: Event): Set = computeRelayListToBroadcast(event, mutableSetOf()) + fun sendMyPublicAndPrivateOutbox(event: Event?) = broadcaster.sendMyPublicAndPrivateOutbox(event) - private fun computeRelayListToBroadcast( - event: Event, - visited: MutableSet, - ): Set { - // a-tagged events can form cycles; without this the two recursive descents stack-overflow. - if (!visited.add(event.id)) return emptySet() + fun sendMyPublicAndPrivateOutbox(events: List) = broadcaster.sendMyPublicAndPrivateOutbox(events) - if (event is GiftWrapEvent) { - val receiver = event.recipientPubKey() - return if (receiver != null) { - val relayList = - 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() - } + fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event) - val includeBroadcast = wantsBroadcastRelays(event) - val broadcastRelays = if (includeBroadcast) broadcastRelayList.flow.value else emptySet() + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) = broadcaster.signAndSendPrivately(template, relayList) - if (event is MetadataEvent || event is AdvertisedRelayListEvent) { - // everywhere - return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value + broadcastRelays - } + suspend fun signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T = broadcaster.signWithAndSendPrivately(template, signer, relayList) - val relayList = mutableSetOf() - relayList.addAll(broadcastRelays) + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T = broadcaster.signAndSendPrivatelyOrBroadcast(template, relayList) - val author = cache.getUserIfExists(event.pubKey) + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T = broadcaster.signAndComputeBroadcast(template, broadcast) - if (author != null) { - if (author == userProfile()) { - if (includeBroadcast) { - relayList.addAll(outboxRelays.flow.value) - } else { - // outboxRelays mixes in the broadcast list; for personal/channel events - // we want the user's NIP-65 / private / local outbox without it. - relayList.addAll(nip65RelayList.outboxFlow.value) - relayList.addAll(privateStorageRelayList.flow.value) - relayList.addAll(localRelayList.flow.value) - } - } else { - val relays = - author.outboxRelays()?.ifEmpty { null } - ?: author.allUsedRelaysOrNull() - ?: cache.relayHints.hintsForKey(author.pubkeyHex) + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), + ): T = broadcaster.signAnonymouslyAndBroadcast(template, broadcast, anonymousSigner) - relayList.addAll(relays) - } - } else { - relayList.addAll(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 -> - 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 -> - 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 { - 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 { 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(pubKey)), - ids = listOf(host.id), - ) - } - client - .fetchFirst( - filters = relays.associateWith { _ -> listOf(filter) }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - 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 { - client.publish(noteEvent, computeRelayListToBroadcast(note)) - } - } - } + fun republishEventsTo( + events: List, + relays: Set, + ) = broadcaster.republishEventsTo(events, relays) fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic) @@ -2185,245 +1684,6 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) - /** - * Add a joined Concord community (secret-bearing entry) to the private kind-13302 - * list, and announce a self-signed Guestbook JOIN so this member is visible to - * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). - */ - suspend fun joinConcordCommunity( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey? = null, - inviteLabel: String? = null, - ) { - sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) - announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) - } - - /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ - private suspend fun announceConcordGuestbookJoin( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey?, - inviteLabel: String?, - ) { - if (!isWriteable()) return - val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Create a new Concord community: mint its genesis (metadata + #general), - * publish the owner-signed genesis wraps to [relays] (or our outbox), and add - * the secret-bearing entry to the kind-13302 joined list. Returns the new - * community id, or null if not writeable. - */ - suspend fun createConcordCommunity( - name: String, - description: String? = null, - relays: List = emptyList(), - icon: ImagePointer? = null, - ): String? { - if (!isWriteable()) return null - val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } - val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon) - - val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - community.genesisWraps.forEach { client.publish(it, publishTo) } - - joinConcordCommunity( - ConcordCommunityListEntry( - id = community.communityIdHex, - owner = community.ownerPubKey, - ownerSalt = community.ownerSalt.toHexKey(), - root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, - relays = relayUrls, - name = name, - addedAt = TimeUtils.now() * 1000, - ), - ) - return community.communityIdHex - } - - /** - * Mint a shareable invite link for a joined community and publish its - * kind-33301 public bundle to the community relays. Returns the `…/invite/…` - * URL, or null if the community isn't joined or isn't writeable. - */ - suspend fun mintConcordInvite( - communityId: String, - base: String = "https://amethyst.social", - ): String? { - if (!isWriteable()) return null - val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null - val invite = - ConcordActions.inviteFor( - communityIdHex = entry.id, - ownerPubKey = entry.owner, - ownerSaltHex = entry.ownerSalt, - communityRootHex = entry.root, - rootEpoch = entry.rootEpoch, - name = entry.name, - relays = entry.relays, - ) - val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) - - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo) - return minted.url - } - - /** Drop a joined Concord community from the private kind-13302 list by its id. */ - suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) - - /** - * Redeem a Concord invite link (`…/invite/#`): parse it, fetch - * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it - * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. - * - * Returns a [ConcordInviteResult] that separates the failure modes so the UI can - * both explain what went wrong and decide whether a retry could ever help — a - * bundle we can't open (e.g. minted by a newer client) must not strand the user - * on a spinner that retries forever. - * - * A bundle whose `expires_at` has passed is rejected with - * [ConcordInviteResult.Expired]. Expiry is resolved inside - * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather - * than being a field nobody reads. - * - * **This must only ever be called from an explicit user action.** It contacts - * relay URLs carried in the link (chosen by whoever minted it) and publishes a - * Guestbook JOIN signed by this account, so calling it on deep-link arrival would - * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. - * - * If the resolved community is already in the joined list, this returns - * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook - * JOIN, so reopening an old invite for a community you're already in simply takes - * you to it. - */ - suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { - if (!isWriteable()) return ConcordInviteResult.InvalidLink - val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink - - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return ConcordInviteResult.NotReachable - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - - // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a - // stale openable copy) so we honour revocation and can tell the user *why* a link won't open - // instead of stranding them on a spinner that retries a link we can never redeem. - val bundle = - when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { - is InviteBundleStatus.Live -> status.invite - is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired - InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked - InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible - InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable - } - - // Already a member? Just take the user to the community. Re-following and re-announcing a - // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an - // old invite is reopened, so short-circuit to Joined — the screen forwards to the community - // either way ("take me there", not "join again"). - if (concordChannelList.liveCommunities.value.any { it.id == bundle.communityId }) { - return ConcordInviteResult.Joined(bundle.communityId) - } - - val entry = - ConcordCommunityListEntry( - id = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - relays = bundle.relays, - name = bundle.name, - addedAt = TimeUtils.now() * 1000, - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), - ) - joinConcordCommunity(entry) - return ConcordInviteResult.Joined(bundle.communityId) - } - - /** - * Post [text] to a Concord channel: derive the channel plane key, build an - * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), - * fold it locally for an instant echo, and publish it to the community's relays. - * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes - * straight to the community relay set. Returns false if not writeable or the - * community isn't currently joined/folded. - */ - suspend fun sendConcordChannelMessage( - communityId: String, - channelIdHex: String, - text: String, - replyTo: Note? = null, - replyMode: ReplyMode = ReplyMode.INLINE, - imetas: List = emptyList(), - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - - // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the - // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - - val parent = replyTo?.event - val wrap = - when { - // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when - // the user attached media); an inline reply is a kind-9 message quoting the parent; a - // fresh post is a plain kind-9 message. - parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) - parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - parent != null -> - ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - else -> - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) - } - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Send a channel message carrying encrypted image attachments ([imetas], built by the composer - * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are - * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no - * attachments this is just a plain [sendConcordChannelMessage]. - */ - suspend fun sendConcordChannelImageMessage( - communityId: String, - channelIdHex: String, - text: String, - imetas: List, - ): Boolean { - if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - /** * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that * message. Resolves the chat context from the note's gatherer; today it drives the @@ -2439,7 +1699,7 @@ class Account( val gatherers = rootNote.inGatherers gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord -> - return sendConcordChannelMessage( + return this.concord.sendConcordChannelMessage( concord.channelId.communityId, concord.channelId.channelId, text, @@ -2538,1177 +1798,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - /** - * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors - * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's - * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays - * inside the encrypted channel (never a plaintext public kind-7 that would leak - * the message id). [note] must be a Concord channel message (carries a - * [ConcordChannel] gatherer). - */ - suspend fun reactToConcordMessage( - note: Note, - reaction: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to - // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. - val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Edit my own Concord channel message [note] to [newText]. Mirrors - * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the - * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays - * inside the encrypted channel (a public edit would e-tag the private rumor id onto - * public relays). The receiving side overlays the newest edit onto the target message; - * only the *original author's* edits are applied, so we gate to my own kind-9 messages. - * Returns false if [note] isn't an editable Concord message I authored. - */ - suspend fun editConcordChannelMessage( - note: Note, - newText: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - // Edits only apply to plain kind-9 messages, and only the author may edit their own. - if (target !is ChatEvent || target.pubKey != signer.pubKey) return false - - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. - val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at - * most every few seconds while composing. Not folded locally (we never show our own typing); - * ephemeral, so relays broadcast but never store it. - */ - suspend fun sendConcordTyping( - communityId: String, - channelIdHex: String, - ) { - if (!isWriteable()) return - val entry = concordSessions.sessionFor(communityId)?.entry ?: return - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ - private fun publishConcordWrap( - entry: ConcordCommunityListEntry, - wrap: Event, - ) { - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Registers an own Concord channel message with the delivery tracker so its chat - * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed - * shows the inner rumor, so we re-open the wrap (we just built it, so this always - * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions - * and typing wraps skip this — they never become a feed row. - */ - private fun trackConcordDelivery( - entry: ConcordCommunityListEntry, - channelKey: GroupKey, - wrap: Event, - ) { - val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) - } - - // ── Concord roles & moderation (CORD-04) ───────────────────────────────── - // Each publishes a Control Plane edition; authority is enforced at fold time by - // every client's AuthorityResolver, so a call by someone who doesn't outrank the - // target is simply dropped on fold. Owner-authored calls always take effect. - - /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ - suspend fun grantConcordRole( - communityId: String, - member: HexKey, - roleIds: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** The default community Admin role: position 1, holding every management + moderation permission. */ - private fun concordAdminRole() = - RoleEntity( - name = CONCORD_ADMIN_ROLE, - position = 1, - permissions = - ConcordPermissions - .of( - ConcordPermissions.MANAGE_ROLES, - ConcordPermissions.MANAGE_CHANNELS, - ConcordPermissions.MANAGE_METADATA, - ConcordPermissions.KICK, - ConcordPermissions.BAN, - ConcordPermissions.MANAGE_MESSAGES, - ConcordPermissions.CREATE_INVITE, - ).toWire(), - ) - - /** - * If [note] is a Concord channel message whose author the OWNER may toggle - * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner - * qualifies — the Admin role sits at position 1 and the resolver requires the - * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for - * the owner's own note, the owner as target, or a non-owner actor. - */ - fun concordAdminTarget(note: Note): Triple? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null - if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null - val adminRoleId = - state.roles.entries - .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - ?.key - val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) - return Triple(communityId, author, isAdmin) - } - - /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ - suspend fun makeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val cp = session.controlPlaneKey() - - val existing = - session.state.value - ?.roles - ?.entries - ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - val roleIdHex = - existing?.key ?: run { - val roleId = RandomInstance.bytes(32) - val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, roleWrap) - roleId.toHexKey() - } - - val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** Revoke all roles from [member] (demote an admin back to a plain member). */ - suspend fun removeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** - * If [note] is a Concord channel message whose author this account is allowed to - * ban — the actor outranks the target and holds the BAN permission, and the target - * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null - * otherwise, so the UI offers Ban only where we are willing to act. - * - * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the - * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's - * rank-checks the *contents* of a banlist edition — both gate only on the author's - * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its - * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin - * above them is therefore *accepted* by every client today. Since we cannot refuse - * such a ban without diverging from Armada, we at least refuse to author one — this - * restricts what we write, never what we accept, so it cannot split consensus. - * Enforcing it on the fold needs a spec change; see the QA plan's open findings. - */ - fun concordBanTarget(note: Note): Pair? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val authority = - concordSessions - .sessionFor(communityId) - ?.state - ?.value - ?.authority ?: return null - if (authority.isOwner(author)) return null - // The owner short-circuits rather than going through canActOn: canActOn starts at - // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put - // the owner on the banlist (see the KDoc) — routing the owner through it would let them be - // locked out of moderating their own community. - val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN) - return if (canBan) communityId to author else null - } - - /** Add [member] to the community banlist. */ - suspend fun banConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Remove [member] from the community banlist. */ - suspend fun unbanConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── - // A ban is a soft removal — the banned member still holds the room key and can - // still decrypt traffic; every client just declines to *show* their posts. A - // Refounding is the hard removal: it rotates the community_root, so a removed - // member's key stops working for anything published afterwards. - - /** - * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, - * roll the `community_root`, re-key every retained member (Guestbook membership ∪ - * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted - * Control Plane under the new root. A removed member keeps the prior root (so - * their history stays readable) but receives no blob, so they can never decrypt - * anything published after the rotation. - * - * Requires ownership or the BAN permission; returns false otherwise (or if the - * community isn't joined/writeable, or a target is the owner). - */ - suspend fun refoundConcordCommunity( - communityId: String, - removed: Set, - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val state = session.state.value ?: return false - val authority = state.authority - val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) - if (!iCanBan) return false - val removedLower = removed.mapTo(HashSet()) { it.lowercase() } - if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false - - // 1. Ban the removed members on the current Control Plane so the compacted snapshot — - // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally - // first, so each subsequent edition chains onto the updated banlist head. - for (target in removedLower) { - val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, banWrap) - } - - // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. - // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the - // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other - // clients need not), so building the set without observed authors silently expelled every - // member who had only ever posted: they hold no role, receive no blob, and the Refounding - // strands them. That mainly hit cross-client communities, where Armada members are the - // bulk of the roster. - // - // Still a floor, not a census (see allMembers): a member who joined without a Guestbook - // motion, holds no role, and has never posted leaves no trace to find, so a Refounding - // cannot re-key them. Stranded recovery is what gets those members back. - val recipients = - (session.allMembers() + signer.pubKey) - .mapTo(HashSet()) { it.lowercase() } - .apply { - removeAll(removedLower) - removeAll(authority.bannedMembers()) - }.toList() - - // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. - val entry = session.entry - val newRoot = RandomInstance.bytes(32) - val build = - ConcordActions.buildRefounding( - rotatorSigner = signer, - communityId = communityId, - priorRoot = entry.root.hexToByteArray(), - newRoot = newRoot, - rootEpoch = entry.rootEpoch, - priorControlWraps = session.controlPlaneWraps(), - priorControlKey = session.controlPlaneKey(), - recipientsXOnly = recipients, - createdAt = TimeUtils.now(), - ) - - // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs - // (the key that unlocks it) to the community relays. - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (publishTo.isNotEmpty()) { - build.controlWraps.forEach { client.publish(it, publishTo) } - build.rekeyWraps.forEach { client.publish(it, publishTo) } - } - - // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and - // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch) - return true - } - - // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered - // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not - // adopted — and re-published — twice on successive revision ticks. - private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) - - /** - * Persist a rotated access root/epoch for [entry], keeping the prior root as a - * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the - * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — - * without this, cascading removals would lose everyone but the roster). No-op if - * this exact rotation was already adopted. - */ - private suspend fun adoptConcordRoot( - entry: ConcordCommunityListEntry, - newRoot: ByteArray, - newEpoch: Long, - ) { - if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) - sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) - announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) - } - - /** - * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for - * each joined community, look for our new root among the kind-3303 wraps seen at - * our next base-rekey address. If a role-authorized rotator (owner or a current, - * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the - * session rebuilds at the new epoch and its next-rekey address moves on, so a stale - * wrap never re-triggers. Called on every Concord revision tick. - * - * Authority is the roster, never key possession: any non-banned BAN-holder may - * rotate, including for the owner. The owner deliberately does NOT refuse a root - * authored by someone else — refusing would strand the owner alone on the dead - * epoch whenever an admin legitimately rotates, and would diverge from Armada, - * which forks a community across clients. Self-escalation to BAN is prevented - * upstream by the role rank gate in AuthorityResolver. - * - * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, - * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the - * owner included) by omission — nothing on this receive path can prevent it. The - * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves - * the invite link the membership was joined through and merges forward. - */ - private suspend fun drainConcordRekeys() { - if (!isWriteable()) return - for (session in concordSessions.sessions()) { - val wraps = session.pendingBaseRekeyWraps() - if (wraps.isEmpty()) continue - val entry = session.entry - val received = - ConcordActions.openBaseRekey( - wraps = wraps, - baseRekey = session.nextBaseRekeyKey(), - recipientSigner = signer, - priorRoot = entry.root.hexToByteArray(), - rootEpoch = entry.rootEpoch, - ) ?: continue - if (received.newEpoch <= entry.rootEpoch) continue - val authority = session.state.value?.authority ?: continue - - // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder - // who has themselves been banned could still rotate the whole community. - val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) - if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch) - } - } - - // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the - // Concord revision tick (which fires on every structural change) without turning it into a - // relay-fetch loop. - private val lastConcordRecoveryCheck = ConcurrentHashMap() - - /** - * Stranded recovery (CORD-05/06 receive path). A Refounding carries only - * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left - * out of the rekey recipient set receives nothing and sits on the dead epoch - * forever while everyone else moves on. This happens to any member, the owner - * included, and [drainConcordRekeys] cannot prevent it: there is no message to - * miss detecting. - * - * The way back is the invite link the membership was joined through - * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and - * carried through every rotation by [adoptConcordRoot]). The community keeps - * re-minting its bundle at that same addressable coordinate, so a bundle there at - * a **strictly higher** epoch than ours proves we were left behind — and carries - * the new root. Same or lower epoch is a no-op. Memberships with no link (direct - * invites, legacy entries) are inert here; that is expected, not an error. - * - * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps - * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the - * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays - * derivable). We then re-announce the Guestbook at the new epoch, exactly as an - * ordinary rotation does, so the recovered member is visible to whoever refounds - * next instead of being silently dropped again. - * - * Called on the Concord revision tick, but rate-limited per community - * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. - */ - private suspend fun recoverStrandedConcordCommunities() { - if (!isWriteable()) return - val now = TimeUtils.nowMillis() - for (entry in concordChannelList.liveCommunities.value) { - val inviteRef = entry.inviteRef ?: continue - val last = lastConcordRecoveryCheck[entry.id] - if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue - lastConcordRecoveryCheck[entry.id] = now - - val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue - val relays = - ( - parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + - entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - ).toSet() - if (relays.isEmpty()) continue - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. - val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - - val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue - if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue - Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") - sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) - announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) - } - } - - /** - * Replace the community metadata (name / icon / description / relays) with a new - * Control-Plane edition. Honored on fold only when this account holds - * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. - */ - suspend fun editConcordMetadata( - communityId: String, - name: String, - description: String?, - icon: ImagePointer?, - banner: ImagePointer?, - relays: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) - val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold - * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on - * the same predicate. The channel id is a fresh random 32-byte entity id. - */ - suspend fun createConcordChannel( - communityId: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val channelId = RandomInstance.bytes(32) - val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ - suspend fun renameConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Carry the standing definition forward and change only the name. A ChannelEntity built from - // scratch defaults `private` and `voice` to false, so renaming a private channel used to - // publish an edition declaring it PUBLIC — and a voice channel became a text channel. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ - suspend fun deleteConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Same as rename: preserve the standing flags so a tombstone does not also silently - // reclassify the channel it retires. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from - * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT - * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a - * card can show what the link opens, or null if the link is invalid/unreadable. - */ - suspend fun peekConcordInvite(url: String): CommunityInvite? { - val parsed = ConcordActions.parseInviteLink(url) ?: return null - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return null - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - } - - /** - * Bootstrap the Concord hub from the network: fetch this account's kind-13302 - * joined-communities list and fold the newest into [LocalCache], so communities - * we joined on another Concord client with this key surface here. - * - * We query a wide relay set because different Concord clients publish this - * private list to different places: the reference clients (Armada/Vector) push - * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may - * also have copied it onto their **own** outbox/read relays. Our normal account - * subscription never asks for kind 13302, so without this explicit fetch a - * community joined on Armada would never appear — even if the list sits on the - * user's own outbox. - * - * Read-only import: kind 13302 is replaceable, so folding an older copy is a - * no-op and this is safe to call on every hub open. Merging our own edits with - * a foreign writer's is a separate concern (newest-wins replaceable). - * - * [extraRelays] are additional relays to query — the bootstrap relays saved on the - * bottom-bar tabs of pinned communities. A community's private list frequently lives - * only on the community's own relays (never the user's outbox), so a community pinned - * to the bottom bar would otherwise never surface when opened cold. - */ - suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { - val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet() - if (relays.isEmpty()) return - val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) - // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give - // the fetch a generous window to drain every relay before we pick the newest copy. - val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) - val newest = events.filterIsInstance().maxByOrNull { it.createdAt } - val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0 - Log.d( - "Concord", - "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + - "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", - ) - newest?.let { cache.justConsumeMyOwnEvent(it) } - } - - /** - * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox - * fill in without the user opening each channel one by one. Per channel, a channel read before is - * caught up from its last-read time (accurate unread badge + the missed messages ready when it - * opens) while a channel never read pulls only its single newest wrap for a preview — see - * [ConcordSubscriptionPlanner.channelPreviewFilters]. - * - * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the - * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), - * so it lands in the channel's message store the previews/unread counts read — and the always-on - * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. - * So this only needs to run when a community's channels first fold (the account preload) or its - * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. - */ - suspend fun warmConcordChannelPreviews(entries: List) { - val filters = - entries.flatMap { entry -> - val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList() - ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { channelIdHex -> - loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) - }) - } - if (filters.isEmpty()) return - val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } - client.fetchAll(filters = byRelay, timeoutMs = 20_000L) - } - - /** - * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must - * never fold on a truncated edition set. - * - * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold - * silently drops channels or mis-renders membership. Two ways that happens, both closed here: - * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition - * with a `created_at` below the high-water mark that we never actually ingested — an unban - * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is - * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the - * whole plane every run. - * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can - * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` - * cursors until a plane is drained), so the fold sees every edition regardless of the cap. - * - * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the - * priors). Wraps ingest through the global cache connector → [concordSessions] like every other - * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into - * one filter is safe here precisely because we page — the cap no longer truncates. The live control - * subscription still carries brand-new editions in real time; this is the periodic completeness pass. - */ - suspend fun syncConcordControlPlanes(entries: List) { - if (entries.isEmpty()) return - val authorsByRelay = HashMap>() - for (entry in entries) { - for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) - } - } - if (authorsByRelay.isEmpty()) return - // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a - // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. - val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } - var drained = 0 - client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } - Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") - } - - // ── NIP-29 relay-group actions ─────────────────────────────────────────── - // 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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 (!isWriteable()) return - val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) - 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, - ): String? { - val signed = 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 = 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) }) { - client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), - timeoutMs = 8_000, - pendingOnAuthRequired = true, - ) { _, _ -> false } - results = 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): 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) - 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) - 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 (!isWriteable()) return null - val signed = signer.sign(JobRequestEvent.build(request, channelId, null)) - // Reflect it locally so the board updates immediately (publish only sends to relays). - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return - val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) - val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) - val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val signed = signer.sign(ApprovalGrantEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val signed = signer.sign(ApprovalDenyEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return - val template = - eventTemplate(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 = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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 = emptyList(), - geohashes: List = 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. - 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, - ) - signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } - - val id = GroupId(groupId, relay) - 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 = - 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(pubKey)) - } - 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) - 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, - ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) - 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)) - 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, - ) { - // 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) - 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, - ) { - 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, - ) { - 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 = emptyList(), - geohashes: List = emptyList(), - parent: String? = channel.parentGroupId(), - children: List = 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, - ) - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) @@ -3745,14 +1834,6 @@ class Account( client.publish(signedEvent, relays) } - fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } - - fun sendAutomatic(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, computeRelayListToBroadcast(event)) - } - suspend fun sendWebBookmark( url: String, title: String?, @@ -3971,24 +2052,6 @@ class Account( client.publish(signedEvent, outboxRelays.flow.value) } - fun sendMyPublicAndPrivateOutbox(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, outboxRelays.flow.value) - } - - fun sendMyPublicAndPrivateOutbox(events: List) { - events.forEach { - client.publish(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } - - fun sendLiterallyEverywhere(event: Event) { - client.publish(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) - cache.justConsumeMyOwnEvent(event) - } - suspend fun pollRespond( event: PollEvent, responses: Set, @@ -4221,96 +2284,6 @@ class Account( signAndComputeBroadcast(template) } - suspend fun signAndSendPrivately( - template: EventTemplate, - relayList: Set, - ) { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - 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 signWithAndSendPrivately( - template: EventTemplate, - signer: NostrSigner, - relayList: Set, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - if (relayList.isNotEmpty()) client.publish(event, relayList) - return event - } - - suspend fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val relays = relayList(event) - val targets = - if (!relays.isNullOrEmpty()) { - relays.toSet() - } else { - computeRelayListToBroadcast(event) - } - chatDeliveryTracker.trackPublic(event.id, targets) - client.publish(event, targets) - return event - } - - suspend fun signAndComputeBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - - suspend fun signAnonymouslyAndBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), - ): T { - val event = anonymousSigner.sign(template) - - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. @@ -4803,541 +2776,6 @@ class Account( // --- Marmot Group Messaging --- - /** - * 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 { - val groupRelays = - marmotManager - ?.groupMetadata(nostrGroupId) - ?.relays - ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - }?.toSet() - return if (!groupRelays.isNullOrEmpty()) groupRelays else 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, - ) { - 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 = marmotManager ?: return - if (!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 - 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. - marmotGroupList.markAsKnown(nostrGroupId) - if (groupRelays.isEmpty()) { - Log.w("MarmotDbg") { - "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" - } - } - 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 = marmotManager ?: return "Error: Marmot not initialized" - if (!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 = outboxRelays.flow.value - val memberKeyPackageRelays = - ( - cache - .getAddressableNoteIfExists( - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - .createAddress(memberPubKey), - )?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - )?.relays()?.toSet().orEmpty() - val memberOutbox = - 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(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, - ) { - val memberPubKey = keyPackageEvent.pubKey - Log.d("MarmotDbg") { - "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + - "groupRelays=${groupRelays.size}" - } - val manager = marmotManager ?: return - if (!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 = 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 }}" - } - 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 = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) - val recipientInbox = - cache - .getOrCreateUser(memberPubKey) - .dmInboxRelays() - .orEmpty() - val relayList = computed + outboxRelays.flow.value + recipientInbox - Log.d("MarmotDbg") { - "addMarmotGroupMember: welcome gift wrap relay sources " + - "computeRelayListToBroadcast=${computed.size} myOutbox=${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 }}" - } - } - 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 = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value) - - /** - * Publish or rotate KeyPackage events. - */ - suspend fun publishMarmotKeyPackages() { - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" } - return - } - if (!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 -> - 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 }}" - } - client.publish(event, relays) - } - } - } - - /** - * Generate and publish initial KeyPackage for this account. - */ - suspend fun publishMarmotKeyPackage() { - val manager = marmotManager ?: return - if (!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)}…" - } - cache.justConsumeMyOwnEvent(event) - 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. - */ - private suspend fun ensureMarmotKeyPackagePublished() { - val manager = marmotManager ?: return - if (!isWriteable()) return - try { - val hasBundle = manager.hasActiveKeyPackages() - Log.d("MarmotDbg") { - "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${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 = marmotManager ?: return false - return manager.hasActiveKeyPackages() - } - - /** - * Create a new Marmot MLS group. - */ - suspend fun createMarmotGroup(nostrGroupId: HexKey) { - val manager = marmotManager ?: return - if (!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. - 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, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) - if (metadata != null && metadata.adminPubkeys.contains(signer.pubKey)) { - val remaining = metadata.adminPubkeys.filter { it != 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 != signer.pubKey } - if (heir != null) remaining.add(heir) - } - if (remaining.isNotEmpty()) { - val demoted = metadata.copy(adminPubkeys = remaining) - val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted) - 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 marmotGroupList.rooms) stops surfacing the group. - marmotGroupList.removeGroup(nostrGroupId) - 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 ${signer.pubKey.take(8)}…" } - marmotManager?.resetAllState() - for (groupId in marmotGroupList.allGroupIds()) { - 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, - ) { - Log.d("MarmotDbg") { - "removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " + - "groupRelays=${groupRelays.size}" - } - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" } - return - } - if (!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 = 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 }}" - } - 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, - ) { - val manager = marmotManager ?: return - if (!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 = marmotGroupList.getOrCreateGroup(nostrGroupId) - manager.syncMetadataTo(nostrGroupId, chatroom) - 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, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (metadata.adminPubkeys.contains(targetPubKey)) return - - val outboxRelayStrings = 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, - ) { - val manager = marmotManager ?: return - if (!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 = outboxRelays.flow.value.map { it.url } - val updated = - metadata - .copy(adminPubkeys = remaining) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) - } - suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { @@ -5985,14 +3423,6 @@ class Account( .mapNotNull { it.event } /** Publishes the given events to each of the given relays. No-op if either list is empty. */ - fun republishEventsTo( - events: List, - relays: Set, - ) { - if (relays.isEmpty() || events.isEmpty()) return - events.forEach { client.publish(it, relays) } - } - suspend fun requestToVanish( relays: List, reason: String, @@ -6101,7 +3531,7 @@ class Account( // restoreAll() above has already restored any previously // generated bundles. Only generate-and-publish if no active // bundle exists in memory after restore. - ensureMarmotKeyPackagePublished() + marmot.ensureMarmotKeyPackagePublished() // Sync MIP-01 metadata from restored groups to chatrooms and // re-hydrate decrypted messages from persistent storage. @@ -6165,10 +3595,13 @@ class Account( concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. - runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + // A promotion to staff delivers the Control Plane write key inside the Grant + // itself (CORD-04 §3), so the fold that seats the role is also when it arrives. + runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. - runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } + runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt new file mode 100644 index 0000000000..1bf9339ae4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -0,0 +1,1607 @@ +/* + * 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.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.util.concurrent.ConcurrentHashMap + +/** Name of the default Concord community Admin role minted by "Make admin". */ +private const val CONCORD_ADMIN_ROLE = "Admin" + +/** + * How often a joined Concord community's stored invite link is re-resolved to check whether + * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is + * rare and silent, so this trades detection latency for not turning the revision tick into a + * relay-fetch loop. + */ +private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L + +/** + * How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`. + * + * 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44 + * encryptions at the ceiling — heavy but survivable on a phone, and far above any real community. + * Raising it raises the cost of the attack it exists to bound, not the safety. + */ +private const val MAX_REFOUNDING_RECIPIENTS = 5_000 + +/** + * Concord (encrypted communities) orchestration for an [Account]: join/create/ + * invite flows, channel messages/reactions/edits/typing, roles and moderation, + * community refound/recovery, metadata and channel management, and control-plane + * sync. Event building lives in the commons `ConcordActions`/`ConcordModeration` + * objects; this class wires them to the account's signer, session manager, + * channel list, and relay client. Rumor ingestion stays on [Account] + * (`consumeConcordRumorGated`), which is wired into `ConcordSessionManager`. + */ +class AccountConcordActions( + private val account: Account, +) { + /** + * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * list, and announce a self-signed Guestbook JOIN so this member is visible to + * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). + */ + suspend fun joinConcordCommunity( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ) { + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(entry)) + announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + } + + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ + private suspend fun announceConcordGuestbookJoin( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey?, + inviteLabel: String?, + ) { + if (!account.isWriteable()) return + val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(account.signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Create a new Concord community: mint its genesis (metadata + #general), + * publish the owner-signed genesis wraps to [relays] (or our outbox), and add + * the secret-bearing entry to the kind-13302 joined list. Returns the new + * community id, or null if not writeable. + */ + suspend fun createConcordCommunity( + name: String, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): String? { + if (!account.isWriteable()) return null + val relayUrls = + relays.ifEmpty { + account.outboxRelays.flow.value + .map { it.url } + } + val community = ConcordActions.createCommunity(account.signer, name, TimeUtils.now(), description, relayUrls, icon) + + val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + community.genesisWraps.forEach { account.client.publish(it, publishTo) } + + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + // The creator is the founding staff member (CORD-02 §2): it keeps the write + // secret and publishes only the derived pubkey to everyone else. + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = relayUrls, + name = name, + addedAt = TimeUtils.now() * 1000, + ), + ) + return community.communityIdHex + } + + // ---- CORD-05 Invite List (kind 13303) ------------------------------------- + + /** + * This account's Invite List (kind 13303): the creator's private, self-encrypted record of every + * link they minted (`token` + `signer_sk` per entry). + * + * Returns **null** when the list could not be read — no relay answered, or the signer refused + * the decrypt — and an empty document only when the account genuinely has no list yet. Callers + * must not conflate the two: republishing an "empty" list over this replaceable coordinate + * destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated. + * + * Read on the account's OUTBOX relays, never a community's: the coordinate is + * (13303, me, "") — one list for the whole account — so scoping it per community would fork it + * into divergent versions that the newest-wins rule then silently collapses. + * + * Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is + * bookkeeping the user never sees, needed only at mint and at rotation. + */ + private suspend fun readConcordInviteList(): ConcordInviteListDocument? { + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay + // served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle + // timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes + // the signer_sk of every link it failed to read, so the two must be told apart. + val reasons = mutableMapOf() + val events = + account.client.fetchAllWithHooks( + filters = relays.associateWith { listOf(filter) }, + doneOut = reasons, + ) { _, _ -> true } + + val newest = + events + .mapNotNull { it.second as? ConcordInviteListEvent } + // Filter by kind BEFORE picking the newest: taking the newest of anything and then + // casting means one stray event at this coordinate reads as "unreadable" forever. + .maxByOrNull { it.createdAt } + ?: return if (reasons.anyRelayServed()) { + ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one + } else { + null // nobody answered; we know nothing about what is published + } + return newest.decrypt(account.signer) + } + + /** + * Merges [patch] into the published Invite List and republishes it, returning whether it landed. + * + * Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is + * replaceable, so publishing a patch-only document over an unread list deletes every other + * link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next + * rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is + * enough to trigger that, which is exactly how the kind-13302 community list was once emptied. + */ + private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean { + val publishTo = account.outboxRelays.flow.value + if (publishTo.isEmpty()) return false + val base = + readConcordInviteList() ?: run { + Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } + return false + } + // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event + // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever + // local signing worked, and every caller's "did the record land?" gate becomes decorative. + return runCatching { + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) + }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + } + + /** + * Re-posts every live link this account minted for [entry]'s community at its own coordinate, + * carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the + * link signer, so this moves the link behind the same URL instead of orphaning it at a dead + * epoch — which is the whole premise stranded recovery rests on. + * + * [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow + * decrypts asynchronously, so reading it straight after adopting a new root yields the OLD + * epoch and would re-mint every link onto the epoch we just left. + * + * Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link + * fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A + * coordinate whose newest event is a revocation tombstone is left alone: re-posting a live + * bundle over it would silently un-revoke the link. + */ + private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return 0 + val list = readConcordInviteList() ?: return 0 + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + val now = TimeUtils.now() + + // An elapsed or retired link can no longer be joined; re-posting it would only resurrect a + // dead URL at a live epoch. + val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned } + if (links.isEmpty()) return 0 + + // One REQ for every link's bundle rather than a round trip each. This runs inside the + // user-visible Refounding, and a serial fetch per link makes a removal take time linear in + // how many links the creator ever minted, each able to wait out its own idle timeout. + val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() } + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) }) + val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() } + + return coroutineScope { + byAuthor + .map { (author, link) -> + async { + runCatching { + val token = link.token.hexToByteArray() + // Classify per coordinate, never over the pooled set: one link's newer + // revocation tombstone must not decide another link's status. + val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false + val moved = + current.invite.copy( + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + relays = entry.relays, + ) + // Confirmed: a link counted as moved but never stored is a link its + // holders can no longer redeem, reported as a success. + account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false) + } + }.awaitAll() + .count { it } + } + } + + /** + * Mint a shareable invite link for a joined community and publish its + * kind-33301 public bundle to the community relays. Returns the `…/invite/…` + * URL, or null if the community isn't joined or isn't writeable. + */ + suspend fun mintConcordInvite( + communityId: String, + base: String = "https://amethyst.social", + ): String? { + if (!account.isWriteable()) return null + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return null + // CREATE_INVITE, and not while banned. This used to check only that we held the community, + // which made minting the one moderation-free action in the app: a member the owner had just + // banned could tap the invite button and hand out a working link to the community they were + // removed from, and every account they invited arrived as a fresh un-banned npub. + // + // Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control + // entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published + // OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is. + // The owner is proven by the community id (CORD-02), so they are read off the entry and can + // mint before the session exists — the session is built asynchronously off the joined list, + // and requiring it here would have made the owner's own invite button fail on a cold start. + // Everyone else needs the folded roster, so no session means no invite. + val session = account.concordSessions.sessionFor(communityId) + val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true) + if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null + val invite = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + // The joiner can never derive the Control Plane address, so the bundle carries + // it (CORD-05 §1). Null on a legacy community, which has none to carry. + controlPk = entry.controlPk, + ) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) + + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + // Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk` + // was never stored can never be refreshed, so the next Refounding orphans it and everyone + // holding it is stranded — with nothing to have warned them. Failing the mint is the honest + // outcome; a stored entry for a link nobody received is harmless by comparison. + if (!publishConcordInviteList( + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = entry.id, + url = minted.url, + createdAt = TimeUtils.now(), + ), + ), + ), + ) + ) { + Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" } + return null + } + + if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + return minted.url + } + + /** + * Every link this account minted for [communityId] that is still live, newest first — the + * backing list for the invite-links screen. + * + * Null means the list could not be read (no relay answered, or the signer refused the decrypt), + * which the UI must show as an error rather than as "you have no links": telling a creator their + * leaked link doesn't exist is worse than telling them we couldn't check. + * + * Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList] + * already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window + * between our revoke and the next merge. + */ + suspend fun listConcordInviteLinks(communityId: String): List? { + val list = readConcordInviteList() ?: return null + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + return list.entries + .filter { it.communityId == communityId && it.token !in tombstoned } + .sortedByDescending { it.createdAt } + } + + /** + * Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then + * records the retirement in the kind-13303 list. Returns false if the link could not be retired. + * + * No community permission is checked, deliberately. The coordinate is authored by the link + * signer, whose secret only the creator holds, so revoking is an act on your own key rather than + * on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer + * retire the links they had already handed out, which is precisely when they most need to. + * + * The wire tombstone goes first and the list second. That is the inverse of minting and it is + * deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a + * tombstoned token's entry terminally, so recording first and then failing to publish would + * leave the link live with its signer gone and no way left to retire it. A failed list write is + * recoverable — the link is already dead on the wire, and the refresh path re-mints only a + * coordinate that still resolves Live. + */ + suspend fun revokeConcordInvite( + communityId: String, + token: String, + ): Boolean { + if (!account.isWriteable()) return false + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return false + val link = + readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } + ?: run { + Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } + return false + } + + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return false + // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a + // tombstone no relay stored — and the list write below would then drop this entry on merge, + // destroying the only `signer_sk` that could ever retire the link while the link stays live. + val published = + runCatching { + account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) + }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) + if (!published) return false + + if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + // The link is already dead on the wire, so this is bookkeeping we can retry rather than a + // failed revocation. Reported as success for exactly that reason. + Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } + } + return true + } + + /** Drop a joined Concord community from the private kind-13302 list by its id. */ + suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) + + /** + * Redeem a Concord invite link (`…/invite/#`): parse it, fetch + * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it + * with the fragment token, and add the resulting secret-bearing entry to the + * kind-13302 joined list. + * + * Returns a [ConcordInviteResult] that separates the failure modes so the UI can + * both explain what went wrong and decide whether a retry could ever help — a + * bundle we can't open (e.g. minted by a newer client) must not strand the user + * on a spinner that retries forever. + * + * A bundle whose `expires_at` has passed is rejected with + * [ConcordInviteResult.Expired]. Expiry is resolved inside + * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather + * than being a field nobody reads. + * + * **This must only ever be called from an explicit user action.** It contacts + * relay URLs carried in the link (chosen by whoever minted it) and publishes a + * Guestbook JOIN signed by this account, so calling it on deep-link arrival would + * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. + * + * If the resolved community is already in the joined list, this returns + * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook + * JOIN, so reopening an old invite for a community you're already in simply takes + * you to it. + */ + suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink + + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return ConcordInviteResult.NotReachable + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + + // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a + // stale openable copy) so we honour revocation and can tell the user *why* a link won't open + // instead of stranding them on a spinner that retries a link we can never redeem. + val bundle = + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired + InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked + InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible + InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable + } + + // Already a member? Just take the user to the community. Re-following and re-announcing a + // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an + // old invite is reopened, so short-circuit to Joined — the screen forwards to the community + // either way ("take me there", not "join again"). + if (account.concordChannelList.liveCommunities.value + .any { it.id == bundle.communityId } + ) { + return ConcordInviteResult.Joined(bundle.communityId) + } + + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this the rotation meant to expel them hands them the new + // keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the + // other door into the same room. + // + // Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields + // the root, and no verdict means no join. Two things make that safe to insist on rather than + // a way to brick valid invites: + // + // - the plane is fetched over the SAME relays that just served the bundle, not the relay + // list inside the bundle alone, which can be stale (a moved relay, a link minted before a + // relay change) and would otherwise refuse a community we can plainly reach; + // - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing + // older ban edition fails the gate open — it re-admits the very account it exists to + // refuse — so the one direction we must not economise on is completeness. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + // Union, not `ifEmpty`: the relays that served the bundle are known-good for this community, + // and the bundle's own list is the one that goes stale. + val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays + val planeWraps = mutableListOf() + account.client.fetchAllPagesFromPool( + filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, + ) { event, _ -> planeWraps.add(event) } + val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys) + if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable + if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { + return ConcordInviteResult.Banned + } + + val entry = + ConcordCommunityListEntry( + id = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split, so we fold it at the legacy address. + controlPk = bundle.controlPk, + relays = bundle.relays, + name = bundle.name, + addedAt = TimeUtils.now() * 1000, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + ) + joinConcordCommunity(entry) + return ConcordInviteResult.Joined(bundle.communityId) + } + + /** + * Post [text] to a Concord channel: derive the channel plane key, build an + * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), + * fold it locally for an instant echo, and publish it to the community's relays. + * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes + * straight to the community relay set. Returns false if not writeable or the + * community isn't currently joined/folded. + */ + suspend fun sendConcordChannelMessage( + communityId: String, + channelIdHex: String, + text: String, + replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, + imetas: List = emptyList(), + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + + // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the + // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + + val parent = replyTo?.event + val wrap = + when { + // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when + // the user attached media); an inline reply is a kind-9 message quoting the parent; a + // fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + parent != null -> + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + else -> + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + } + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Send a channel message carrying encrypted image attachments ([imetas], built by the composer + * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are + * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no + * attachments this is just a plain [sendConcordChannelMessage]. + */ + suspend fun sendConcordChannelImageMessage( + communityId: String, + channelIdHex: String, + text: String, + imetas: List, + ): Boolean { + if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors + * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's + * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays + * inside the encrypted channel (never a plaintext public kind-7 that would leak + * the message id). [note] must be a Concord channel message (carries a + * [ConcordChannel] gatherer). + */ + suspend fun reactToConcordMessage( + note: Note, + reaction: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to + // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. + val emojiTags = + account.emoji + .findEmojiTags(reaction) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Edit my own Concord channel message [note] to [newText]. Mirrors + * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the + * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays + * inside the encrypted channel (a public edit would e-tag the private rumor id onto + * public relays). The receiving side overlays the newest edit onto the target message; + * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * Returns false if [note] isn't an editable Concord message I authored. + */ + suspend fun editConcordChannelMessage( + note: Note, + newText: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + // Edits only apply to plain kind-9 messages, and only the author may edit their own. + if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false + + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. + val emojiTags = + account.emoji + .findEmojiTags(newText) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at + * most every few seconds while composing. Not folded locally (we never show our own typing); + * ephemeral, so relays broadcast but never store it. + */ + suspend fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) { + if (!account.isWriteable()) return + val session = account.concordSessions.sessionFor(communityId) ?: return + // A ban hides every message we send, so continuing to announce that we are typing them is + // both noise and a contradiction of what the ban told the room. Filtered on the receive side + // too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending. + if (session.state.value + ?.authority + ?.isBanned(account.signer.pubKey) == true + ) { + return + } + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ + private fun publishConcordWrap( + entry: ConcordCommunityListEntry, + wrap: Event, + ) { + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Registers an own Concord channel message with the delivery tracker so its chat + * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed + * shows the inner rumor, so we re-open the wrap (we just built it, so this always + * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions + * and typing wraps skip this — they never become a feed row. + */ + private fun trackConcordDelivery( + entry: ConcordCommunityListEntry, + channelKey: GroupKey, + wrap: Event, + ) { + val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) + } + + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── + // Each publishes a Control Plane edition; authority is enforced at fold time by + // every client's AuthorityResolver, so a call by someone who doesn't outrank the + // target is simply dropped on fold. Owner-authored calls always take effect. + + /** + * The Control Plane keys for a moderation write, or null when this account cannot + * publish there: on a split epoch only `control_root` holders can mint a wrap that + * verifies at the plane's address (CORD-02 §2), and wrapping without the secret + * throws rather than missigning. Rank and key possession can diverge — a freshly + * promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3), + * and the UI gates on rank — so every moderation verb no-ops through this check + * instead of crashing on a rank-gated action. + */ + private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? { + val cp = session.controlPlaneKeys() + if (!cp.canWrite) { + Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" } + return null + } + return cp + } + + /** + * Whether this account may take the action guarded by [bit] in [session] — and, when [target] is + * given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal"). + * + * Every moderation verb below funnels through this. It used to live only in the composables that + * drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which + * ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere + * else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` — + * a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced. + * + * Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry] + * rather than from the fold, because the community id proves them (CORD-02) and they must stay able + * to moderate before their Control Plane has finished folding — or through a fold a rogue has + * damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else + * anything. + */ + private fun isAuthorizedFor( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): Boolean { + val me = account.signer.pubKey + if (session.entry.owner.equals(me, ignoreCase = true)) return true + val authority = session.state.value?.authority ?: return false + // hasPermission, never effectivePermissions: the latter reads the roles alone and would let a + // banned staffer keep acting for as long as they hold the key. + val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit) + if (!allowed) { + Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" } + } + return allowed + } + + /** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */ + private fun controlKeysForAction( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): ControlPlaneKeys? { + if (!isAuthorizedFor(session, bit, target)) return null + return controlKeysForWrite(session) + } + + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ + suspend fun grantConcordRole( + communityId: String, + member: HexKey, + roleIds: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + // A Grant that first makes its member staff must deliver the control_root in the same + // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the + // roles carry a Control-writing bit and we hold the secret to hand over. + val wrap = + ConcordModeration.grantWithStaffDelivery( + actor = account.signer, + controlPlane = cp, + communityId = communityId.hexToByteArray(), + member = member, + roleIds = roleIds, + current = session.controlEditions(), + createdAt = TimeUtils.now(), + owner = session.entry.owner, + controlRoot = session.entry.controlRoot?.hexToByteArray(), + epoch = session.entry.rootEpoch, + ) + publishConcordWrap(session.entry, wrap) + return true + } + + /** The default community Admin role: position 1, holding every management + moderation permission. */ + private fun concordAdminRole() = + RoleEntity( + name = CONCORD_ADMIN_ROLE, + position = 1, + permissions = + ConcordPermissions + .of( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ConcordPermissions.CREATE_INVITE, + ).toWire(), + ) + + /** + * If [note] is a Concord channel message whose author the OWNER may toggle + * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner + * qualifies — the Admin role sits at position 1 and the resolver requires the + * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for + * the owner's own note, the owner as target, or a non-owner actor. + */ + fun concordAdminTarget(note: Note): Triple? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val session = account.concordSessions.sessionFor(communityId) ?: return null + val state = session.state.value ?: return null + // Rank alone isn't enough on a split epoch: the Grant edition takes the control_root + // (CORD-02 §2), so don't offer an action the verb would refuse. + if (!session.controlPlaneKeys().canWrite) return null + if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null + val adminRoleId = + state.roles.entries + .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + ?.key + val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) + return Triple(communityId, author, isAdmin) + } + + /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ + suspend fun makeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + + val existing = + session.state.value + ?.roles + ?.entries + ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + val roleIdHex = + existing?.key ?: run { + val roleId = RandomInstance.bytes(32) + val roleWrap = ConcordModeration.defineRole(account.signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, roleWrap) + roleId.toHexKey() + } + + // Admin carries every management bit, so this Grant makes its member staff: it must + // deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds + // authority it cannot publish under. + val grantWrap = + ConcordModeration.grantWithStaffDelivery( + actor = account.signer, + controlPlane = cp, + communityId = communityId.hexToByteArray(), + member = member, + roleIds = listOf(roleIdHex), + current = session.controlEditions(), + createdAt = TimeUtils.now(), + owner = session.entry.owner, + controlRoot = session.entry.controlRoot?.hexToByteArray(), + epoch = session.entry.rootEpoch, + ) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** Revoke all roles from [member] (demote an admin back to a plain member). */ + suspend fun removeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** + * If [note] is a Concord channel message whose author this account is allowed to + * ban — the actor outranks the target and holds the BAN permission, and the target + * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI offers Ban only where we are willing to act. + * + * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the + * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's + * rank-checks the *contents* of a banlist edition — both gate only on the author's + * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its + * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin + * above them is therefore *accepted* by every client today. Since we cannot refuse + * such a ban without diverging from Armada, we at least refuse to author one — this + * restricts what we write, never what we accept, so it cannot split consensus. + * Enforcing it on the fold needs a spec change; see the QA plan's open findings. + */ + fun concordBanTarget(note: Note): Pair? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val session = account.concordSessions.sessionFor(communityId) ?: return null + val authority = session.state.value?.authority ?: return null + // Rank alone isn't enough on a split epoch: the banlist edition takes the control_root + // (CORD-02 §2), so don't offer an action the verb would refuse. + if (!session.controlPlaneKeys().canWrite) return null + if (authority.isOwner(author)) return null + // The owner short-circuits rather than going through canActOn: canActOn starts at + // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put + // the owner on the banlist (see the KDoc) — routing the owner through it would let them be + // locked out of moderating their own community. + val canBan = authority.isOwner(account.signer.pubKey) || authority.canActOn(account.signer.pubKey, author, ConcordPermissions.BAN) + return if (canBan) communityId to author else null + } + + /** Add [member] to the community banlist. */ + suspend fun banConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false + val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Remove [member] from the community banlist. */ + suspend fun unbanConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false + val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── + // A ban is a soft removal — the banned member still holds the room key and can + // still decrypt traffic; every client just declines to *show* their posts. A + // Refounding is the hard removal: it rotates the community_root, so a removed + // member's key stops working for anything published afterwards. + + /** + * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, + * roll the `community_root`, re-key every retained member (Guestbook membership ∪ + * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * Control Plane under the new root. A removed member keeps the prior root (so + * their history stays readable) but receives no blob, so they can never decrypt + * anything published after the rotation. + * + * Requires ownership or the BAN permission; returns false otherwise (or if the + * community isn't joined/writeable, or a target is the owner). + */ + suspend fun refoundConcordCommunity( + communityId: String, + removed: Set, + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val state = session.state.value ?: return false + val authority = state.authority + // hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol + // and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the + // shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same + // ban-aware predicate), but that is a race against banlist propagation, not a check. + val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN) + if (!iCanBan) return false + val removedLower = removed.mapTo(HashSet()) { it.lowercase() } + if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + // Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin + // cannot Refound a peer admin out of the community any more than they could ban one. The owner + // short-circuits, as everywhere else, because canActOn starts at hasPermission. + if (!authority.isOwner(account.signer.pubKey) && + removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) } + ) { + return false + } + // A Refounding writes the current plane (the pre-rotation bans) and the new one (the + // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A + // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. + val cp = controlKeysForWrite(session) ?: return false + + // 1. Ban the removed members on the current Control Plane so the compacted snapshot — + // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally + // first, so each subsequent edition chains onto the updated banlist head. + for (target in removedLower) { + val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, banWrap) + } + + // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. + // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the + // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other + // clients need not), so building the set without observed authors silently expelled every + // member who had only ever posted: they hold no role, receive no blob, and the Refounding + // strands them. That mainly hit cross-client communities, where Armada members are the + // bulk of the roster. + // + // Still a floor, not a census (see allMembers): a member who joined without a Guestbook + // motion, holds no role, and has never posted leaves no trace to find, so a Refounding + // cannot re-key them. Stranded recovery is what gets those members back. + val recipients = + (session.allMembers() + account.signer.pubKey) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removedLower) + removeAll(authority.bannedMembers()) + }.let { candidates -> boundRecipients(candidates, authority) } + + // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. + val entry = session.entry + val newRoot = RandomInstance.bytes(32) + // A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2), + // so a demoted staffer's retained secret dies with the epoch — and a legacy community + // upgrades to the split as a side effect of its next ban (CORD-06 §3). + val newControlRoot = RandomInstance.bytes(32) + // The staff set the new secret goes to: the owner plus everyone holding a + // Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other + // recipient the 104-byte one carrying the pubkey alone. (The builder mints a + // blob per recipient, so staff who aren't recipients are simply never reached.) + val staff = authority.staffMembers() + val build = + ConcordActions.buildRefounding( + rotatorSigner = account.signer, + communityId = communityId, + priorRoot = entry.root.hexToByteArray(), + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = session.controlPlaneWraps(), + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = staff, + createdAt = TimeUtils.now(), + ownerPubKey = entry.owner, + ) + + // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs + // (the key that unlocks it) to the community relays. + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isNotEmpty()) { + build.controlWraps.forEach { account.client.publish(it, publishTo) } + build.rekeyWraps.forEach { account.client.publish(it, publishTo) } + } + + // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + + // 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, + // and a member it left out — no rekey blob, no message to miss — has no way back at all. + // Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so + // reading it here would hand us the epoch we just left and re-mint every link onto it. + val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0 + Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } + return true + } + + /** + * Caps the Refounding recipient set, keeping the members whose standing we can actually vouch + * for when there are too many. + * + * `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are + * unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every + * author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway + * npub someone posts from, or simply announces, becomes one more mandatory blob in the next + * Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only + * hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`. + * + * The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be + * padded from outside. The remainder fills the budget, and anything dropped is **logged rather + * than silently truncated** — a dropped member is stranded on the dead epoch and their only way + * back is a recovery path that needs to know it happened. + */ + private fun boundRecipients( + candidates: Set, + authority: AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + + // The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it + // cannot be padded from outside, and dropping an admin to make room for a stranger inverts + // the point of the cap. + val vouched = authority.roleHolders() + authority.staffMembers() + val kept = LinkedHashSet() + candidates.filterTo(kept) { it in vouched } + for (candidate in candidates) { + if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break + kept.add(candidate) + } + val dropped = candidates.size - kept.size + if (dropped > 0) { + Log.w("Concord") { + "Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " + + "(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " + + "stranded on the prior epoch" + } + } + return kept.toList() + } + + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered + // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not + // adopted — and re-published — twice on successive revision ticks. + private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) + + /** + * Persist a rotated access root/epoch for [entry], keeping the prior root as a + * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the + * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — + * without this, cascading removals would lose everyone but the roster). No-op if + * this exact rotation was already adopted. + */ + private suspend fun adoptConcordRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, + ): ConcordCommunityListEntry? { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null + // The rewrite itself — banking the leaving epoch's address for the anti-rollback floor, + // dropping stale control material on a legacy rotation, preserving invite_ref and residue — + // is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and + // the Guestbook re-announce below are Android's. + val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) + announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + return next + } + + /** + * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for + * each joined community, look for our new root among the kind-3303 wraps seen at + * our next base-rekey address. If a role-authorized rotator (owner or a current, + * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the + * session rebuilds at the new epoch and its next-rekey address moves on, so a stale + * wrap never re-triggers. Called on every Concord revision tick. + * + * Authority is the roster, never key possession: any non-banned BAN-holder may + * rotate, including for the owner. The owner deliberately does NOT refuse a root + * authored by someone else — refusing would strand the owner alone on the dead + * epoch whenever an admin legitimately rotates, and would diverge from Armada, + * which forks a community across clients. Self-escalation to BAN is prevented + * upstream by the role rank gate in AuthorityResolver. + * + * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, + * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the + * owner included) by omission — nothing on this receive path can prevent it. The + * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves + * the invite link the membership was joined through and merges forward. + */ + internal suspend fun drainConcordRekeys() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = account.signer, + communityId = entry.id, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + ) ?: continue + if (received.newEpoch <= entry.rootEpoch) continue + val authority = session.state.value?.authority ?: continue + + // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder + // who has themselves been banned could still rotate the whole community. + val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) + if (!authorized) continue + val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + + // Move our own links onto the epoch we just adopted. Rotating is not the only way to end + // up on a new epoch — being re-keyed is the common one — and a link creator who is merely + // re-keyed would otherwise leave every link they handed out pointing at the dead root, + // which is exactly the orphaning this branch exists to stop. Stranded recovery reads the + // bundle's epoch, so a link nobody re-mints is a member nobody can recover. + adopted?.let { next -> + val moved = refreshConcordInviteLinks(next) + if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" } + } + } + } + + /** + * Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the + * promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the + * granter↔member pairwise key, so promotion and key delivery are one signed edition + * with nothing separate to watch an inbox for. + * + * Adoption is gated twice and fails closed both times. The secret is adopted only if + * it derives to exactly the `control_pk` we already hold for the named epoch — a + * garbage wrap is attributable griefing, nothing worse — and only from a Grant our own + * fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts. + * The epoch check matters because compaction re-wraps a Grant head verbatim across + * Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch. + * + * Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the + * revision tick, like the rekey drain. + */ + internal suspend fun drainConcordStaffGrants() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val entry = session.entry + val state = session.state.value ?: continue + // The whole decision — are we staff, does a Grant carry a wrap, does it open, name our + // epoch, and derive to the control_pk we hold — is shared with `amy` in + // [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's. + val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue + + account.sendMyPublicAndPrivateOutbox( + account.concordChannelList.follow(entry.withControlRoot(delivered)), + ) + } + } + + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the + // Concord revision tick (which fires on every structural change) without turning it into a + // relay-fetch loop. + private val lastConcordRecoveryCheck = ConcurrentHashMap() + + /** + * Stranded recovery (CORD-05/06 receive path). A Refounding carries only + * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left + * out of the rekey recipient set receives nothing and sits on the dead epoch + * forever while everyone else moves on. This happens to any member, the owner + * included, and [drainConcordRekeys] cannot prevent it: there is no message to + * miss detecting. + * + * The way back is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and + * carried through every rotation by [adoptConcordRoot]). The community keeps + * re-minting its bundle at that same addressable coordinate, so a bundle there at + * a **strictly higher** epoch than ours proves we were left behind — and carries + * the new root. Same or lower epoch is a no-op. Memberships with no link (direct + * invites, legacy entries) are inert here; that is expected, not an error. + * + * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps + * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the + * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays + * derivable). We then re-announce the Guestbook at the new epoch, exactly as an + * ordinary rotation does, so the recovered member is visible to whoever refounds + * next instead of being silently dropped again. + * + * Called on the Concord revision tick, but rate-limited per community + * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. + */ + internal suspend fun recoverStrandedConcordCommunities() { + if (!account.isWriteable()) return + val now = TimeUtils.nowMillis() + for (entry in account.concordChannelList.liveCommunities.value) { + val inviteRef = entry.inviteRef ?: continue + val last = lastConcordRecoveryCheck[entry.id] + if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue + lastConcordRecoveryCheck[entry.id] = now + + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue + val relays = + ( + parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ).toSet() + if (relays.isEmpty()) continue + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + + // A removed member holds the link's unlock token forever, so without this the sweep + // walks them straight back into the epoch they were rotated out of — see A2 in + // docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last + // one whose Control Plane we can still fold. + // + // Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not + // exist yet or whose first fold has not landed, and this sweep runs on the revision tick + // — so a banned member's own client would have hit that window on cold start and + // recovered itself, which is precisely the bypass this gate exists to stop. No verdict + // means no recovery; the next sweep retries once the roster is known. + val authority = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.authority + if (authority == null) { + Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" } + lastConcordRecoveryCheck.remove(entry.id) + continue + } + val bannedHere = authority.isBanned(account.signer.pubKey) + val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue + if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue + Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) + announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + } + } + + /** + * Replace the community metadata (name / icon / description / relays) with a new + * Control-Plane edition. Honored on fold only when this account holds + * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. + */ + suspend fun editConcordMetadata( + communityId: String, + name: String, + description: String?, + icon: ImagePointer?, + banner: ImagePointer?, + relays: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false + val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) + val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold + * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on + * the same predicate. The channel id is a fresh random 32-byte entity id. + */ + suspend fun createConcordChannel( + communityId: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + val channelId = RandomInstance.bytes(32) + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ + suspend fun renameConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + // Carry the standing definition forward and change only the name. A ChannelEntity built from + // scratch defaults `private` and `voice` to false, so renaming a private channel used to + // publish an edition declaring it PUBLIC — and a voice channel became a text channel. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ + suspend fun deleteConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + // Same as rename: preserve the standing flags so a tombstone does not also silently + // reclassify the channel it retires. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from + * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT + * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a + * card can show what the link opens, or null if the link is invalid/unreadable. + */ + suspend fun peekConcordInvite(url: String): CommunityInvite? { + val parsed = ConcordActions.parseInviteLink(url) ?: return null + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + } + + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list and fold the newest into [LocalCache], so communities + * we joined on another Concord client with this key surface here. + * + * We query a wide relay set because different Concord clients publish this + * private list to different places: the reference clients (Armada/Vector) push + * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may + * also have copied it onto their **own** outbox/read relays. Our normal account + * subscription never asks for kind 13302, so without this explicit fetch a + * community joined on Armada would never appear — even if the list sits on the + * user's own outbox. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + * + * [extraRelays] are additional relays to query — the bootstrap relays saved on the + * bottom-bar tabs of pinned communities. A community's private list frequently lives + * only on the community's own relays (never the user's outbox), so a community pinned + * to the bottom bar would otherwise never surface when opened cold. + */ + suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val relays = (stock + account.mineRelays.flow.value + account.outboxRelays.flow.value + extraRelays).toSet() + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give + // the fetch a generous window to drain every relay before we pick the newest copy. + val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L) + val newest = events.filterIsInstance().maxByOrNull { it.createdAt } + val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0 + Log.d( + "Concord", + "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + + "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", + ) + newest?.let { account.cache.justConsumeMyOwnEvent(it) } + } + + /** + * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox + * fill in without the user opening each channel one by one. Per channel, a channel read before is + * caught up from its last-read time (accurate unread badge + the missed messages ready when it + * opens) while a channel never read pulls only its single newest wrap for a preview — see + * [ConcordSubscriptionPlanner.channelPreviewFilters]. + * + * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the + * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), + * so it lands in the channel's message store the previews/unread counts read — and the always-on + * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. + * So this only needs to run when a community's channels first fold (the account preload) or its + * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. + */ + suspend fun warmConcordChannelPreviews(entries: List) { + val filters = + entries.flatMap { entry -> + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value ?: return@flatMap emptyList() + ConcordSubscriptionPlanner.channelPreviewFilters( + entry, + state, + lastReadFor = { channelIdHex -> + account.loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) + }, + accountPubKey = account.userProfile().pubkeyHex, + ) + } + if (filters.isEmpty()) return + val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } + account.client.fetchAll(filters = byRelay, idleTimeoutMs = 20_000L) + } + + /** + * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must + * never fold on a truncated edition set. + * + * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold + * silently drops channels or mis-renders membership. Two ways that happens, both closed here: + * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition + * with a `created_at` below the high-water mark that we never actually ingested — an unban + * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is + * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the + * whole plane every run. + * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can + * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` + * cursors until a plane is drained), so the fold sees every edition regardless of the cap. + * + * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the + * priors). Wraps ingest through the global cache connector → [concordSessions] like every other + * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into + * one filter is safe here precisely because we page — the cap no longer truncates. The live control + * subscription still carries brand-new editions in real time; this is the periodic completeness pass. + */ + suspend fun syncConcordControlPlanes(entries: List) { + if (entries.isEmpty()) return + val authorsByRelay = HashMap>() + for (entry in entries) { + for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + } + if (authorsByRelay.isEmpty()) return + // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a + // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. + val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } + var drained = 0 + account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } + Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt new file mode 100644 index 0000000000..e45fd03764 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -0,0 +1,580 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Log +import kotlin.coroutines.cancellation.CancellationException + +/** + * Marmot (MLS encrypted groups) orchestration for an [Account]: group create/ + * leave/reset, member add/remove via key-package fetch, admin grant/revoke, + * metadata updates, group messaging, and key-package publishing. MLS state + * lives in [MarmotManager]; this class wires it to the account's signer, relay + * client, and relay lists. Functions live here (not a ViewModel) so headless + * callers - notification receivers, background workers - can drive them. + */ +class AccountMarmotActions( + private val account: Account, +) { + /** + * Resolve the relay set for a Marmot group. Prefer the relays carried in + * the MLS GroupContext metadata so every member converges on the same + * canonical set; fall back to the account's outbox relays if the group + * has none (e.g. a group joined before MIP-01 metadata existed). + * + * Lives on Account (not AccountViewModel) so that headless callers — + * notifications' BroadcastReceiver, background workers — can resolve + * relays without spinning up a ViewModel. + */ + fun marmotGroupRelays(nostrGroupId: HexKey): Set { + 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, + ) { + 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, + ) { + 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 = + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt new file mode 100644 index 0000000000..a5bb8e9a7b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -0,0 +1,554 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership +import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent +import com.vitorpamplona.quartz.buzz.dm.DmHideEvent +import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent +import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent +import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.workflowChannel +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * NIP-29 relay-group and Buzz-workspace orchestration for an [Account]: + * join/leave/create/delete/archive groups, threads, invites, pins, member and + * role management, metadata edits, plus the Buzz dialect's DMs, jobs, + * workflows, and typing signals. Event building lives in quartz builders; + * this class wires them to the account's signer and the group's host relay. + */ +class AccountRelayGroupActions( + private val account: Account, +) { + // All group commands are published ONLY to the group's host relay, where + // relay29 authorizes them. The relay is the source of truth; the kind-10009 + // list is our own cross-device bookkeeping of what we joined. + + /** Send a kind 9021 join request to the group's host relay and remember it. */ + suspend fun joinRelayGroup( + channel: RelayGroupChannel, + code: String? = null, + ) { + val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.follow(channel) + } + + /** + * Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral + * (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter + * our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS]. + */ + suspend fun sendBuzzTyping(channel: RelayGroupChannel) { + if (!account.isWriteable()) return + val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) + account.client.publish(signed, setOf(channel.groupId.relayUrl)) + } + + /** + * Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010 + * command. [participants] are the OTHER 1-8 people — the relay adds me, derives the + * canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent] + * (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]. + * We never assign the channel id ourselves, so callers discover the materialized DM + * by watching that registry rather than from this call's return. + */ + suspend fun openBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ): 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): 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.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 = emptyList(), + geohashes: List = 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 = + 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, + ) { + 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, + ) { + // 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 = emptyList(), + geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = 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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f2605013c1..9802633ddf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -501,6 +503,18 @@ class AccountSettings( return false } + fun changeHiddenDrawerItems(newItems: Set): 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index a33799dd40..5fa6adc7ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames +import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -83,6 +87,7 @@ class AccountSyncedSettings( val navigation = AccountNavigationPreferences( MutableStateFlow(internalSettings.navigation.bottomBarItems), + MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))), ) fun toInternal(): AccountSyncedSettingsInternal = @@ -124,7 +129,11 @@ class AccountSyncedSettings( .map { it.id } .sorted(), ), - navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value), + navigation = + AccountNavigationPreferencesInternal( + navigation.bottomBarItems.value, + navigation.hiddenDrawerItems.value.toNames(), + ), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -221,6 +230,11 @@ class AccountSyncedSettings( if (navigation.bottomBarItems.value != newBottomBarItems) { navigation.bottomBarItems.tryEmit(newBottomBarItems) } + + val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems)) + if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) { + navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -322,6 +336,8 @@ class AccountMediaPreferences( @Stable class AccountNavigationPreferences( val bottomBarItems: MutableStateFlow>, + /** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */ + val hiddenDrawerItems: MutableStateFlow>, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 4f3f51ae95..32b3900cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal( // favorite apps, and individual joined chats/groups). Defaulted so blobs // written before this field existed decode to the app's current defaults. var bottomBarItems: List = 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 = emptyList(), ) @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt new file mode 100644 index 0000000000..1c9f6ae8bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.coroutines.cancellation.CancellationException + +private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" + +/** + * Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47 + * NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC + * onchain zaps/sends. Event building lives in the commons ZapActions/ + * Bolt12ZapActions; this class wires wallet selection, signing, and relay + * routing to the account. + */ +class AccountZapActions( + private val account: Account, +) { + suspend fun createZapRequestFor( + event: Event, + pollOption: Int?, + message: String = "", + zapType: LnZapEvent.ZapType, + toUser: User?, + additionalRelays: Set? = 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? = 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, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = 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) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt new file mode 100644 index 0000000000..1f3f356995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Memory-reclaim policy over the [LocalCache] stores: trims the soft caches, + * prunes hidden/old/expired/superseded events, and owns the shared + * [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on. + * + * Pure policy — it holds no state of its own beyond the cache reference, so every + * function can be exercised against a populated cache in tests. Driven by + * `MemoryTrimmingService`. + */ +class CachePruner( + private val cache: LocalCache, +) { + fun cleanMemory() { + Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" } + cache.notes.cleanUp() + Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" } + + Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" } + cache.addressables.cleanUp() + Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" } + + Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" } + cache.users.cleanUp() + Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" } + } + + fun cleanObservers() { + cache.notes.forEach { _, it -> it.clearFlow() } + cache.addressables.forEach { _, it -> it.clearFlow() } + } + + private fun pruneHiddenMessagesChannel( + channel: Channel, + account: Account, + ) { + val toBeRemoved = channel.pruneHiddenMessages(account) + + val childrenToBeRemoved = mutableListOf() + + 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() + + 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() + + // 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() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // 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 { + val host = note.rumorHost ?: return emptyList() + + val children = mutableListOf() + 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() + + 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) { + 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() + + 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) { + 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() + + 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() + + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt new file mode 100644 index 0000000000..3af6813384 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.tagValueContains +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CancellationException + +/** + * Prefix/content search over the [LocalCache] stores: users, notes, and the + * public-chat / ephemeral / live-activity channel maps. Pure read-side policy — + * no state beyond the cache reference — so ranking and filtering rules can be + * tested against a populated cache. + */ +class CacheSearch( + private val cache: LocalCache, +) { + fun findUsersStartingWith( + username: String, + forAccount: Account?, + ): List { + 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 { + 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 { + 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 { + if (text.isBlank()) return emptyList() + + return cache.ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findLiveActivityChannelsStartingWith(text: String): List { + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6b4d2599cb..8f502e358b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -54,6 +54,15 @@ sealed interface ConcordInviteResult { */ data object Expired : ConcordInviteResult + /** + * The link opens, but this community's roster has banned us (CORD-04). + * + * A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the + * URL and its unlock token forever — so honouring the link alone would hand the new keys to the + * very account the rotation expelled. + */ + data object Banned : ConcordInviteResult + /** * The bundle event was found but could not be opened with the link's token — * typically because it was minted by a newer/incompatible Concord client whose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt new file mode 100644 index 0000000000..00062654eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The minimal get-or-create surface of the event cache, used by callers (like + * `NewMessageTagger`) that resolve user/note references while composing without + * needing the full [LocalCache] API. + */ +interface Dao { + fun getOrCreateUser(pubkey: HexKey): User + + fun getOrCreateNote(hex: HexKey): Note + + fun getOrCreateAddressableNote(address: Address): AddressableNote? +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt new file mode 100644 index 0000000000..33ed7a5369 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +/** + * The sign-and-publish choke point for an [Account]: computes the relay set an + * event should be broadcast to (NIP-65 outbox model, relay hints, channel home + * relays, broadcast lists, DM inboxes) and owns every publish path - automatic, + * outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast. + * + * Feature orchestration on [Account] (and the Account*Actions classes) should + * funnel every publish through this class instead of calling the relay client + * directly. + */ +class EventBroadcaster( + private val account: Account, +) { + private fun computeRelayListForLinkedUser(user: User): Set = + 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 = + 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 = 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 = computeRelayListToBroadcast(event, mutableSetOf()) + + private fun computeRelayListToBroadcast( + event: Event, + visited: MutableSet, + ): Set { + // 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() + 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 { + 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) = 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) { + 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 signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + 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 signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T { + val event = signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + if (relayList.isNotEmpty()) account.client.publish(event, relayList) + return event + } + + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): 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 signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = 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 signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = 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, + relays: Set, + ) { + if (relays.isEmpty() || events.isEmpty()) return + events.forEach { account.client.publish(it, relays) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bdad25dece..14bb1531d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -52,11 +53,9 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.LocalCache.observeEvents -import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent @@ -184,7 +183,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isRegular import com.vitorpamplona.quartz.nip01Core.core.isReplaceable -import com.vitorpamplona.quartz.nip01Core.core.tagValueContains import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider @@ -202,8 +200,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -211,7 +207,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -219,9 +214,6 @@ import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -261,7 +253,6 @@ import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -278,7 +269,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent -import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent @@ -374,7 +364,6 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -410,7 +399,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent -import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -492,7 +480,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { @Volatile var lnurlEndpointResolver: LnurlEndpointResolver? = null - val relayHints = HintIndexer() + override val relayHints = HintIndexer() /** * Cashu mint URL directory, populated passively as @@ -545,6 +533,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { */ val observables = FilterIndex() + /** + * Memory-reclaim policy (soft-cache trims + hidden/old/expired/superseded event + * pruning) and the shared [CachePruner.unlinkAndRemove] removal primitive. + */ + val pruner = CachePruner(this) + + /** Prefix/content search over users, notes, and channels. */ + val search = CacheSearch(this) + fun Filter.match(note: Note): Boolean { val event = note.event return if (event != null) { @@ -683,18 +680,18 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() } - fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() + override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() fun load(keys: List): List = keys.mapNotNull(::checkGetOrCreateUser) fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - override fun getOrCreateUser(hex: HexKey): User { - require(isValidHex(key = hex)) { "$hex is not a valid hex" } + override fun getOrCreateUser(pubkey: HexKey): User { + require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" } // Pass `this` as the UserContext — User now resolves each pinned // addressable note (kind:10002 / 10050 / 10019) lazily on first // read, instead of all-or-nothing at construction time. - return users.getOrCreate(hex) { User(it, userContext) } + return users.getOrCreate(pubkey) { User(it, userContext) } } /** [UserContext] bridge to this cache's addressable lookup. */ @@ -1031,7 +1028,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // quoted note. Count it as a boost so it shows in the quoted note's repost // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately // kept out of `replyTo` so the quote still renders as a root post in the home - // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + // feed (see Note.isNewThread); deletion cleanup lives in CachePruner.unlinkAndRemove. addQuoteBoosts(event, note, replyTo) refreshNewNoteObservers(note) @@ -1635,14 +1632,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { * * Removal has two halves: unlinking the note from everything that points AT it * (its parents, channels, and the per-user report/card/status/poll indexes — - * all handled by [unlinkAndRemove]); and dealing with the note's OWN children + * all handled by [CachePruner.unlinkAndRemove]); and dealing with the note's OWN children * (the notes that point at IT). The delete path and the prune path share the * first half and differ only on the second: * - delete (here): the children are independent events and stay in the cache; * [Note.detachFromChildren] only severs their back-reference so the removed * shell can neither leak (held alive by a child's `replyTo`) nor be later * resurrected by `computeReplyTo` as a second Note for the same id. - * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. + * - prune (see [CachePruner.unlinkAndRemove] callers): the whole child subtree is removed. * * Rumors additionally drop the envelope notes that delivered them. */ @@ -1651,7 +1648,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { deleteNote.detachFromChildren() - unlinkAndRemove(deleteNote) + pruner.unlinkAndRemove(deleteNote) } /** @@ -1868,7 +1865,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { val new = consumeRegularEvent(event, relay, wasVerified) if (new) { - val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) } + val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) } val eventsReported = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } + event.reportedAddresses().map { getOrCreateAddressableNote(it.address) } @@ -1889,7 +1886,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // report can `p`-tag an incidentally-mentioned third party with no type of its own, // and there is no threshold here to absorb that noise the way // `receivedReportsByAuthor`'s hide path does. - val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey } + val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey } authorsReported.forEach { author -> if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note) } @@ -2950,6 +2947,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { val new = consumeRegularEvent(event, relay, wasVerified) if (new) { pollNote.pollState().addResponse(responseNote) + // Responses and their poll race each other. If the poll is already here, hand the + // tally its rules now; if it isn't, consume(PollEvent) does it on arrival. + (pollNote.event as? PollEvent)?.let { + pollNote.pollState().updatePolicy(PollTallyPolicy.from(it)) + } } return new } @@ -2957,6 +2959,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return false } + fun consume( + event: PollEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeRegularEvent(event, relay, wasVerified) + attachToRelayGroupIfScoped(event, relay) + + // Not gated on `new`: the tally may have been built from responses that arrived before this + // poll did, and updatePolicy is idempotent for the usual re-delivery from another relay. + getOrCreateNote(event.id).pollState().updatePolicy(PollTallyPolicy.from(event)) + + return new + } + fun consume( event: FileStorageEvent, relay: NormalizedRelayUrl?, @@ -3116,637 +3133,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return false } - fun findUsersStartingWith( - username: String, - forAccount: Account?, - ): List { - if (username.isBlank()) return emptyList() - - checkNotInMainThread() - - val key = decodePublicKeyAsHexOrNull(username) - - if (key != null) { - val user = getUserIfExists(key) - if (user != null) { - return listOfNotNull(user) - } - } - - val dualCase = - listOf( - DualCase(username.lowercase(), username.uppercase()), - ) - - val finds = - 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 { - checkNotInMainThread() - - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - - if (key != null) { - val note = getNoteIfExists(key) - val noteEvent = note?.event - val newNote = - if (noteEvent is AddressableEvent) { - val addressableNote = getAddressableNoteIfExists(noteEvent.address()) - if (addressableNote?.event?.id == note.idHex) { - addressableNote - } else { - note - } - } else { - note - } - - if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) { - return listOfNotNull(newNote) - } - } - - return 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 - } + - 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 { - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - if (key != null) { - getPublicChatChannelIfExists(key)?.let { - return listOf(it) - } - } - - return publicChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findEphemeralChatChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - return ephemeralChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findLiveActivityChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - try { - val parsed = Nip19Parser.uriToRoute(text)?.entity - if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { - return listOf(getOrCreateLiveChannel(parsed.address())) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - - return liveChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) - fun cleanMemory() { - Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } - notes.cleanUp() - Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${notes.size()}" } - - Log.d("LargeCache") { "Addressables cleanup started. Current size: ${addressables.size()}" } - addressables.cleanUp() - Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${addressables.size()}" } - - Log.d("LargeCache") { "Users cleanup started. Current size: ${users.size()}" } - users.cleanUp() - Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${users.size()}" } - } - - fun cleanObservers() { - notes.forEach { _, it -> it.clearFlow() } - addressables.forEach { _, it -> it.clearFlow() } - } - - fun pruneHiddenMessagesChannel( - channel: Channel, - account: Account, - ) { - val toBeRemoved = channel.pruneHiddenMessages(account) - - val childrenToBeRemoved = mutableListOf() - - 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) { - ephemeralChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - geohashChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - liveChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - publicChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - 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 PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L - - fun pruneOldMessagesChannel(channel: Channel) { - val toBeRemoved = channel.pruneOldMessages() - - val childrenToBeRemoved = mutableListOf() - - 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() - PRESENCE_PRUNE_AGE_SECONDS) - } - - 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() - - ephemeralChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - geohashChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - liveChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - publicChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - relayGroupChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - 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() - - // 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() - val accountNip04Pruned = HashMap() - val roomNip04Pruned = HashMap() - // 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", - ) - } - } - } - } - - fun removeIfWrap(note: Note): List { - val host = note.rumorHost ?: return emptyList() - - val children = mutableListOf() - getNoteIfExists(host.id)?.let { hostNote -> - (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> - 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 = - notes.filter { _, note -> - val noteEvent = note.event - if (noteEvent is AddressableEvent) { - noteEvent.createdAt < - (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) - } else { - false - } - } - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> 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) { - checkNotInMainThread() - - val toBeRemoved = - 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() - - 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 [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 [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]. - */ - private fun unlinkAndRemove(note: Note) { - note.replyTo?.forEach { masterNote -> - masterNote.removeNote(note) - } - - note.inGatherers?.forEach { it.removeNote(note) } - - 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 -> - 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 { 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 { getNoteIfExists(it)?.removeTimestamp(note) } - } - - if (noteEvent is ReportEvent) { - noteEvent.reportedAuthor().forEach { - getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> - reports.removeReport(note) - reports.removeReportNamingUser(note) - } - } - - noteEvent.reportedPost().forEach { - getNoteIfExists(it.eventId)?.removeReport(note) - } - - noteEvent.reportedAddresses().forEach { - getAddressableNoteIfExists(it.address)?.removeReport(note) - } - } - - if (note is AddressableNote && noteEvent is ContactCardEvent) { - 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 { - getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) - } - } - - note.clearFlow() - - notes.remove(note.idHex) - - 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) { - nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } - } - - fun pruneExpiredEvents() { - checkNotInMainThread() - - val now = TimeUtils.now() - val versionsToBeRemoved = notes.filter { _, it -> it.event?.isExpirationBefore(now) == true } - val addressesToBeRemoved = addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true } - - val childrenToBeRemoved = mutableListOf() - - 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() - - val toBeRemoved = - account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> - (notes.filter { _, it -> it.event?.pubKey == userHex } + 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") - } - override fun markAsSeen( eventId: String, relay: NormalizedRelayUrl, @@ -3799,7 +3187,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { live.newNote(newNote) } - private fun refreshDeletedNoteObservers(newNote: Note) { + internal fun refreshDeletedNoteObservers(newNote: Note) { // Deletes don't have a filterable shape — every observer // might hold this note in its result set, so iterate them // all. The index doesn't help here. @@ -4072,413 +3460,35 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { ): Boolean = try { when (event) { - is AcceptedBadgeSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AdvertisedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppSpecificDataEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationRequestEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorProficiencyEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AudioHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is AudioTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BadgeAwardEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is BadgeDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlockedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlossomServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NestsServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BroadcastRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is OldBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarDateSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarTimeSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarRSVPEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CallAnswerEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallHangupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallIceCandidateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallOfferEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRejectEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRenegotiateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - // ============================================================ - // NIP-60 Cashu wallet + NIP-61 nutzaps + // Kinds with dedicated consume() logic: typed overloads that + // update channels, zaps, drafts, the deletion index, etc. + // Everything without per-kind logic falls through to the + // generic replaceable / regular groups at the bottom. // ============================================================ - is CashuWalletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - is CashuTokenEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuSpendingHistoryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuMintQuoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NutzapInfoEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NutzapEvent -> { - consume(event, relay, wasVerified) - } - - // ============================================================ - // NIP-87 Cashu mint discovery + recommendations - // ============================================================ - // All three are kind 3xxxx (parameterized-replaceable per the - // spec) but neither CashuMintEvent / FedimintEvent / - // MintRecommendationEvent extends AddressableEvent in Quartz - // today, so consumeBaseReplaceable's `check(event is - // AddressableEvent)` would crash. Route through - // consumeRegularEvent — downstream consumers - // (CashuMintDirectoryState, CashuWalletState) already dedupe - // by (pubKey, dTag) and keep the newest. Without these - // entries the dispatch falls into the "Event Not Supported" - // else branch and silently drops the event, so our own - // kind:38000 thumbs-up never lands in the cache. - is CashuMintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FedimintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is MintRecommendationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChannelCreateEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChannelHideMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMuteUserEvent -> { - consume(event, relay, wasVerified) - } - - is ChatMessageEncryptedFileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Bolt12OfferListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ClassifiedsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FundraiserEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdexEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdDetectionEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is Ps1SaveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CommunityDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityPostApprovalEvent -> { - consume(event, relay, wasVerified) - } - - is ContactListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is DeletionEvent -> { - consume(event, relay, wasVerified) - } - - is DraftWrapEvent -> { - consume(event, relay, wasVerified) - } - - is EmojiPackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EmojiPackSelectionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EphemeralChatEvent -> { - consume(event, relay, wasVerified) - } - - is GeohashChatEvent -> { - consume(event, relay, wasVerified) - } - - is EphemeralChatListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + - // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the - // addressable cache, so it must be stored (it was silently dropped before). - is SimpleGroupListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; - // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), - // so — exactly like the 10009 list above — it must be stored replaceably or the Concord - // hub stays empty even after the event arrives. - is ConcordCommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GroupMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is GroupMembersEvent -> { - consume(event, relay, wasVerified) - } - - is GroupAdminsEvent -> { - consume(event, relay, wasVerified) - } - - is GroupPinnedEvent -> { - consume(event, relay, wasVerified) - } + is NutzapEvent -> consume(event, relay, wasVerified) + is ChannelCreateEvent -> consume(event, relay, wasVerified) + is ChannelHideMessageEvent -> consume(event, relay, wasVerified) + is ChannelMessageEvent -> consume(event, relay, wasVerified) + is ChannelMetadataEvent -> consume(event, relay, wasVerified) + is ChannelMuteUserEvent -> consume(event, relay, wasVerified) + is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) + is DeletionEvent -> consume(event, relay, wasVerified) + is DraftWrapEvent -> consume(event, relay, wasVerified) + is EphemeralChatEvent -> consume(event, relay, wasVerified) + is GeohashChatEvent -> consume(event, relay, wasVerified) + is GroupMetadataEvent -> consume(event, relay, wasVerified) + is GroupMembersEvent -> consume(event, relay, wasVerified) + is GroupAdminsEvent -> consume(event, relay, wasVerified) + is GroupPinnedEvent -> consume(event, relay, wasVerified) // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: // route it onto the channel so a moderation UI can offer the relay's role set. - is SupportedRolesEvent -> { - consume(event, relay, wasVerified) - } + is SupportedRolesEvent -> consume(event, relay, wasVerified) - // Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants - // addressable is durable group state, so it's stored replaceably. The 9xxx - // moderation actions and join/leave requests are regular one-shot events the - // relay is authoritative for (it applies them and republishes the - // 39000/39001/39002); we store them so they're queryable and don't fall through - // to the "Not Supported" warning, but we don't act on them client-side. - is GroupParticipantsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PutUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RemoveUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is EditMetadataEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteEventEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is UpdatePinListEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateInviteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is JoinRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LeaveRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExternalIdentitiesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GenericRepostEvent -> { - consume(event, relay, wasVerified) - } - - is FhirResourceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ProfileGalleryEntryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FileStorageEvent -> { - consume(event, relay, wasVerified) - } - - is FileStorageHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FollowListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GeohashListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GoalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is GenericRepostEvent -> consume(event, relay, wasVerified) + is FileStorageEvent -> consume(event, relay, wasVerified) is GiftWrapEvent -> { // A wrap with an empty content carries no NIP-44 ciphertext and can @@ -4492,177 +3502,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is GroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitIssueEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPatchEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestUpdateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitStatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitRepositoryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GitRepositoryStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is UserGraspListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChessGameEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is JesterEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is KeyPackageEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is KeyPackageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameChallengeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameAcceptEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessMoveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameEndEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessDrawOfferEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HashtagListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoriteAlgoFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HighlightEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is IndexerRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryPrologueEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStorySceneEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryReadingStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InterestSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LabeledBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveActivitiesEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesChatMessageEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesRaidEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesClipEvent -> { - consume(event, relay, wasVerified) - } - - is MeetingSpaceEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomPresenceEvent -> { - consume(event, relay, wasVerified) - } - - is MusicTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MusicPlaylistEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PodcastEpisodeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LiveActivitiesEvent -> consume(event, relay, wasVerified) + is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) + is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) + is MeetingRoomPresenceEvent -> consume(event, relay, wasVerified) is PodcastMetadataEvent -> { // Drop the known "Mock Podcast" spam flood instead of caching thousands of them. @@ -4673,133 +3517,27 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is AuthoredPodcastsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoritePodcastsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20EpisodeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20TrailerEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LnZapEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapRequestEvent -> { - consume(event, relay, wasVerified) - } - - is OnchainZapEvent -> { - consume(event, relay, wasVerified) - } - - is Bolt12ZapEvent -> { - consume(event, relay, wasVerified) - } - - is NIP90StatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LnZapPaymentRequestEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapPaymentResponseEvent -> { - consume(event, relay, wasVerified) - } - - is LongTextNoteEvent -> { - consume(event, relay, wasVerified) - } - - is MetadataEvent -> { - consume(event, relay, wasVerified) - } - - is MuteListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NNSEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NipTextEvent -> { - consume(event, relay, wasVerified) - } - - is OtsEvent -> { - consume(event, relay, wasVerified) - } - - is PictureEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateDmEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateOutboxRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProfileBadgesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProxyRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PinListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PublicMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PeopleListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RequestToVanishEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CodeSnippetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ZapPollEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LnZapEvent -> consume(event, relay, wasVerified) + is LnZapRequestEvent -> consume(event, relay, wasVerified) + is OnchainZapEvent -> consume(event, relay, wasVerified) + is Bolt12ZapEvent -> consume(event, relay, wasVerified) + is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) + is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) + is LongTextNoteEvent -> consume(event, relay, wasVerified) + is MetadataEvent -> consume(event, relay, wasVerified) + is NipTextEvent -> consume(event, relay, wasVerified) + is OtsEvent -> consume(event, relay, wasVerified) + is PollResponseEvent -> consume(event, relay, wasVerified) + is ReactionEvent -> consume(event, relay, wasVerified) + is LabelEvent -> consume(event, relay, wasVerified) + is ContactCardEvent -> consume(event, relay, wasVerified) + is ReportEvent -> consume(event, relay, wasVerified) + is RepostEvent -> consume(event, relay, wasVerified) + is StatusEvent -> consume(event, relay, wasVerified) + is TextNoteModificationEvent -> consume(event, relay, wasVerified) + is ConcordChatEditEvent -> consume(event, relay, wasVerified) + is WikiNoteEvent -> consume(event, relay, wasVerified) + is PaymentTargetsEvent -> consume(event, relay, wasVerified) is ChatEvent -> { consumeRegularEvent(event, relay, wasVerified).also { @@ -4811,6 +3549,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } + is PollEvent -> consume(event, relay, wasVerified) + + is ThreadEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachThreadToRelayGroupIfScoped(event, relay) + } + } + // ------------------------------------------------------------------ // Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29). // Timeline kinds attach into the group's BuzzWorkspaceChannel; the @@ -4821,87 +3567,78 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // BitChat `g` tag is absent, and it is handled below with the ephemerals. // ------------------------------------------------------------------ - is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) - is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) - // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) - // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. - is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) - is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - - // Buzz addressable/replaceable state. - is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is DmVisibilityEvent -> consume(event, relay, wasVerified) - is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - - // Buzz store-only regular kinds (queryable state; no timeline row yet). - is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified) is RelayMembershipListEvent -> consume(event, relay, wasVerified) is RelayAddMemberEvent -> consume(event, relay, wasVerified) is RelayRemoveMemberEvent -> consume(event, relay, wasVerified) - is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + is DmVisibilityEvent -> consume(event, relay, wasVerified) - // Buzz relay-signed sidecars and audit projections: store-only, queryable. - is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) + // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. + is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) + + is StreamMessageV2Event, + is StreamMessageDiffEvent, + is JobRequestEvent, + is JobAcceptedEvent, + is JobProgressEvent, + is JobResultEvent, + is JobCancelEvent, + is JobErrorEvent, + is HuddleStartedEvent, + is HuddleParticipantJoinedEvent, + is HuddleParticipantLeftEvent, + is HuddleEndedEvent, + -> consumeBuzzTimelineEvent(event, relay, wasVerified) + + // Buzz store-only regular kinds (queryable state; no timeline row yet), + // plus relay-signed sidecars and audit projections. + is ForumCommentEvent, + is ForumVoteEvent, + is StreamMessagePinnedEvent, + is StreamMessageBookmarkedEvent, + is StreamMessageScheduledEvent, + is StreamReminderEvent, + is DmCreatedEvent, + is DmOpenEvent, + is DmAddMemberEvent, + is DmHideEvent, + is MemberAddedNotificationEvent, + is AgentTurnMetricEvent, + is ModerationBanEvent, + is ModerationTimeoutEvent, + is ModerationUntimeoutEvent, + is ModerationResolveReportEvent, + is ProductFeedbackEvent, + is RelayAdminAddMemberEvent, + is RelayAdminRemoveMemberEvent, + is RelayAdminChangeRoleEvent, + is SetWorkspaceProfileEvent, + is ArchiveRequestEvent, + is UnarchiveRequestEvent, + is ArchivedIdentityEvent, + is UnarchivedIdentityEvent, + is HuddleGuidelinesEvent, + is WorkflowTriggeredEvent, + is WorkflowStepStartedEvent, + is WorkflowStepCompletedEvent, + is WorkflowStepFailedEvent, + is WorkflowCompletedEvent, + is WorkflowFailedEvent, + is WorkflowCancelledEvent, + is WorkflowApprovalRequestedEvent, + is WorkflowApprovalGrantedEvent, + is WorkflowApprovalDeniedEvent, + is WorkflowTriggerEvent, + is ApprovalGrantEvent, + is ApprovalDenyEvent, + is AuditEntryEvent, + is ChannelSummaryEvent, + is PresenceSnapshotEvent, + -> consumeBuzzRegularEvent(event, relay, wasVerified) // Buzz ephemeral signals: transient by definition (20000-29999) — do not // pollute the note store, and do NOT mark the dialect from them (they are @@ -4926,165 +3663,211 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // pairing before any workspace relationship is established. is PairingEvent -> false - is PollEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachToRelayGroupIfScoped(event, relay) - } - } + // ============================================================ + // Replaceable / addressable kinds with no per-kind logic: + // the newest version per address is kept, older ones dropped. + // New kinds without custom consume logic go in one of the two + // groups below — an unlisted kind falls into the else branch + // and is rejected as unsupported. + // ============================================================ + is AcceptedBadgeSetEvent, + is AdvertisedRelayListEvent, + is AppDefinitionEvent, + is AppRecommendationEvent, + is AppSpecificDataEvent, + is AttestationEvent, + is AttestationRequestEvent, + is AttestorRecommendationEvent, + is AttestorProficiencyEvent, + is AudioTrackEvent, + is BadgeDefinitionEvent, + is BlockedRelayListEvent, + is BlossomServersEvent, + is NestsServersEvent, + is BroadcastRelayListEvent, + is BookmarkListEvent, + is OldBookmarkListEvent, + is CalendarEvent, + is CalendarDateSlotEvent, + is CalendarTimeSlotEvent, + is CalendarRSVPEvent, + is CashuWalletEvent, + is NutzapInfoEvent, + is ChannelListEvent, + is ChatMessageRelayListEvent, + is Bolt12OfferListEvent, + is ClassifiedsEvent, + is FundraiserEvent, + is BirdexEvent, + is Ps1SaveEvent, + is CommunityDefinitionEvent, + is CommunityListEvent, + is ContactListEvent, + is EmojiPackEvent, + is EmojiPackSelectionEvent, + is EphemeralChatListEvent, + // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + + // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the + // addressable cache, so it must be stored (it was silently dropped before). + is SimpleGroupListEvent, + // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; + // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), + // so — exactly like the 10009 list above — it must be stored replaceably or the Concord + // hub stays empty even after the event arrives. + is ConcordCommunityListEvent, + // The relay-signed NIP-29 39004 AV-participants addressable is durable group state. + is GroupParticipantsEvent, + is ExternalIdentitiesEvent, + is FileServersEvent, + is FollowListEvent, + is GeohashListEvent, + is GitRepositoryEvent, + is GitRepositoryStateEvent, + is UserGraspListEvent, + is RootSiteEvent, + is NamedSiteEvent, + is RootNappletEvent, + is NamedNappletEvent, + is RelayFeedsListEvent, + is KeyPackageEvent, + is KeyPackageRelayListEvent, + is LiveChessGameChallengeEvent, + is LiveChessGameAcceptEvent, + is LiveChessMoveEvent, + is LiveChessGameEndEvent, + is LiveChessDrawOfferEvent, + is HashtagListEvent, + is FavoriteAlgoFeedsListEvent, + is IndexerRelayListEvent, + is InteractiveStoryPrologueEvent, + is InteractiveStorySceneEvent, + is InteractiveStoryReadingStateEvent, + is InterestSetEvent, + is LabeledBookmarkListEvent, + is MeetingSpaceEvent, + is MeetingRoomEvent, + is MusicTrackEvent, + is MusicPlaylistEvent, + is AuthoredPodcastsEvent, + is FavoritePodcastsListEvent, + is Podcasting20EpisodeEvent, + is Podcasting20TrailerEvent, + is MuteListEvent, + is NNSEvent, + is PrivateOutboxRelayListEvent, + is ProfileBadgesEvent, + is ProxyRelayListEvent, + is PinListEvent, + is PeopleListEvent, + // Buzz addressable/replaceable state. + is PersonaEvent, + is TeamEvent, + is ManagedAgentEvent, + is AgentProfileEvent, + is EngramEvent, + is WorkflowDefEvent, + is EventReminderEvent, + is PushLeaseEvent, + is WindowBoundsEvent, + is ArchivedIdentitiesListEvent, + is RelayDiscoveryEvent, + is RelayMonitorEvent, + is RelaySetEvent, + is ReleaseArtifactSetEvent, + is SearchRelayListEvent, + is SoftwareApplicationEvent, + is TrustedRelayListEvent, + is TrustProviderListEvent, + is VideoHorizontalEvent, + is VideoVerticalEvent, + is WebBookmarkEvent, + is ExerciseTemplateEvent, + -> consumeBaseReplaceable(event, relay, wasVerified) - is ThreadEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachThreadToRelayGroupIfScoped(event, relay) - } - } - - is PollResponseEvent -> { - consume(event, relay, wasVerified) - } - - is RoadEventReportEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RoadEventConfirmationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayDiscoveryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RelayMonitorEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReactionEvent -> { - consume(event, relay, wasVerified) - } - - is LabelEvent -> { - consume(event, relay, wasVerified) - } - - is ContactCardEvent -> { - consume(event, relay, wasVerified) - } - - is RelaySetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReportEvent -> { - consume(event, relay, wasVerified) - } - - is RepostEvent -> { - consume(event, relay, wasVerified) - } - - is ReleaseArtifactSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SealedRumorEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is SearchRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareApplicationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareAssetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is StatusEvent -> { - consume(event, relay, wasVerified) - } - - is TextNoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TextNoteModificationEvent -> { - consume(event, relay, wasVerified) - } - - is ConcordChatEditEvent -> { - consume(event, relay, wasVerified) - } - - is TorrentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TorrentCommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TrustedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is TrustProviderListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoHorizontalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoNormalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VideoVerticalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoShortEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WakeUpEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WebBookmarkEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is WelcomeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WikiNoteEvent -> { - consume(event, relay, wasVerified) - } - - is WorkoutRecordEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExerciseTemplateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PaymentTargetsEvent -> { - consume(event, relay, wasVerified) - } + // ============================================================ + // Regular kinds with no per-kind logic: stored as plain notes. + // ============================================================ + is AudioHeaderEvent, + is BadgeAwardEvent, + is CallAnswerEvent, + is CallHangupEvent, + is CallIceCandidateEvent, + is CallOfferEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + is CashuTokenEvent, + is CashuSpendingHistoryEvent, + is CashuMintQuoteEvent, + // NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx + // (parameterized-replaceable per the spec) but neither CashuMintEvent / + // FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz + // today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would + // crash. Route them as regular events — downstream consumers + // (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag) + // and keep the newest. + is CashuMintEvent, + is FedimintEvent, + is MintRecommendationEvent, + is ChatMessageEncryptedFileHeaderEvent, + is ChatMessageEvent, + is BirdDetectionEvent, + is CommentEvent, + // The NIP-29 9xxx moderation actions and join/leave requests are regular + // one-shot events the relay is authoritative for (it applies them and + // republishes the 39000/39001/39002); we store them so they're queryable, + // but we don't act on them client-side. + is PutUserEvent, + is RemoveUserEvent, + is EditMetadataEvent, + is DeleteEventEvent, + is UpdatePinListEvent, + is DeleteGroupEvent, + is CreateGroupEvent, + is CreateInviteEvent, + is JoinRequestEvent, + is LeaveRequestEvent, + is FhirResourceEvent, + is FileHeaderEvent, + is ProfileGalleryEntryEvent, + is FileStorageHeaderEvent, + is GoalEvent, + is GroupEvent, + is GitIssueEvent, + is GitReplyEvent, + is GitPatchEvent, + is GitPullRequestEvent, + is GitPullRequestUpdateEvent, + is GitStatusEvent, + is ChessGameEvent, + is JesterEvent, + is HighlightEvent, + is PodcastEpisodeEvent, + is NIP90StatusEvent, + is NIP90ContentDiscoveryResponseEvent, + is NIP90ContentDiscoveryRequestEvent, + is NIP90UserDiscoveryResponseEvent, + is NIP90UserDiscoveryRequestEvent, + is PictureEvent, + is PrivateDmEvent, + is PublicMessageEvent, + is RequestToVanishEvent, + is CodeSnippetEvent, + is ZapPollEvent, + is RoadEventReportEvent, + is RoadEventConfirmationEvent, + is SealedRumorEvent, + is SoftwareAssetEvent, + is TextNoteEvent, + is TorrentEvent, + is TorrentCommentEvent, + is VideoNormalEvent, + is VideoShortEvent, + is VoiceEvent, + is VoiceReplyEvent, + is WakeUpEvent, + is WelcomeEvent, + is WorkoutRecordEvent, + -> consumeRegularEvent(event, relay, wasVerified) else -> { Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 00b375d9bf..49b9004e68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -59,7 +59,6 @@ import java.io.File class AccountCacheState( val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val contentResolverFn: () -> ContentResolver, @@ -266,7 +265,6 @@ class AccountCacheState( signer = signerWithClientTag, geolocationFlow = geolocationFlow, nwcFilterAssembler = nwcFilterAssembler, - cashuWalletFilterAssembler = cashuWalletFilterAssembler, cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler, okHttpClientForMoney = okHttpClientForMoney, otsResolverBuilder = otsResolverBuilder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 40f30c1dbb..1491bee591 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -62,6 +62,28 @@ class IndexerRelayListState( suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `indexerListNote.event` is usually still null and this resolves through + * `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public + * indexer relays gets its own relays immediately instead of the defaults. + */ + fun normalizeIndexerRelayListPrecached(note: Note): Set = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList + + /** + * The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup] + * substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the + * one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read + * this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff + * what the user actually configured. + * + * Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same + * reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an + * `emptySet()` seed left a window where `.value` contradicted the contract above. + */ val flow = getIndexerRelayListFlow() .map { normalizeIndexerRelayListWithBackup(it.note) } @@ -70,7 +92,7 @@ class IndexerRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + normalizeIndexerRelayListPrecached(indexerListNote), ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 3a227ef306..eb3714dc5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -62,6 +62,31 @@ class SearchRelayListState( suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `searchListNote.event` is usually still null and this resolves through + * `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public + * search relays gets its own relays immediately instead of the defaults. Accounts whose relays + * are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands. + */ + fun normalizeSearchRelayListPrecached(note: Note): Set = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList + + /** + * The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup] + * substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the + * one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can + * rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the + * user actually configured. + * + * Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means + * the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed + * left a window where `.value` contradicted the "never empty" contract above and search + * silently queried nothing. That window is unbounded for a NIP-46 signer whose list has + * private entries, since the first emission waits on a remote decrypt. + */ val flow = getSearchRelayListFlow() .map { normalizeSearchRelayListWithBackup(it.note) } @@ -70,7 +95,7 @@ class SearchRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + normalizeSearchRelayListPrecached(searchListNote), ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 3f9c610a9d..e18dfbde9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event @@ -102,7 +100,6 @@ class CashuWalletState( private val signer: NostrSigner, private val cache: LocalCache, private val scope: CoroutineScope, - private val assembler: CashuWalletFilterAssembler, private val outboxRelaysFlow: StateFlow>, private val inboxRelaysFlow: StateFlow>, private val dmRelaysFlow: StateFlow>, @@ -391,7 +388,6 @@ class CashuWalletState( // Lifecycle // ============================================================ private val jobs = mutableListOf() - private var currentSubscription: CashuWalletQueryState? = null @Volatile private var started = false @@ -469,21 +465,11 @@ class CashuWalletState( // our own kind:10019 — and another client may have published that // with relays unrelated to our NIP-65 lists — so we listen on the // union of those plus our NIP-65 inbox + DM relays. - jobs += - scope.launch(Dispatchers.IO) { - combine( - outboxRelaysFlow, - inboxRelaysFlow, - dmRelaysFlow, - _nutzapInfoEvent, - ) { outbox, inbox, dm, info -> - CashuWalletQueryState( - pubkey = pubKey, - ownEventRelays = outbox, - inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), - ) - }.collect { syncSubscription(it) } - } + // The relay subscription for this wallet is NOT here. It lives in + // CashuWalletEoseManager, inside the account-level assembler group, so it mounts and + // unmounts with every other account-level loader instead of running for the whole life of + // the Account object. What stays below is wallet *state*: indexing what arrives, and the + // local bookkeeping around it. // Reactive incremental update: any new event arrival that matches our // pubkey + the NIP-60/61 kinds we care about gets indexed. @@ -538,25 +524,6 @@ class CashuWalletState( fun destroy() { jobs.forEach { it.cancel() } jobs.clear() - currentSubscription?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = null - } - - // ============================================================ - // Subscription management - // ============================================================ - private fun syncSubscription(next: CashuWalletQueryState) { - val previous = currentSubscription - if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) { - previous?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = null - return - } - if (previous == next) return // unchanged - - previous?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = next - assembler.subscribe(next) } // ============================================================ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index b2ca4dcafc..1f2a0722e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder( normalizedUrl: String, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { + // Overlay-mesh hosts (0200::/7) are reachable only through the local mesh + // interface — Tor cannot route the range, so proxying only breaks the fetch. false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { true @@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder( isOnionRelaysActive: Boolean, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { isOnionRelaysActive diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt index c144917206..b35a1d02f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt index 7087316550..276e554749 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt index 2ec8bbf84e..7c7d3b95f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt index 8c8b5a26f2..652cf1b0f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt index 25b4142de9..dfa894880f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt index 37e3282003..722f233b49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt index a42ce71241..329d2b1a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.compute50kmRange import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.service.location.LocationState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt index 24b39bee60..8102e92182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt index eba50b6bcf..ff25143e5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt index 6cd70135c2..cfaf3629ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt index 6ee086ab5d..88e12bef45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.global import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt index fb724b88c4..5665eb6d0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt index 71df70bafd..d947ba6f0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt index a527720df8..d04ab0f024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt index a5a9268a24..eb2db69cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt index bf877e1260..37edb872c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt index 65027a3f90..31eac2dd69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt index a4b5aaef9a..bea37b9ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt index ce3cfe4da9..915eec656a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.relay import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt index d5734bda9d..5a0e0575d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.unknown import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 893f02ec87..14d3499371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -57,7 +57,27 @@ class DataStoreNappletStorage( key: String, value: String, ) { - dataStore.edit { it[keyOf(coordinate, key)] = value } + dataStore.edit { preferences -> + val prefix = prefixOf(coordinate) + val target = keyOf(coordinate, key) + val currentBytes = + preferences + .asMap() + .entries + .asSequence() + .filter { it.key.name.startsWith(prefix) } + .sumOf { (storedKey, storedValue) -> + storedKey.name + .removePrefix(prefix) + .encodeToByteArray() + .size + + ((storedValue as? String)?.encodeToByteArray()?.size ?: 0) + } + val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0) + val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size + require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." } + preferences[target] = value + } } override suspend fun remove( @@ -87,4 +107,9 @@ class DataStoreNappletStorage( coordinate: String, key: String, ) = stringPreferencesKey(prefixOf(coordinate) + key) + + companion object { + /** NAP-STORAGE's recommended per-napplet UTF-8 quota. */ + const val MAX_STORAGE_BYTES = 512 * 1024 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 37a124c67e..f49b497538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this @@ -93,7 +95,11 @@ class NappletBrokerService : Service() { // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the // requesting surface's launch token, so a surface's REQs always target the account it acts as. - private val liveSubscriptions = NappletLiveSubscriptions() + private val liveSubscriptions = NappletLiveSubscriptions(scope) + + // NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id. + // Cancelling removes the job before it can emit a late terminal envelope to the sandbox. + private val resourceRequests = ConcurrentHashMap() // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } @@ -117,7 +123,7 @@ class NappletBrokerService : Service() { override fun onDestroy() { liveSubscriptions.closeAll() - identityWatch.stop() + identityWatch.stopAll() // Every applet/browser surface has unbound, so the "session" the user granted for is over. // The ledger and the broker cache are now app-wide singletons that outlive this service, so // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime @@ -280,38 +286,57 @@ class NappletBrokerService : Service() { // Resolve the launch token to the trusted identity + declared set. The sandbox never states // its own coordinate, so a compromised :napplet process can only ever act as the napplet it // was launched as (it holds only its own token). An unknown token = no session; refuse. - val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + val session = NappletLaunchRegistry.resolve(launchToken) if (session == null) { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) return true } val identity = session.identity val declared = session.declared + val resourceRequestKey = "$launchToken\u0000$requestId" + if (requestType == "resource.cancel") { + resourceRequests.remove(resourceRequestKey)?.cancel() + return true + } + val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany" - scope.launch { - // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply - // decision (it stays wire-identical with the future desktop host). This service only supplies - // the broker, the Messenger transport, and the live relay subscription each Outcome implies. - // The launch token decides whose key signs — not the active account. A surface opened by - // one account can never be handed another's signer, even while it stays open across a switch. - val broker = brokerFor(session.accountPubKey) - if (broker == null) { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) - return@launch - } - when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { - is NappletRequestRouter.Outcome.Ignore -> {} - is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> - liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } - is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) - is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) } - is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop() - is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } - is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + val requestJob = + scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) { + // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply + // decision (it stays wire-identical with the future desktop host). This service only supplies + // the broker, the Messenger transport, and the live relay subscription each Outcome implies. + // The launch token decides whose key signs — not the active account. A surface opened by + // one account can never be handed another's signer, even while it stays open across a switch. + val broker = brokerFor(session.accountPubKey) + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) + return@launch + } + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + is NappletRequestRouter.Outcome.Reply -> { + reply(replyTo, requestId, outcome.payload) + // NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup + // snapshot succeeds, the runtime owns identity.changed delivery for this + // trusted launch token until the broker service closes. + if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) { + identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) } + } + } + is NappletRequestRouter.Outcome.OpenSubscription -> + liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } + is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + } } + if (tracksResourceRequest) { + resourceRequests.put(resourceRequestKey, requestJob)?.cancel() + requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) } + requestJob.start() } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index adff3b46f6..15ab1abd71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @StringRes fun NappletCapability.labelRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay @@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int = @StringRes fun NappletCapability.descriptionRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 42f4ec93f5..583cda40b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -294,9 +294,10 @@ class NappletConsentSummary( context.getString(R.string.napplet_consent_pay_no_amount) } } - is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) + NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany -> + context.getString(R.string.napplet_consent_resource) is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) // Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown. - is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" + is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index f94f7bc2cf..819c55b4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It @@ -34,31 +35,35 @@ import kotlinx.coroutines.launch * value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key * (or `""` when no account is signed in) to the caller-supplied sink. * - * One watch at a time per host binding; [start] replaces any prior one. Reached only after the - * router confirmed the applet declared the IDENTITY capability. + * Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's + * streams. A watch starts only after that surface successfully obtains its public-key snapshot. */ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private var job: Job? = null + private val jobs = ConcurrentHashMap() fun start( + watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - stop() - job = - scope.launch { - pubKey(boundPubKey) - .distinctUntilChanged() - .drop(1) - .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } - } + jobs.computeIfAbsent(watchId) { id -> + scope + .launch { + pubKey(boundPubKey) + .distinctUntilChanged() + .drop(1) + .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } + }.also { job -> + job.invokeOnCompletion { jobs.remove(id, job) } + } + } } - fun stop() { - job?.cancel() - job = null + fun stopAll() { + jobs.values.forEach { it.cancel() } + jobs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index b91aabad36..8f6741e564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -76,7 +76,7 @@ object NappletLaunchRegistry { accountPubKey: HexKey, ): String { val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() - sessions[token] = Session(identity, declared, accountPubKey) + sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey) return token } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 2c98ffb8fe..c923b799d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -25,16 +25,20 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletHostActivity import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.utils.Log /** * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only @@ -49,20 +53,18 @@ object NappletLauncher { manifest: NappletManifest, authorPubKey: HexKey, identifier: String, - ) = launch( - context = context, - paths = manifest.paths(), - servers = manifest.servers(), - authorPubKey = authorPubKey, - identifier = identifier, - aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), - title = manifest.title() ?: identifier.ifBlank { "Napplet" }, - requires = manifest.requires(), - ) + ) { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" } + return + } + buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } + } /** - * Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — - * the broker then refuses every capability, so the site renders as inert static content. + * Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified + * manifest overload so raw callers cannot bypass signature/author validation. */ fun launch( context: Context, @@ -73,12 +75,26 @@ object NappletLauncher { aggregateHash: HexKey?, title: String, requires: List, - // nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The - // broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless - // of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. - profile: HostProfile = HostProfile.NAPPLET, + // Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must + // use the signature-checking manifest overload above. + profile: HostProfile, + ) { + if (profile != HostProfile.WEBSITE) { + Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" } + return + } + val params = + runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) } + .onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) } + .getOrNull() + ?: return + openHost(context, params) + } + + private fun openHost( + context: Context, + params: Bundle, ) { - val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) val intent = Intent(context, NappletHostActivity::class.java).apply { putExtras(params) @@ -105,6 +121,29 @@ object NappletLauncher { requires: List, profile: HostProfile, ): Bundle { + require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." } + return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) + } + + private fun buildLaunchParamsTrusted( + context: Context, + paths: List, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + profile: HostProfile, + ): Bundle { + val effectiveAggregateHash = + if (profile == HostProfile.NAPPLET) { + requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) { + "NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate." + } + } else { + aggregateHash + } val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 // Augment the manifest's servers with the author's published Blossom list (kind:10063), if @@ -118,7 +157,7 @@ object NappletLauncher { // Mint the launch token in the (trusted) main process: the broker resolves the sandbox's // requests back to THIS identity + declared set, regardless of anything the sandbox sends. - val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) + val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash) val declared = profile.declaredCapabilities(requires) // Bound to the account launching it, so the surface keeps signing as that account even if the // user switches while it is open (an embedded surface is rebuilt on a switch and re-mints). @@ -156,7 +195,7 @@ object NappletLauncher { putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) - putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) + putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash) putString(NappletHostContract.EXTRA_TITLE, title) putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) @@ -170,4 +209,34 @@ object NappletLauncher { putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) } } + + /** Signature-checking entry point for embedded NIP-5D surfaces. */ + fun buildLaunchParams( + context: Context, + manifest: NappletManifest, + authorPubKey: HexKey, + identifier: String, + ): Bundle? { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" } + return null + } + return runCatching { + buildLaunchParamsTrusted( + context = context, + paths = manifest.paths(), + servers = manifest.servers(), + authorPubKey = authorPubKey, + identifier = identifier, + aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), + title = manifest.title() ?: identifier.ifBlank { "Napplet" }, + requires = manifest.requires(), + profile = HostProfile.NAPPLET, + ) + }.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) } + .getOrNull() + } + + private const val TAG = "NappletLauncher" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 8429a0b721..89e56f0b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger * signatures still came from the old one. [open] is reached only after the broker authorized the * subscription (RELAY consent). */ -class NappletLiveSubscriptions { +class NappletLiveSubscriptions( + private val scope: CoroutineScope, +) { private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) @@ -53,6 +59,20 @@ class NappletLiveSubscriptions { val client: INostrClient, ) { val eoseSent = AtomicBoolean(false) + val deliveries = Channel(Channel.UNLIMITED) + var deliveryJob: Job? = null + } + + private sealed interface Delivery { + data class RelayEvent( + val event: Event, + ) : Delivery + + data object Eose : Delivery + + data class Closed( + val reason: String, + ) : Delivery } /** @@ -77,15 +97,31 @@ class NappletLiveSubscriptions { // can't collide with the subscription it's replacing. val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) liveSubs[nappletSubId] = sub + sub.deliveryJob = + scope.launch { + for (delivery in sub.deliveries) { + if (liveSubs[nappletSubId] !== sub) break + when (delivery) { + is Delivery.RelayEvent -> + NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let { + push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + } + Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason)) + } + } + } val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + ) { + sub.deliveries.trySend(Delivery.RelayEvent(event)) + } // A subscription fans out to several relays; collapse their EOSEs into the single // relay.eose the SDK expects (fired when the first relay finishes its stored events). @@ -93,14 +129,16 @@ class NappletLiveSubscriptions { relay: NormalizedRelayUrl, forFilters: List?, ) { - if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose) } override fun onClosed( message: String, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + ) { + sub.deliveries.trySend(Delivery.Closed(message)) + } } runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } @@ -109,12 +147,18 @@ class NappletLiveSubscriptions { /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ fun close(nappletSubId: String) { val sub = liveSubs.remove(nappletSubId) ?: return + sub.deliveries.close() + sub.deliveryJob?.cancel() runCatching { sub.client.unsubscribe(sub.clientSubId) } } /** Tears down every open subscription (service teardown). */ fun closeAll() { - liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } + liveSubs.values.forEach { sub -> + sub.deliveries.close() + sub.deliveryJob?.cancel() + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } liveSubs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt new file mode 100644 index 0000000000..3b55dad5cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt @@ -0,0 +1,75 @@ +/* + * 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.napplet + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 + +/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */ +internal object NappletRelayCleartext { + suspend fun forDelivery( + event: Event, + signer: NostrSigner, + ): Event? = forDelivery(event, signer.pubKey, signer::decrypt) + + internal suspend fun forDelivery( + event: Event, + userPubKey: HexKey, + decrypt: suspend (String, HexKey) -> String, + ): Event? { + if (!isEncrypted(event)) return event + + val peer = + when { + event.pubKey == userPubKey -> event.recipientPubKey() + event.isAddressedTo(userPubKey) -> event.pubKey + else -> null + } ?: return null + val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null + + // NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and + // signature fields so callers can still correlate it, while making clear that this object + // must never be republished as a signed event after its content projection has changed. + return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig) + } + + internal fun isEncrypted(event: Event): Boolean = + event is PrivateDmEvent || + EncryptedInfo.isNIP04(event.content) || + isNip44V2(event.content) + + private fun isNip44V2(content: String): Boolean = + content.length >= MIN_NIP44_V2_LENGTH && + runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess + + private fun Event.recipientPubKey(): HexKey? = + tags.firstNotNullOfOrNull { tag -> + tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" } + } + + private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey } + + private const val MIN_NIP44_V2_LENGTH = 132 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index e7fd4f0d67..3222e810c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -255,7 +256,7 @@ class AccountNappletGateways( emptyList() } else { runCatching { - account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds) + account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds) }.getOrDefault(emptyList()) } val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } } @@ -265,7 +266,8 @@ class AccountNappletGateways( .distinctBy { it.id } .sortedByDescending { it.createdAt } val limit = filters.mapNotNull { it.limit }.maxOrNull() - return limit?.let { merged.take(it) } ?: merged + val limited = limit?.let { merged.take(it) } ?: merged + return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) } } /** @@ -279,7 +281,7 @@ class AccountNappletGateways( } val result = CompletableDeferred() - account.sendZapPaymentRequestFor(invoice, null) { response -> + account.zaps.sendZapPaymentRequestFor(invoice, null) { response -> when (response) { is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index a5e67039e0..24bf6e655c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.quartz.nip01Core.core.Address @@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Authenticator +import okhttp3.Call +import okhttp3.Callback +import okhttp3.CookieJar +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InterruptedIOException +import java.net.InetAddress import java.net.URLDecoder +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.TimeUnit /** * Fetches a resource URL on an applet's behalf — the applet has no direct network @@ -63,41 +81,100 @@ class NappletResourceFetcher( private val account: Account, private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + /** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */ suspend fun fetch( url: String, coordinate: String, - ): NappletResource? = + ): NappletResourceResult = withContext(Dispatchers.IO) { - // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. - NappletNetworkRegistry.awaitReady() - val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) when { url.startsWith("data:") -> decodeDataUrl(url) - url.startsWith("https://") -> { - runCatching { - client - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (!r.isSuccessful) return@withContext null - val body = r.body.bytes() - val type = r.header("Content-Type") ?: "application/octet-stream" - NappletResource(body, type) - } - }.getOrNull() + url.startsWith("nostr:") -> + resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.") + url.startsWith("https://") || url.startsWith("blossom:") -> { + // Route like the applet's own page: locked napplets stay on Tor. The derived + // client removes ambient cookies/auth and validates DNS before every hop. + NappletNetworkRegistry.awaitReady() + val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate))) + if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client) } - url.startsWith("blossom:") -> fetchBlossom(url, client) - url.startsWith("nostr:") -> resolveNostr(url) - else -> null + else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.") } } + private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient = + baseClient + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .cache(null) + .cookieJar(CookieJar.NO_COOKIES) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .dns( + Dns { hostname -> + baseClient.dns.lookup(hostname).also { addresses -> + if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) { + throw BlockedResourceException("Resolved address is not public.") + } + } + }, + ).addNetworkInterceptor { chain -> + chain.proceed( + chain + .request() + .newBuilder() + .removeHeader("Authorization") + .removeHeader("Cookie") + .removeHeader("Proxy-Authorization") + .build(), + ) + }.build() + + private suspend fun fetchHttps( + url: String, + client: OkHttpClient, + ): NappletResourceResult { + var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.") + repeat(MAX_REDIRECTS + 1) { hop -> + try { + client + .newCall( + Request + .Builder() + .url(current) + .get() + .build(), + ).await() + .use { response -> + if (response.isRedirect) { + if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.") + current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.") + return@repeat + } + if (response.code == 404) return failure(ERROR_NOT_FOUND) + if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.") + if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE) + return classify(body) + } + } catch (e: BlockedResourceException) { + return failure(ERROR_BLOCKED, e.message) + } catch (_: InterruptedIOException) { + return failure(ERROR_TIMEOUT) + } catch (_: Exception) { + return failure(ERROR_NETWORK) + } + } + return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + } + + private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) } + + private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } + /** * Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed` * carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to @@ -144,7 +221,7 @@ class NappletResourceFetcher( val relays = account.homeRelays.flow.value if (relays.isEmpty()) return null return runCatching { - account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS) + account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS) }.getOrDefault(emptyList()) .maxByOrNull { it.createdAt } } @@ -155,65 +232,205 @@ class NappletResourceFetcher( * wrong server can never substitute the blob. Returns null for a malformed hash or if no server * serves it. */ - private fun fetchBlossom( + private suspend fun fetchBlossom( url: String, client: OkHttpClient, - ): NappletResource? { - val hash = - url - .removePrefix("blossom://") - .removePrefix("blossom:") - .substringBefore('/') - .substringBefore('?') - .trim() - .lowercase() - if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + ): NappletResourceResult { + if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") + val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase() + if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") val servers = account.blossomServers .getBlossomServersList() ?.servers() .orEmpty() + var sawHashMismatch = false for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { - val bytes = - runCatching { - client - .newCall( - Request - .Builder() - .url(candidate) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - }.getOrNull() ?: continue - if (StaticSiteResolver.verify(bytes, hash)) { - return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + when (val fetched = fetchHttps(candidate, client)) { + is NappletResourceResult.Success -> { + if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) { + sawHashMismatch = true + continue + } + return fetched + } + is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched } } - return null + if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.") + return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.") } + private suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (continuation.isActive) continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }, + ) + } + /** Parses a `data:[][;base64],` URL into bytes + content type. */ - private fun decodeDataUrl(url: String): NappletResource? { + private fun decodeDataUrl(url: String): NappletResourceResult { val comma = url.indexOf(',') - if (comma < 0) return null + if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.") val meta = url.substring("data:".length, comma) val data = url.substring(comma + 1) + if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE) val isBase64 = meta.endsWith(";base64") - val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val declaredType = + meta + .removeSuffix(";base64") + .substringBefore(';') + .ifEmpty { "text/plain" } + .lowercase() val bytes = if (isBase64) { - runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.") } else { - URLDecoder.decode(data, "UTF-8").encodeToByteArray() + runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.") } - return NappletResource(bytes, contentType) + if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + return classify(bytes, declaredType) + } + + private fun classify( + bytes: ByteArray, + declaredType: String? = null, + ): NappletResourceResult { + if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.") + val sniffed = sniffContentType(bytes) + val type = + when { + sniffed in ALLOWED_SNIFFED_TYPES -> sniffed + declaredType == "application/json" && isJson(bytes) -> "application/json" + declaredType == "text/plain" && isPlainText(bytes) -> "text/plain" + else -> null + } ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.") + return success(NappletResource(bytes, type)) + } + + private fun looksLikeSvg(bytes: ByteArray): Boolean { + val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase() + return prefix.contains(" + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + var total = 0 + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > MAX_RESOURCE_BYTES) return null + output.write(buffer, 0, read) + } + return output.toByteArray() + } } companion object { + internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true + + internal fun isPublicAddress(address: InetAddress): Boolean { + if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) { + return false + } + val bytes = address.address + if (bytes.size == 4) { + val first = bytes[0].toInt() and 0xff + val second = bytes[1].toInt() and 0xff + // Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify. + if (first == 0 || first >= 224) return false + if (first == 100 && second in 64..127) return false + if (first == 192 && second == 0) return false + if (first == 198 && second in 18..19) return false + if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false + if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false + } else if (bytes.size == 16) { + val first = bytes[0].toInt() and 0xff + if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local + if ( + first == 0x20 && + (bytes[1].toInt() and 0xff) == 0x01 && + (bytes[2].toInt() and 0xff) == 0x0d && + (bytes[3].toInt() and 0xff) == 0xb8 + ) { + return false // 2001:db8::/32 documentation range + } + } + return true + } + private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L + private const val FETCH_TIMEOUT_SECONDS = 30L + private const val MAX_REDIRECTS = 5 + private const val MIME_PREFIX_BYTES = 8 * 1024 + private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024 + private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:" + const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024 + private const val ERROR_INVALID_REQUEST = "invalid-request" + private const val ERROR_NOT_FOUND = "not-found" + private const val ERROR_BLOCKED = "blocked-by-policy" + private const val ERROR_TIMEOUT = "timeout" + private const val ERROR_TOO_LARGE = "too-large" + private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme" + private const val ERROR_DECODE_FAILED = "decode-failed" + private const val ERROR_NETWORK = "network-error" + private val SHA256 = Regex("^[0-9a-f]{64}$") + private val ALLOWED_SNIFFED_TYPES = + setOf( + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "audio/ogg", + "video/mp4", + ) } + + private class BlockedResourceException( + message: String, + ) : java.io.IOException(message) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 664b55ed61..3ea9d1678d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -113,7 +113,7 @@ object ClinkDebitPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 28a0d64fb0..a5911dc6c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -85,7 +85,7 @@ object ClinkOfferPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 6fa95cfe3f..3f7657ca83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -159,7 +159,7 @@ class V4VPaymentHandler( tlvRecords = tlvRecords, ) - account.sendNwcRequest(request) { response: Response? -> + account.zaps.sendNwcRequest(request) { response: Response? -> if (response is IErrorResponseLike) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), @@ -195,7 +195,7 @@ class V4VPaymentHandler( try { val nostrRequest = if (asZap && noteEvent != null) { - account.createZapRequestFor( + account.zaps.createZapRequestFor( event = noteEvent, pollOption = null, message = message, @@ -250,7 +250,7 @@ class V4VPaymentHandler( is PaymentSource.Nwc -> { var done = 0 payables.forEach { payable -> - account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> + account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> if (response is IErrorResponseLike) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 6fa29d1b5e..0d5ad13cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -163,7 +163,7 @@ class ZapPaymentHandler( val canBolt12 = account.settings.nwcWallets.value .isNotEmpty() && - account.defaultWalletSupportsBolt12Pay() + account.zaps.defaultWalletSupportsBolt12Pay() val bolt12Recipients = unverifiedZapsToSend.mapNotNull { @@ -330,7 +330,7 @@ class ZapPaymentHandler( val zapRequest = if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { - account.createZapRequestFor( + account.zaps.createZapRequestFor( event = noteEvent, pollOption = pollOption, message = message, @@ -414,7 +414,7 @@ class ZapPaymentHandler( return mapNotNullAsync( items = payables, runRequestFor = { payable: Payable -> - account.sendZapPaymentRequestFor( + account.zaps.sendZapPaymentRequestFor( bolt11 = payable.invoice, zappedNote = note, onResponse = { response -> @@ -462,7 +462,7 @@ class ZapPaymentHandler( val progress = PaymentProgress(recipients.size, onProgress) mapNotNullAsync(recipients) { recipient: Bolt12Recipient -> - account.sendBolt12Zap( + account.zaps.sendBolt12Zap( zappedEvent = note.event, recipientPubKey = recipient.user.pubkeyHex, offer = recipient.offer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 0a70fd4923..82566a44f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -54,21 +54,21 @@ class MemoryTrimmingService( ) { // Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are // not currently held by the UI, so it is safe and inexpensive at any pressure level. - cache.cleanMemory() - cache.cleanObservers() - cache.pruneExpiredEvents() - cache.prunePastVersionsOfReplaceables() + cache.pruner.cleanMemory() + cache.pruner.cleanObservers() + cache.pruner.pruneExpiredEvents() + cache.pruner.prunePastVersionsOfReplaceables() if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { // Tier 2: real reclaim pressure — drop events from muted/blocked users, old // messages, and unobserved reactions. account.forEach { - cache.pruneHiddenEvents(it) - cache.pruneHiddenMessages(it) + cache.pruner.pruneHiddenEvents(it) + cache.pruner.pruneHiddenMessages(it) } val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() - cache.pruneOldMessages() - cache.pruneRepliesAndReactions(accounts) + cache.pruner.pruneOldMessages() + cache.pruner.pruneRepliesAndReactions(accounts) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 87ab766cee..0fd483cf2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -21,56 +21,137 @@ package com.vitorpamplona.amethyst.service.location import android.annotation.SuppressLint -import android.content.Context import android.location.Location import android.location.LocationListener import android.location.LocationManager +import android.os.Build import android.os.Looper -import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE -import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch +/** + * Wraps [LocationManager] update registration as a cold [Flow]. + * + * Registers on **one** provider, chosen by [LocationProviderLadder], rather than + * on every provider the device reports. The previous shotgun cost four + * simultaneous registrations — passive, network, fused and gps, the last at + * HIGH_ACCURACY — to produce a 5 km geohash. + * + * Takes a [LocationManager] rather than a `Context` so the registration + * behaviour is unit-testable; the caller does the `getSystemService` lookup. + * + * [onListening] is fired from inside the flow, after a registration succeeds, and + * released again from the `try`/`finally` that wraps everything after it, never + * as an `onStart`/`onCompletion` pair on the returned flow. The distinction + * matters: an `onStart` fires on collection even when nothing registered, so a + * device with no usable provider would accrue location time with no location + * running, and — because the ledger refcounts the two [LocationState] flows + * together — the unpaired close would steal the other flow's holder. + * + * The pair is kept honest from both ends. The acquire cannot fire without a + * registration, because a failure to register throws before reaching it. The + * release cannot be skipped, because everything after the acquire runs inside a + * `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation] + * (the seed sweep below) can throw a non-cancellation exception and unwind + * before `awaitClose` is ever reached, and `try`/`finally` is what still runs + * the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in + * `LocationFlowTest`. (In principle a collector cancelling mid-seed would also + * unwind past `awaitClose` the same way, but that path could not be + * reproduced — `callbackFlow`'s buffer is empty at this point, so the single + * seed send returns without suspending and never observes the cancellation.) + */ class LocationFlow( - private val context: Context, + private val locationManager: LocationManager, + private val sdkInt: Int = Build.VERSION.SDK_INT, ) { @SuppressLint("MissingPermission") fun get( - minTimeMs: Long = MIN_TIME, - minDistanceM: Float = MIN_DISTANCE, + minTimeMs: Long, + minDistanceM: Float, + onListening: ((Boolean) -> Unit)? = null, ): Flow = callbackFlow { - Log.i("LocationFlow", "Start") - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val locationCallback = LocationListener { location -> Log.d("LocationFlow") { "onLocationChanged $location" } launch { send(location) } } - locationManager.allProviders.forEach { - val location = locationManager.getLastKnownLocation(it) - Log.d("LocationFlow") { "Last Known location is $location" } - if (location != null) { - send(location) - } - Log.d("LocationFlow", "Requesting Updates") - locationManager.requestLocationUpdates( - it, - minTimeMs, - minDistanceM, - locationCallback, - Looper.getMainLooper(), - ) - } + // One binder call, reused for both the ladder filter and the seed. + val providers = locationManager.allProviders - awaitClose { - Log.i("LocationFlow", "Stop") + val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers } + + val registered = + candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) } + ?: throw SecurityException("No usable location provider. Candidates: $candidates") + + Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" } + onListening?.invoke(true) + + // Cleanup lives in this finally, not in awaitClose — see the class + // KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in + // LocationFlowTest, which fails if it moves. + try { + // Seeded after registration so the no-provider path throws + // without having emitted anything; seeding first would show the + // consumer Success -> LackPermission on a device with no + // compatible provider. + freshestLastKnownLocation(candidates)?.let { + Log.d("LocationFlow") { "Last known location is $it" } + send(it) + } + + awaitClose { } + } finally { + Log.i("LocationFlow") { "Stopped listening on $registered" } locationManager.removeUpdates(locationCallback) + onListening?.invoke(false) } } + + /** True when the registration was accepted; false when the provider refused it. */ + @SuppressLint("MissingPermission") + private fun requestUpdates( + provider: String, + minTimeMs: Long, + minDistanceM: Float, + locationCallback: LocationListener, + ): Boolean = + try { + locationManager.requestLocationUpdates( + provider, + minTimeMs, + minDistanceM, + locationCallback, + Looper.getMainLooper(), + ) + true + } catch (e: SecurityException) { + Log.w("LocationFlow", "Provider $provider refused the update request", e) + false + } + + /** + * The freshest cached fix across the providers the ladder deemed usable. + * Sweeping every provider the device reports instead would, on the + * coarse-only legacy path, pay a guaranteed-to-throw binder call per + * fine-only provider on every flow start. Still guarded per provider, like + * the update request is — on a device where one refuses us, the others + * should still seed. + */ + @SuppressLint("MissingPermission") + private fun freshestLastKnownLocation(providers: List): Location? = + providers + .mapNotNull { provider -> + try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + Log.w("LocationFlow", "No permission to read the last known location of $provider", e) + null + } + }.maxByOrNull { it.time } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt new file mode 100644 index 0000000000..fb9ae6d8a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt @@ -0,0 +1,72 @@ +/* + * 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.service.location + +import android.location.LocationManager +import android.os.Build + +/** + * Picks which location providers to try, in order. + * + * Deliberately selects on **provider existence**, never on + * [LocationManager.isProviderEnabled]. A registration on a disabled provider + * goes live by itself when the user enables location — including from the + * quick-settings shade without leaving the app, which is exactly what someone + * does after seeing an empty "Around Me" feed. An enabled-state guard evaluated + * once at subscription start would lose that. + * + * Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`; + * only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which + * lets a coarse-only app request any provider and receive a fuzzed result, is an + * Android 12 change. Amethyst declares coarse only, so the legacy branch is + * unconditional below API 31. + * + * Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the + * branch below has no permission input, so pre-31 devices would keep getting + * `network` alone and silently lose the precision the new permission was granted + * for. Whoever adds it must widen the condition here too. + * + * Returns the ordered candidate list rather than a single choice so the caller + * can fall through to the next rung if a registration is refused. An empty list + * means no compatible provider exists. + */ +object LocationProviderLadder { + // Compile-time String constants, inlined by the compiler, so naming + // FUSED_PROVIDER (added in API 31) is safe on older runtimes. + private val FULL_LADDER = + listOf( + LocationManager.FUSED_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER) + + fun chooseProviders( + sdkInt: Int, + exists: (String) -> Boolean, + ): List { + val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER + + return ladder.filter(exists) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 98899953e5..82ad1bed00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,32 +21,83 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import android.location.Location +import android.location.LocationManager import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same +// package, so it needs no import. + +/** + * Turns the device's location into geohashes, listening **only while the app is + * in the foreground**. + * + * The gate is not an optimisation of last resort: `Account` builds 30 + * `SharingStarted.Eagerly` top-nav filter states on the account scope, and + * `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so + * without it every user with location permission holds a registration for the + * life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`. + * + * Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60 + * call sites read `account.live*FollowLists.value` synchronously rather than + * collecting, and would silently serve a stale or initial value. + */ class LocationState( context: Context, - scope: CoroutineScope, - /** Resource-ledger hook: true while GPS/location updates are actively requested. */ + private val scope: CoroutineScope, + private val isForeground: StateFlow, + /** + * Resource-ledger hook: true while location updates are actively + * registered. Reaches the OS only through the default [locationSource], + * which hands it to [LocationFlow] — a caller that overrides + * [locationSource] (the tests do) is responsible for firing it, or not. + */ private val onListening: ((Boolean) -> Unit)? = null, + private val locationSource: (Long, Float) -> Flow = { minTimeMs, minDistanceM -> + LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) + .get(minTimeMs, minDistanceM, onListening) + }, ) { companion object { - const val MIN_TIME: Long = 10000L - const val MIN_DISTANCE: Float = 100.0f + /** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */ + const val COARSE_MIN_TIME: Long = 60_000L + const val COARSE_MIN_DISTANCE: Float = 500.0f + + /** Building-level geohashes need the tighter profile. */ + const val PRECISE_MIN_TIME: Long = 10_000L + const val PRECISE_MIN_DISTANCE: Float = 100.0f + + /** + * How long to keep listening after the last activity stops, so a + * one-second app switch doesn't destroy and rebuild the registration. + * Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis. + */ + const val BACKGROUND_GRACE_MS: Long = 5_000L + + /** + * How long `stateIn` keeps the upstream alive after the last collector + * leaves, so a screen rotation or a tab switch doesn't rebuild the + * registration either. + */ + const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L } sealed class LocationResult { @@ -59,9 +110,16 @@ class LocationState( object Loading : LocationResult() } + private enum class Gate { NoPermission, Paused, Listen } + private var hasLocationPermission = MutableStateFlow(false) - private var latestLocation: LocationResult = LocationResult.Loading - private var latestPreciseLocation: LocationResult = LocationResult.Loading + + // Read by R1 below to decide whether to emit Loading, from a different + // coroutine than the onEach that writes it — hence a StateFlow rather than + // a plain field. + private val latestLocation = MutableStateFlow(LocationResult.Loading) + + private val latestPreciseLocation = MutableStateFlow(LocationResult.Loading) fun setLocationPermission(newValue: Boolean) { if (newValue != hasLocationPermission.value) { @@ -69,36 +127,92 @@ class LocationState( } } + /** + * Foreground with an asymmetric delay: leaving the foreground waits out + * [BACKGROUND_GRACE_MS], returning to it is immediate. + * + * `debounce(5000)` would delay both edges, and the duration-selector + * overload that allows an asymmetric delay is `@FlowPreview`. + * `transformLatest` cancels the pending `delay` when foreground returns + * first, which is exactly the semantics wanted, with no preview opt-in. + * + * Known and harmless: [ForegroundTracker] starts at `false`, so on a + * process that starts backgrounded the first emission — and therefore the + * first gate verdict, including `LackPermission` — is delayed by + * [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process + * that starts into the foreground emits immediately, because the activity's + * `onStart` cancels the pending delay. + */ @OptIn(ExperimentalCoroutinesApi::class) - val geohashStateFlow by lazy { - hasLocationPermission - .transformLatest { - if (it) { - emit(LocationResult.Loading) - val result = - LocationFlow(context) - .get(MIN_TIME, MIN_DISTANCE) - .onStart { onListening?.invoke(true) } - .onCompletion { onListening?.invoke(false) } - .map { - LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult - }.onEach { - latestLocation = it - }.catch { e -> - Log.w("GeohashStateFlow", "Exception in the flow", e) - latestLocation = LocationResult.LackPermission - emit(LocationResult.LackPermission) - } + private val settledForeground: Flow = + isForeground.transformLatest { foreground -> + if (!foreground) delay(BACKGROUND_GRACE_MS) + emit(foreground) + } - emitAll(result) - } else { - emit(LocationResult.LackPermission) + private val gate: Flow = + combine(hasLocationPermission, settledForeground) { permitted, foreground -> + when { + !permitted -> Gate.NoPermission + foreground -> Gate.Listen + else -> Gate.Paused + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun buildGeohashStateFlow( + tag: String, + charsCount: Int, + minTimeMs: Long, + minDistanceM: Float, + cache: MutableStateFlow, + ): StateFlow = + gate + .transformLatest { state -> + when (state) { + // Deliberately does NOT write to the cache. Today's code emits + // LackPermission without touching the cache, and wiping it + // here would cost a Loading emission — and so an empty-feed + // flash — on every permission flap, which is the regression R1 + // exists to prevent. Consumers already see LackPermission from + // the StateFlow; the cache is internal and only decides whether + // Loading is emitted. + Gate.NoPermission -> emit(LocationResult.LackPermission) + + // Emit nothing: stateIn keeps the last value, so every + // synchronous .value reader still sees the last known geohash + // while the OS registration is released. + Gate.Paused -> Unit + + Gate.Listen -> { + // Only when there is nothing cached. Emitting Loading on + // every foreground return would flash the "Around Me" feed + // empty, because AroundMeFeedFlow.convert maps anything + // that is not Success to an empty geotag set. + if (cache.value !is LocationResult.Success) emit(LocationResult.Loading) + + emitAll( + locationSource(minTimeMs, minDistanceM) + .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } + .onEach { cache.value = it } + .catch { e -> + Log.w(tag, "Exception in the flow", e) + cache.value = LocationResult.LackPermission + emit(LocationResult.LackPermission) + }, + ) + } } - }.stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - latestLocation, - ) + }.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value) + + val geohashStateFlow: StateFlow by lazy { + buildGeohashStateFlow( + tag = "GeohashStateFlow", + charsCount = GeohashPrecision.KM_5_X_5.digits, + minTimeMs = COARSE_MIN_TIME, + minDistanceM = COARSE_MIN_DISTANCE, + cache = latestLocation, + ) } /** @@ -107,36 +221,19 @@ class LocationState( * to every coarser level (a geohash is a prefix code), so one fix yields the * whole region→building ladder. Kept separate so the coarser * [geohashStateFlow] the "around me" feed relies on is unchanged. + * + * Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android + * fuzzes every fix to roughly a 3 km grid and this is not in fact + * building-level today. The profile is kept so the intent survives if the + * app ever requests `ACCESS_FINE_LOCATION`. */ - @OptIn(ExperimentalCoroutinesApi::class) - val preciseGeohashStateFlow by lazy { - hasLocationPermission - .transformLatest { - if (it) { - emit(LocationResult.Loading) - val result = - LocationFlow(context) - .get(MIN_TIME, MIN_DISTANCE) - .onStart { onListening?.invoke(true) } - .onCompletion { onListening?.invoke(false) } - .map { - LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult - }.onEach { - latestPreciseLocation = it - }.catch { e -> - Log.w("GeohashStateFlow", "Exception in the precise flow", e) - latestPreciseLocation = LocationResult.LackPermission - emit(LocationResult.LackPermission) - } - - emitAll(result) - } else { - emit(LocationResult.LackPermission) - } - }.stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - latestPreciseLocation, - ) + val preciseGeohashStateFlow: StateFlow by lazy { + buildGeohashStateFlow( + tag = "PreciseGeohashStateFlow", + charsCount = GeohashChannelLevel.BUILDING.chars, + minTimeMs = PRECISE_MIN_TIME, + minDistanceM = PRECISE_MIN_DISTANCE, + cache = latestPreciseLocation, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 4af3a66589..30b3add1dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,13 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -45,31 +49,45 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * Two switches gate the system: + * It also decides **which accounts pull from relays**, which is a different question from + * whether the service runs, and the two are deliberately not gated the same way. * - * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the - * "Background notification service" toggle / Quick Settings tile). When off, every - * layer is torn down and nothing restarts, regardless of any account's setting — - * this is the battery-saver "airplane mode". Persisted, so an explicit off survives - * restarts and crashes. - * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], - * "Keep this account active in the background") **or** its NIP-46 signer toggle - * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is - * on, the service runs as long as **at least one** writable account has either flag on — the - * background signer relies on the same foreground service to keep answering requests. + * ## Who subscribes * - * While the master is on, every saved writable account is kept loaded in - * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps - * addressed to any of them (delivered via open relay subscriptions) get unwrapped by - * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active - * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no - * subscriber able to decrypt them. + * - **While a screen is up: every loaded account.** The user can switch accounts at any moment + * and expects the one they land on to be current, so all of them keep their own notifications, + * DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen. + * - **While the app is away: only the accounts that opted in**, via + * [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this + * account active in the background") or their NIP-46 signer toggle + * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). + * + * That is what the setting's name promises, and for a while it did not hold: participation gated + * subscriptions everywhere, so an account you had not opted in for showed no notifications even + * with the app open in front of you. + * + * ## Whether the service runs + * + * The five layers are a background concern, so they stay gated on **both** the global master + * ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service" + * toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off + * survives restarts) **and** at least one account having opted in. A foreground-only account must + * never start a foreground service that outlives the screen that wanted it. + * + * Every saved writable account is kept loaded in [AccountCacheState] whenever either condition + * holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get + * unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to + * decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, private val scope: CoroutineScope, private val accountsCache: AccountCacheState, private val localPreferences: LocalPreferences, + private val subscriptions: AccountSubscriptionRegistry, + /** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */ + private val isForeground: StateFlow, private val activePubKeyProvider: () -> HexKey?, ) { companion object { @@ -98,55 +116,84 @@ class AlwaysOnNotificationServiceManager( wasEnabled = false watchJob = scope.launch { - localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> - if (!masterEnabled) { - // Global airplane mode: suppress every layer regardless of - // per-account participation, and stop keeping accounts loaded. - if (wasEnabled) { - disableServiceLayers() - wasEnabled = false - } - stopMultiAccountPreload() - return@collectLatest - } - - // Master on: keep every writable account loaded so its participation - // flag is observable and its gift wraps can decrypt, then run the - // service only while at least one account is participating. An account - // participates when its always-on setting OR its NIP-46 signer toggle is - // on — the background signer needs the same foreground service alive. - startMultiAccountPreload() - accountsCache.accounts - .flatMapLatest { accounts -> - val flags = - accounts.values.map { account -> - account.settings.alwaysOnNotificationService - .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer } - } - if (flags.isEmpty()) { - flowOf(false) - } else { - combine(flags) { values -> values.any { it } } - } - }.distinctUntilChanged() - .collectLatest { anyParticipating -> - if (anyParticipating) { - wasEnabled = true - enableServiceLayers() - } else if (wasEnabled) { + localPreferences + .notificationServiceEnabledFlow() + .combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground } + .collectLatest { (masterEnabled, foreground) -> + if (!masterEnabled && !foreground) { + // Nothing wants the accounts: the master is off and no screen is up. + // Suppress every layer and stop keeping accounts loaded. + if (wasEnabled) { disableServiceLayers() wasEnabled = false } + stopMultiAccountPreload() + return@collectLatest } - } + + // Keep every writable account loaded — in the foreground so they can all + // pull, and with the master on so participation flags are observable and + // gift wraps can decrypt. + startMultiAccountPreload() + + accountsAndParticipants() + .distinctUntilChanged() + .collectLatest { (all, participating) -> + // The rule the "keep this account active in the background" setting + // actually describes: while a screen is up, EVERY loaded account + // pulls its own notifications, DMs and gift wraps, because the user + // can switch to any of them and expects them current. The setting + // only decides which ones keep doing it once the app is away. + subscriptions.sync(if (foreground) all else participating) + + // The service layers are a background concern, so they stay tied to + // the master switch and to somebody having opted in. A foreground-only + // account must not start a foreground service that outlives the screen. + // + // Edge-triggered, deliberately. This flow re-emits whenever the account + // map changes identity or the app crosses foreground — far more often + // than the old boolean did — and ServiceWatchdogManager.schedule() + // replaces its alarm with one starting `now + 5min`. Calling it on every + // emission pushed the watchdog's first fire past every screen-on, so the + // layer that exists to restart a dead service would never have run. + val shouldRun = masterEnabled && participating.isNotEmpty() + if (shouldRun != wasEnabled) { + if (shouldRun) enableServiceLayers() else disableServiceLayers() + wasEnabled = shouldRun + } + } + } } } + /** + * Every loaded account paired with the subset that opted into running in the background. + * + * Both come from one flow because they change together and the two decisions below — who + * subscribes, and whether the service runs — must never be made from different snapshots. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun accountsAndParticipants(): Flow, List>> = + accountsCache.accounts.flatMapLatest { accounts -> + val all = accounts.values.toList() + val flags = + all.map { account -> + account.settings.alwaysOnNotificationService + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> + if (alwaysOn || signer) account else null + } + } + if (flags.isEmpty()) { + flowOf(all to emptyList()) + } else { + combine(flags) { values -> all to values.filterNotNull() } + } + } + fun stop() { watchJob?.cancel() watchJob = null - preloadJob?.cancel() - preloadJob = null + stopMultiAccountPreload() // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm // and periodic worker stay scheduled and would resurrect the service for a // logged-out user (nobody participating). @@ -211,10 +258,15 @@ class AlwaysOnNotificationServiceManager( * Cancels the preload collector and releases every cached account except the * currently active one, so users with the master off return to single-account * memory/battery footprint. + * + * Unmounts the background subscriptions too: with the master off, the only + * account that should be talking to relays is the one on screen, and its + * subscription comes from the screen's own mount. */ private fun stopMultiAccountPreload() { preloadJob?.cancel() preloadJob = null + subscriptions.clear() // remove this because we don't know which other accounts might be getting used. // val active = activePubKeyProvider() // if (active != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c2fa2eab2d..d90c812658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -36,6 +36,7 @@ import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R @@ -79,6 +80,10 @@ class NotificationRelayService : Service() { companion object { private const val TAG = "NotificationRelayService" private const val CHANNEL_ID = "notification_relay_service" + + /** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */ + private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs" + private const val NOTIFICATION_ID = 9832 private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" @@ -141,6 +146,9 @@ class NotificationRelayService : Service() { private var relayServiceCollectorJob: Job? = null private var connectedRelayCount = 0 + /** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */ + private var lastBreakdown: List = emptyList() + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -336,9 +344,12 @@ class NotificationRelayService : Service() { pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } + // Tapping goes to the screen that answers the question the notification raises — "why is it + // connected to N relays" — rather than to whatever tab was last open. val openAppIntent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + data = ACTIVE_SUBSCRIPTIONS_URI.toUri() } val pendingIntent = PendingIntent.getActivity( @@ -348,11 +359,44 @@ class NotificationRelayService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + // Expanded only. The collapsed line stays the bare count it has always been — that is all + // most people want from an ongoing notification — and the per-job breakdown appears solely + // when someone deliberately expands it to ask why the phone is talking to N relays. + // + // Held across reconnects rather than recomputed blindly: the breakdown is derived from the + // *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and + // the expanded view would collapse to a single line exactly when someone is most likely + // looking at it. What each connection is *for* does not change while it is re-establishing, + // so the last known answer is still the right one; only the count above it goes stale, and + // that count is already labelled "connecting". + val fresh = RelayPurposeSummary.lines(this) + if (fresh.isNotEmpty()) lastBreakdown = fresh + val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() } + + // Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it + // sits in the shade's Silent section next to the low-importance content kinds + // (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into + // one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that + // has it, making the whole bundle un-swipeable. Giving this one a group of its own + // would not help: a group with a summary but no children, or a child with no summary, + // is force-grouped just the same. What keeps content notifications out of that bundle + // is that they always post their own group summary (see NotificationUtils), which + // leaves this the only ungrouped silent notification we post — one is below the + // threshold, so no bundle is formed and nothing gets stapled to it. return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) - .setSmallIcon(R.drawable.amethyst_service) + .apply { + breakdown?.let { + setStyle( + NotificationCompat + .BigTextStyle() + .setBigContentTitle(getString(R.string.always_on_notif_title)) + .bigText(contentText + "\n\n" + it.joinToString("\n")), + ) + } + }.setSmallIcon(R.drawable.amethyst_service) .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ecfd14b6ad..26b2930f02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -54,13 +56,35 @@ class NotificationReplyReceiver : BroadcastReceiver() { intent: Intent, ) { val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0) + + // Whatever the action, the user is done with this notification, so record it before + // doing anything else. An enrichment window may still be open on the event (up to + // 25s from the first post), and it re-posts the notification every time metadata + // lands — without this the notification the user just dealt with comes back, and the + // enricher keeps a relay subscription and a wakelock alive for it until the window + // elapses. Replies mark it after the send succeeds instead, so a failure leaves the + // notification to enrich and retry. + val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID) + if (intent.action != NotificationUtils.REPLY_ACTION && + intent.action != NotificationUtils.PUBLIC_REPLY_ACTION && + intent.action != NotificationUtils.MARMOT_REPLY_ACTION + ) { + eventId?.let { NotificationUtils.markDismissed(it) } + } + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) as NotificationManager when (intent.action) { NotificationUtils.MARK_READ_ACTION -> { - notificationManager.cancel(notificationId) + notificationManager.cancelAndPrune(notificationId) + } + + // The user swiped the notification away. It is already gone; all that is left + // is to take its group summary with it when it was the last child. + NotificationUtils.DISMISS_ACTION -> { + notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId) } NotificationUtils.REPLY_ACTION -> { @@ -78,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { if (members.isEmpty()) return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendReply(accountNpub, members, replyText) } } @@ -95,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendPublicReply(accountNpub, targetEventId, replyText) } } @@ -114,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID) val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR) - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText) } } @@ -124,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { private fun runOnRelay( notificationManager: NotificationManager, notificationId: Int, + eventId: String?, block: suspend () -> Unit, ) { val pendingResult = goAsync() @@ -138,7 +163,8 @@ class NotificationReplyReceiver : BroadcastReceiver() { try { block() - notificationManager.cancel(notificationId) + eventId?.let { NotificationUtils.markDismissed(it) } + notificationManager.cancelAndPrune(notificationId) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } @@ -189,7 +215,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { persistOwn = false, ) - account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId)) + account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId)) } private suspend fun sendPublicReply( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a1879a70f4..6d4791386c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -70,8 +70,16 @@ object NotificationUtils { const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" + const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" + + /** + * Hex id of the event this notification was posted for, carried on every action + * and on the delete intent so the receiver can mark it dismissed. Distinct from + * [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to. + */ + const val KEY_EVENT_ID = "key_event_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" const val KEY_TARGET_EVENT_ID = "key_target_event_id" @@ -82,16 +90,27 @@ object NotificationUtils { const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" private const val REPLY_SUMMARY_ID_BASE = 0x50000 - // Event ids the user has just read/dismissed in-app. The enrichment path - // re-posts a notification as metadata arrives; without this guard a - // notification the user already dismissed would be resurrected seconds later - // when its author's kind:0 lands. Keyed by the event id string (not the - // hashCode) so distinct events can't collide. Entries self-expire after a - // window comfortably longer than the 25s enrichment window. + /** + * Every group key this object posts under starts with this. Used to tell our own + * summaries apart from the ones the system creates when it force-groups us (those + * live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the + * platform over a bundle it owns. + */ + private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst." + + // Event ids the user is done with. The enrichment path re-posts a notification + // as metadata arrives; without this guard a notification the user already got + // rid of would be resurrected seconds later when its author's kind:0 lands, and + // the enricher would go on holding a relay window and a wakelock open for it. + // Every way a user can be done with a notification has to record here — reading + // the event in-app, swiping the notification away, "mark as read", and replying + // inline — or that path leaks the resurrection. Keyed by the event id string + // (not the hashCode) so distinct events can't collide. Entries self-expire after + // a window comfortably longer than the 25s enrichment window. private const val DISMISS_GUARD_MS = 90_000L private val recentlyDismissed = ConcurrentHashMap() - private fun markDismissed(eventId: String) { + fun markDismissed(eventId: String) { val now = SystemClock.elapsedRealtime() recentlyDismissed[eventId] = now + DISMISS_GUARD_MS if (recentlyDismissed.size > 256) { @@ -218,6 +237,7 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) @@ -236,11 +256,11 @@ object NotificationUtils { } if (inlineReply != null) { - builder.addAction(publicReplyAction(applicationContext, notId, inlineReply)) + builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply)) } notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -335,20 +355,21 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) when (replyAction) { - is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction)) - is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction)) - null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) } + is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction)) + is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction)) + null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) } } - if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId)) + if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id)) notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -370,6 +391,38 @@ object NotificationUtils { ) } + /** + * Fires when the user swipes this notification away (or hits "Clear all"), so the + * group summary can follow its last child out. + * + * We can't rely on the shade to take the summary with it: SystemUI hides a group + * with a single child and renders that child at the top level + * (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no + * longer counts as "the only child in its group", so dismissing it leaves our + * summary behind. A childless summary is not harmless — SystemUI promotes it into + * the shade on its own, and the system force-groups it + * (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we + * post summaries to stay out of. + */ + private fun dismissIntent( + applicationContext: Context, + notId: Int, + eventId: String, + ): PendingIntent { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = DISMISS_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) + } + return PendingIntent.getBroadcast( + applicationContext, + notId + 2, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + private fun replyRemoteInput(applicationContext: Context): RemoteInput = RemoteInput .Builder(KEY_REPLY_TEXT) @@ -399,12 +452,14 @@ object NotificationUtils { private fun dmReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Dm, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers) } @@ -414,12 +469,14 @@ object NotificationUtils { private fun marmotReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Marmot, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = MARMOT_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId) action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) } @@ -431,12 +488,14 @@ object NotificationUtils { private fun publicReplyAction( applicationContext: Context, notId: Int, + eventId: String, target: InlineReplyTarget, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = PUBLIC_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, target.accountNpub) putExtra(KEY_TARGET_EVENT_ID, target.targetEventId) } @@ -446,11 +505,13 @@ object NotificationUtils { private fun markReadAction( applicationContext: Context, notId: Int, + eventId: String, ): NotificationCompat.Action { val markReadIntent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = MARK_READ_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) } val markReadPendingIntent = PendingIntent.getBroadcast( @@ -549,16 +610,33 @@ object NotificationUtils { // Group summaries, dedup, dismissal // --------------------------------------------------------------------- + /** + * Posts (or refreshes) our own summary for [groupKey]. + * + * The summary goes up with the **first** child, not once a second one shows up. + * Android 16 counts a group child whose summary is missing as ungrouped + * (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the + * package's per-section aggregate bundle, next to every other ungrouped + * notification in the same shade section. The bar is low: `config_autoGroupAtCount` + * is 2, so a single summary-less child plus one other ungrouped notification is a + * bundle. The always-on relay service is exactly that other notification — ongoing + * and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW + * kinds (reactions and reposts), so one lone repost would end up bundled with it. + * The bundle then refuses to swipe away, because the system's aggregate summary + * inherits FLAG_ONGOING_EVENT from any child carrying it — and the service + * notification always does. + * + * Providing the summary from the start keeps the group ours and the system leaves + * it alone. It costs nothing visually: the shade hides any group with fewer than + * two children and shows the child on its own. + */ private fun NotificationManager.sendGroupSummary( category: NotificationCategory, groupKey: String, summaryId: Int, + time: Long, applicationContext: Context, ) { - val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } - - if (activeCount < 2) return - val summaryBuilder = NotificationCompat .Builder(applicationContext, category.channelId(applicationContext)) @@ -566,8 +644,16 @@ object NotificationUtils { .setColor(category.color) .setGroup(groupKey) .setGroupSummary(true) + // The children do the alerting. Without this the summary would buzz on + // its own the moment it starts going up alongside the first child. + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .setOnlyAlertOnce(true) + // Pinned to the child's event time rather than left to default to "now". + // The summary is re-posted on every one of the enrichment path's re-renders, + // and a fresh timestamp each time would keep re-sorting the group in the + // shade while the user is looking at it. + .setWhen(time * 1000) .setStyle( NotificationCompat .InboxStyle() @@ -604,17 +690,53 @@ object NotificationUtils { // items), so bail out before touching anything when nothing is posted for it. if (activeNotifications.none { it.id == notId }) return - cancel(notId) - cancelChildlessGroupSummaries() + cancelAndPrune(notId) } - private fun NotificationManager.cancelChildlessGroupSummaries() { + /** + * Cancels [notId] and drops the group summary it leaves behind, if it was the last + * child. Use this instead of a bare [NotificationManager.cancel] for anything we + * posted through [postStandard] / [postConversation] — every one of those is a + * group child with a summary above it. + */ + fun NotificationManager.cancelAndPrune(notId: Int) { + cancel(notId) + cancelChildlessGroupSummaries(alreadyGone = notId) + } + + /** + * Drops our summaries that no longer have any children. + * + * [alreadyGone] is the id of a notification cancelled moments ago: both + * [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous, + * so [NotificationManager.activeNotifications] can still be listing it and would + * otherwise keep its summary alive forever. + * + * Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate + * summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one + * only makes the platform rebuild it. + */ + fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) { val active: Array = activeNotifications + + // Collect the groups that still have a child in one pass, then cancel the + // summaries not in that set. Every child now ships with a summary, so this list + // is about twice as long as it used to be and the pairwise scan it replaces grew + // four-fold. Membership is decided by the summary flag rather than by comparing + // ids, which is also what makes it correct when a child's id happens to equal the + // summary's. + val groupsWithChildren = HashSet(active.size) + for (child in active) { + if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue + if (child.id == alreadyGone) continue + child.notification.group?.let { groupsWithChildren.add(it) } + } + for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue - val hasChildren = active.any { it.id != summary.id && it.notification.group == group } - if (!hasChildren) cancel(summary.id) + if (!group.startsWith(OWN_GROUP_PREFIX)) continue + if (group !in groupsWithChildren) cancel(summary.id) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt new file mode 100644 index 0000000000..5b71abddbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt @@ -0,0 +1,84 @@ +/* + * 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.service.notifications + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The per-job breakdown behind the always-on notification's relay count. + * + * **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because + * that is all most people ever want from an ongoing notification. This is for the moment someone + * taps to ask *why* their phone is talking to 40 relays. + * + * A relay usually serves several jobs at once (measured: a typical relay carries four), so these + * counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays + * carry my DMs", not "how is the pool partitioned" — there is no partition. + * + * Purposes with no label — feeds and whatever screen is open — collapse into one "browsing" line. + * Those disappear on their own once the app is backgrounded, which is exactly when this notification + * matters most, so spelling them out would add noise precisely when the user is least interested. + */ +object RelayPurposeSummary { + /** + * Lines for the expanded notification, busiest first. Empty when nothing is attributed yet — + * the caller must then fall back to the bare count rather than render an empty section. + */ + fun lines(ctx: Context): List { + val client = Amethyst.instance.client + val named = mutableMapOf>() + val browsing = mutableSetOf() + + client.connectedRelaysFlow().value.forEach { relay -> + client + .activeRequests(relay) + .values + .flatten() + .purposes() + .forEach { purpose -> + if (SubPurposeLabels.isWorthNamingInNotification(purpose)) { + named.getOrPut(purpose) { mutableSetOf() }.add(relay) + } else { + browsing.add(relay) + } + } + } + + val lines = + named.entries + .sortedWith(compareByDescending>> { it.value.size }.thenBy { it.key.name }) + .map { (purpose, relays) -> + pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size) + }.toMutableList() + + if (browsing.isNotEmpty()) { + lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size)) + } + return lines + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt new file mode 100644 index 0000000000..3d6bc3fc0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt @@ -0,0 +1,39 @@ +/* + * 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.service.relayClient + +import com.vitorpamplona.amethyst.model.Account + +/** + * A subscription query state that belongs to one logged-in account. + * + * Around 66 query-state classes already carry an `account`, but nothing tied them together, so a + * subscription manager could not ask "whose subscription is this?" without knowing the concrete + * type. That is why the Active Relay Subscriptions screen filed the home feed under "not attributed" + * despite it being built from one specific person's follow list: the base manager checked for + * `AccountQueryState` and the home feed uses `HomeQueryState`. + * + * Implement this on any query state whose subscriptions belong to a single account, and the base + * managers attribute them automatically. + */ +interface AccountScopedQuery { + val account: Account +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 995870ce21..b7283b37d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -49,6 +50,14 @@ class RelayProxyClientConnector( val torStatus: StateFlow, val client: INostrClient, val scope: CoroutineScope, + /** + * Called with the cause every time this connector *decides* to reconnect, so the + * usage ledger can attribute relay churn without this class knowing where the + * counters go. Causes are the `UsageKeys.TRIGGER_*` constants — see + * [UsageKeys.relayTrigger] for why these are an upper bound rather than a count + * of reconnects actually performed. + */ + val onTrigger: (String) -> Unit = {}, ) { data class RelayServiceInfra( val evaluator: TorRelayEvaluation, @@ -138,6 +147,9 @@ class RelayProxyClientConnector( infra.connectivity is ConnectivityStatus.Off -> { Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" } if (client.isActive()) { + // Counted inside the guard: the upstream combine() re-emits Off + // repeatedly and only this branch does any work. + onTrigger(UsageKeys.TRIGGER_OFF) client.disconnect() } if (infra.torStatus is TorServiceStatus.Active) { @@ -156,6 +168,7 @@ class RelayProxyClientConnector( } // only calls this if the client is not active. Otherwise goes to the else below + onTrigger(UsageKeys.TRIGGER_COLD_START) client.connect() lastNetworkId = networkId lastTorSettings = torSettings @@ -211,10 +224,26 @@ class RelayProxyClientConnector( Log.d("ManageRelayServices") { "Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection" } + // The expensive branch, and the one the churn investigation is + // aimed at: a full teardown re-dials the whole pool and replays + // every REQ. + // + // Only this cause is counted here, so a wifi<->cellular handoff — + // which mints a new network handle AND rebuilds the OkHttp clients + // off the metered bit — is booked as netid alone. relay.trigger.transport + // therefore undercounts exactly the case one would most want it for; + // read it as "transport changed WITHOUT the handle changing". + onTrigger(UsageKeys.TRIGGER_NETID) // Full teardown: disconnect() drops the dead sockets AND clears each // relay's backoff, so the new network starts from a clean slate. client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) } else { + // Non-exclusive: count each independently so the report shows + // which combination fired. + if (transportChanged) onTrigger(UsageKeys.TRIGGER_TRANSPORT) + if (torPolicyChanged) onTrigger(UsageKeys.TRIGGER_TOR_POLICY) + if (classificationChanged) onTrigger(UsageKeys.TRIGGER_CLASSIFICATION) + val freshStart = transportChanged || torPolicyChanged if (freshStart) { // The failures behind the current backoffs were measured against a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt index 1966bac786..054f6f855e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt @@ -158,7 +158,7 @@ class BootRelayDiagnostics( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 4aefae6ba6..07f13fbd0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger( Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt new file mode 100644 index 0000000000..c76800af32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt @@ -0,0 +1,46 @@ +/* + * 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.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account + * attribution for [AccountScopedQuery] keys. + * + * The commons base is account-agnostic (attribution defaults to null) so it can live in + * commonMain. Query states that carry an [Account] (home feed, channels, notifications, …) + * subclass this so their single-account REQs still show up attributed in "Active Relay + * Subscriptions". + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ +abstract class AccountScopedSingleSubNoEoseCacheEoseManager( + client: INostrClient, + allKeys: () -> Set, + invalidateAfterEose: Boolean = false, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose) { + override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 2817e2a39d..61c297887e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEByKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -76,7 +78,7 @@ abstract class PerUniqueIdEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -114,7 +116,13 @@ abstract class PerUniqueIdEoseManager( uniqueSubscribedAccounts.forEach { val mainKey = id(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) updated.add(mainKey) @@ -131,4 +139,13 @@ abstract class PerUniqueIdEoseManager( ): List? abstract fun id(key: T): U + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index 80bce3f6fa..7f53cbd0aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -88,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -127,7 +129,13 @@ abstract class PerUserAndFollowListEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) val sub = findOrCreateSubFor(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } sub.updateFilters(newFilters?.groupByRelay()) updated.add(user) } @@ -145,4 +153,13 @@ abstract class PerUserAndFollowListEoseManager( abstract fun user(key: T): User abstract fun list(key: T): U + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index ad899cd2c1..c89d53a6e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -75,7 +77,7 @@ abstract class PerUserEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -113,7 +115,13 @@ abstract class PerUserEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) @@ -131,4 +139,13 @@ abstract class PerUserEoseManager( ): List? abstract fun user(key: T): User + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 92b40d456e..c7e146b5bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -78,7 +78,7 @@ class NotifyCoordinator( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 653878cd50..e14b2ad2ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler @@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssembler @@ -168,6 +168,9 @@ class RelaySubscriptionsCoordinator( val chess = ChessFilterAssembler(client) val polls = PollsFilterAssembler(client) + + // Votes for the poll whose results screen is open. + val pollResponses = PollResponsesFilterAssembler(client) val pictures = PicturesFilterAssembler(client) val workouts = WorkoutsFilterAssembler(client) val gitRepositories = GitRepositoriesFilterAssembler(client) @@ -204,10 +207,6 @@ class RelaySubscriptionsCoordinator( // active when the wallet's on-chain transactions screen is on top. val onchainZaps = OnchainZapsFilterAssembler(client) - // active when a NIP-60 Cashu wallet exists for the account. - // Subscribes to kinds 17375/7375/7376/7374/10019 by author + inbound 9321 #p=self. - val cashuWallet = CashuWalletFilterAssembler(client) - // active while the user is browsing the NIP-87 mint picker. Subscribes to // kind:38172 cashu mint announcements + kind:38000 cashu-scoped // recommendations on the configured relay set. @@ -234,6 +233,7 @@ class RelaySubscriptionsCoordinator( video, discovery, polls, + pollResponses, pictures, workouts, gitRepositories, @@ -279,7 +279,6 @@ class RelaySubscriptionsCoordinator( chess, nwc, onchainZaps, - cashuWallet, cashuMintDirectory, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index f049140194..a5f530da31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager @@ -31,17 +32,48 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01No import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to logged-in accounts. +// +// Carries only what an account can supply with no screen attached, so the +// background registry can mount the always-on loaders for accounts the user +// opted into keeping active while the app is away. Screens use the richer +// [AccountUiQueryState] below. @Stable -class AccountQueryState( - val account: Account, - val feedContentStates: AccountFeedContentStates, +open class AccountQueryState( + override val account: Account, val otherAccounts: Set, -) +) : AccountScopedQuery { + /** + * The feeds this account renders, when a screen is attached — null for + * background accounts. + * + * The only always-on reader is + * [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager], + * which reads it as a cold-start `since` floor via `lastNoteCreatedAtIfFilled()`. + * That floor only arms once the feed holds a full page, and nothing fills a + * feed that has no UI — so for a background account it could only ever + * return null anyway. Leaving the field off the background key states that + * rather than pretending there is a feed to consult. + */ + open val feedContentStates: AccountFeedContentStates? = null +} + +/** + * The key for an account with a screen attached. Adds the feed states, which + * lets the notification loaders floor their cold-start queries at the depth the + * rendered feed already reaches instead of asking all-time again. + */ +@Stable +class AccountUiQueryState( + account: Account, + override val feedContentStates: AccountFeedContentStates, + otherAccounts: Set, +) : AccountQueryState(account, otherAccounts) /** * Always-on account loaders: metadata, gift wraps, drafts, inbox-relay @@ -53,30 +85,51 @@ class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { // Live tail: the recent week of gift wraps, always open at the top for new messages. - val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + val giftWraps = AccountGiftWrapsEoseManager(client, ::preferredKeys) // History: older gift wraps, loaded on demand in bounded one-shot slices. - val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::preferredKeys) // Live tail: the recent week of notifications from the inbox + group host relays. - val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys) + val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::preferredKeys) // History: older notifications, paged backward by until+limit per relay, driven by the feed's markers. - val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys) + val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys) val group = listOf( - AccountMetadataEoseManager(client, ::allKeys), + AccountMetadataEoseManager(client, ::preferredKeys), giftWraps, giftWrapsHistory, - AccountDraftsEoseManager(client, ::allKeys), + AccountDraftsEoseManager(client, ::preferredKeys), notifications, notificationsHistory, // Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay. - NwcNotificationsEoseManager(client, ::allKeys), - MarmotGroupEventsEoseManager(client, ::allKeys), + NwcNotificationsEoseManager(client, ::preferredKeys), + // NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector + // inside CashuWalletState, so it starts and stops with every other account-level loader. + CashuWalletEoseManager(client, ::preferredKeys), + MarmotGroupEventsEoseManager(client, ::preferredKeys), ) + /** + * One key per account, preferring a screen's [AccountUiQueryState] over the + * background registry's key. + * + * An account can be mounted twice — the user is looking at it *and* asked to keep + * it running in the background. The managers below all dedup by user, but by + * keeping whichever key they meet first, which is just whoever mounted first. + * Resolving it here means the account being looked at keeps the feed-backed + * cold-start floor instead of losing it to a race. + */ + private fun preferredKeys(): Set = + allKeys() + .groupBy { it.account.userProfile().pubkeyHex } + .values + .mapTo(mutableSetOf()) { keys -> + keys.firstOrNull { it.feedContentStates != null } ?: keys.first() + } + override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt index 6af8b6d74a..0af31be8c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -41,7 +41,7 @@ fun AccountFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) + AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt index 0678be343b..a6d9109bff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt @@ -45,7 +45,7 @@ class AccountForegroundFilterAssembler( authenticator: IAuthStatus, failureTracker: RelayOfflineTracker, scope: CoroutineScope, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt index 8938fa0ea0..cc3614e05d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt @@ -39,7 +39,7 @@ fun AccountForegroundFilterAssemblerSubscription( ) { val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) + AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt new file mode 100644 index 0000000000..96aabf0ef3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt @@ -0,0 +1,100 @@ +/* + * 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.service.relayClient.reqCommand.account + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * Mounts the account-level loaders — notifications, DMs, gift wraps, drafts, + * metadata — for accounts that have no screen of their own. + * + * Every other mount of [AccountFilterAssembler] comes from a composable holding an + * `AccountViewModel`, which means it only ever covers the account the user is + * looking at. Every other logged-in account was merely resident in memory so pushed + * gift wraps could be decrypted by their owner; everything else about them depended + * on a push message arriving. On a device with no push (no Play Services, no + * UnifiedPush distributor, Pokey not installed) they pulled nothing at all. + * + * This registry is the pull side. It holds one [AccountQueryState] per account it is + * given and drives [AccountFilterAssembler.subscribe] / + * [AccountFilterAssembler.unsubscribe] directly — those are plain functions, not + * composables, so no UI has to exist. + * + * The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no + * `otherAccounts` — populating the account switcher's avatars is a screen's job, and + * these accounts have no screen. + * + * It deliberately decides nothing about *which* accounts those are: it mounts exactly + * the set it is handed, so the foreground/background rule lives in one place, in + * [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager], + * which already watches the switches that define it and calls [sync] on every change. + */ +class AccountSubscriptionRegistry( + private val assembler: AccountFilterAssembler, +) { + companion object { + private const val TAG = "AccountSubscriptions" + } + + private val mounted = mutableMapOf() + + /** + * Makes the mounted set match [accounts] exactly: subscribes the ones that just + * joined it, unsubscribes the ones that left or were unloaded, and leaves the + * rest untouched. + * + * Idempotent, so callers can hand it the same set on every emission of the flows + * behind it without churning subscriptions. + */ + @Synchronized + fun sync(accounts: Collection) { + val wanted = accounts.associateBy { it.userProfile().pubkeyHex } + + // Unmount accounts that dropped out, and accounts whose Account object was + // replaced (re-login rebuilds it) — the stale instance holds the old + // signer and relay lists, so its filters would be built from dead state. + val stale = mounted.filter { (pubkey, state) -> wanted[pubkey] !== state.account } + stale.forEach { (pubkey, state) -> + mounted.remove(pubkey) + assembler.unsubscribe(state) + } + + var added = 0 + wanted.forEach { (pubkey, account) -> + if (pubkey !in mounted) { + val state = AccountQueryState(account, emptySet()) + mounted[pubkey] = state + assembler.subscribe(state) + added++ + } + } + + if (added > 0 || stale.isNotEmpty()) { + Log.d(TAG) { "Account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } + } + } + + /** Unmounts everything. Used when the app goes away with the master off, and on logout. */ + @Synchronized + fun clear() = sync(emptyList()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index e4525e9537..8ee1043610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,7 +43,8 @@ fun filterDraftsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, kinds = DraftKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 5a88cff00b..dcda63f127 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.BundledUpdate -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -60,7 +62,7 @@ class AccountFollowsLoaderSubAssembler( val scope: CoroutineScope, val authStatus: IAuthStatus, val failureTracker: RelayOfflineTracker, - val allKeys: () -> Set, + val allKeys: () -> Set, ) : IEoseManager { private val logTag = "AccountFollowsLoaderSubAssembler" private val orchestrator = SubscriptionController(client) @@ -114,7 +116,7 @@ class AccountFollowsLoaderSubAssembler( newEose(TimeUtils.now(), relay, forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -157,6 +159,15 @@ class AccountFollowsLoaderSubAssembler( val connectedRelays = client.connectedRelaysFlow().value + // Attributed only when one account is asking. The follow lists above are unioned across every + // logged-in account and the relays are then picked from that union, so with several accounts + // active no single one owns a given filter. Deduped by pubkey because `Account` uses identity + // equality. + val soleAccountPubKey = + accounts + .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .singleOrNull() + val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried) hasTried.removeEveryoneBut(users) @@ -165,7 +176,13 @@ class AccountFollowsLoaderSubAssembler( if (users.isNotEmpty()) { RelayBasedFilter( relay = relay, - filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()), + filter = + ExplainedFilter( + purpose = SubPurpose.RELAY_LISTS, + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = users.sorted(), + accountPubKeys = listOfNotNull(soleAccountPubKey), + ), ) } else { null @@ -173,7 +190,7 @@ class AccountFollowsLoaderSubAssembler( } } - fun updateSubscriptions(keys: Set) { + fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account } val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index 4d3ed060cd..f466cb4f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.mapOfSet fun pickRelaysToLoadUsers( users: Set, @@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers( return pickRelaysToLoadUsers( users, + LocalCache.relayHints, indexRelays - cannotConnectRelays, homeRelays - cannotConnectRelays, searchRelays - cannotConnectRelays, @@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers( hasTried, ) } - -fun pickRelaysToLoadUsers( - users: Set, - indexRelays: Set, - homeRelays: Set, - searchRelays: Set, - connected: Set, - commonRelays: Set, - cannotConnectRelays: Set, - hasTried: EOSEAccountFast, -): Map> = - mapOfSet { - users.forEachIndexed { _, key -> - val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays - - val outbox = key.authorRelayList()?.writeRelaysNorm() - - if (!outbox.isNullOrEmpty()) { - // If there is a home, get from it. - - // if it tried all outbox relays, stop. - // the UserWatch will take over from here. - val leftToTry = (outbox - tried) - leftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - // if not, tries hints first. - val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) - - val leftToTryOnHints = hints - tried - - leftToTryOnHints.forEach { - add(it, key.pubkeyHex) - } - - // if there are only a few hints, broadens the search - if (leftToTryOnHints.size < 3) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val indexRelaysLeftToTry = - (indexRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val homeRelaysLeftToTry = - (homeRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - if (indexRelaysLeftToTry.size >= 2) { - add(indexRelaysLeftToTry[0], key.pubkeyHex) - add(indexRelaysLeftToTry[1], key.pubkeyHex) - } else if (indexRelaysLeftToTry.size == 1) { - add(indexRelaysLeftToTry.first(), key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - indexRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (indexRelaysLeftToTry.size < 2) { - val searchRelaysLeftToTry = searchRelays - tried - - searchRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - val connectedRelaysLeftToTry = - (connected - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - connectedRelaysLeftToTry.take(20).forEach { - add(it, key.pubkeyHex) - } - } else { - connectedRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (searchRelaysLeftToTry.size < 2) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val allRelaysLeftToTry = - (commonRelays - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - allRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - } - } - } - } - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 4dc904d02e..f81c301100 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver @@ -89,7 +91,7 @@ class MarmotGroupEventsEoseManager( "(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}" } for (relay in relaysForGroup) { - result.add(RelayBasedFilter(relay = relay, filter = filter)) + result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages", entityIds = listOf(groupId)))) } } @@ -97,7 +99,7 @@ class MarmotGroupEventsEoseManager( if (fallbackRelays.isNotEmpty()) { val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter() for (relay in fallbackRelays) { - result.add(RelayBasedFilter(relay = relay, filter = ownKeyPackageFilter)) + result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(ownKeyPackageFilter, SubPurpose.ENCRYPTED_GROUPS, "own MLS key package"))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index db90fd8b7c..283ee421e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -20,66 +20,110 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Each account's own profile, lists and recent posts — for **every** logged-in account, in one + * subscription. + * + * Every filter here is `authors`-keyed, so a relay that several accounts read from can be asked + * about all of them at once by widening `authors` rather than opening a REQ per account. This was + * the largest single contributor to blowing a relay's `max_subscriptions`: seven filters in a + * subscription, repeated once per account. + * + * The per-account `limit`s are summed rather than shared. These are mostly replaceable events, so + * the limit is a safety bound rather than a page size, and scaling it by the number of accounts + * keeps each one exactly the headroom it had alone. + */ class AccountMetadataEoseManager( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() +) : SingleSubEoseManager(client, allKeys) { + override fun distinct(key: AccountQueryState) = key.account.userProfile() fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow override fun updateFilter( - key: AccountQueryState, + keys: List, since: SincePerRelayMap?, - ): List = - relayFlow(key).value.flatMap { - val since = since?.get(it)?.time - listOf( - filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), - filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), - filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since), - filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()), - filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), - ).flatten() + ): List { + val accountsPerRelay = mutableMapOf>() + keys.forEach { key -> + relayFlow(key).value.forEach { relay -> + accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key) + } } - val userJobMap = mutableMapOf>() + return accountsPerRelay.flatMap { (relay, accounts) -> + val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + + // An account joining a relay this subscription already covers must not inherit the cursor + // the earlier accounts earned — it would never ask for its own profile, follows or lists. + // This matters more here than for notifications: there is no backward pager to rescue it, + // so the account would go without until the next launch cleared the in-memory cursor. + // Drop the stored cursor AND ignore it for this pass, so the refetch does not depend on + // `since` being a live view of the map we just mutated. + val gained = authorsPerRelay.gainedAuthors(relay, pubkeys) + if (gained) clearEoseFor(relay) + + val relaySince = if (gained) null else since?.get(relay)?.time + + // The account-switcher avatars: other logged-in accounts this screen wants to name. + // Screens supply them; the background registry does not, so this is usually empty. + val otherAccounts = accounts.flatMapTo(mutableSetOf()) { it.otherAccounts }.minus(pubkeys.toSet()) - @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = listOf( - key.account.scope.launch(Dispatchers.IO) { - relayFlow(key).collectLatest { - invalidateFilters() - } - }, - ) - - return super.newSub(key) + filterAccountInfoAndListsFromKey(relay, pubkeys, relaySince), + filterFollowsAndMutesFromKey(relay, pubkeys, relaySince), + filterBookmarksAndReportsFromKey(relay, pubkeys, relaySince), + filterLastPostsFromKey(relay, pubkeys, relaySince ?: TimeUtils.oneMonthAgo()), + filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince, pubkeys), + ).flatten() + } } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + private val authorsPerRelay = MergedAuthorTracker() + + /** Per-account relay watchers, reconciled as accounts come and go. See the notifications manager. */ + private val userJobMap = mutableMapOf>() + + override fun updateSubscriptions(keys: Set) { + val wanted = keys.associateBy { it.account.userProfile() } + + (userJobMap.keys - wanted.keys).toList().forEach { user -> + userJobMap.remove(user)?.forEach { it.cancel() } + } + + wanted.forEach { (user, key) -> + if (user !in userJobMap) { + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + relayFlow(key).collectLatest { invalidateFilters() } + }, + ) + } + } + + super.updateSubscriptions(keys) + } + + override fun destroy() { + authorsPerRelay.clear() + userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } + userJobMap.clear() + super.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index f45feb7d97..087e2cdeee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -108,29 +109,33 @@ val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) fun filterAccountInfoAndListsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AccountInfoAndListsFromKeyKinds, - authors = listOf(pubkey), - limit = 20, + authors = pubkeys, + limit = 20 * pubkeys.size, since = since, ), ), RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AccountInfoAndListsFromKeyKinds2, - authors = listOf(pubkey), - limit = 80, + authors = pubkeys, + limit = 80 * pubkeys.size, since = since, ), ), @@ -138,17 +143,19 @@ fun filterAccountInfoAndListsFromKey( // Addressable — one card per target user — hence its own larger-limit filter. filterContactCardsByAuthorInTheRelay( relay = relay, - author = pubkey, + authors = pubkeys, since = since, ), RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AmethystMetadataKinds, - authors = listOf(pubkey), + authors = pubkeys, tags = AmethystMetadataTagMapFilter, - limit = 1, + limit = pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 74fee9cd74..d265402ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -78,6 +79,12 @@ fun filterBasicAccountInfoFromKeys( relay: NormalizedRelayUrl, otherAccounts: List?, since: Long?, + /** + * Who wants these profiles. The `authors` here are OTHER people — the account-switcher's + * avatars — so unlike every other builder in this package the authors are NOT the accounts + * asking, and attribution has to be passed in or the row reads as unattributed. + */ + requestedBy: List, ): List { if (otherAccounts.isNullOrEmpty()) return emptyList() @@ -85,7 +92,9 @@ fun filterBasicAccountInfoFromKeys( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = requestedBy, kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds.size, @@ -95,7 +104,9 @@ fun filterBasicAccountInfoFromKeys( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = requestedBy, kinds = BasicAccountInfoKinds2, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds2.size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 1dbe220f80..69b00aeea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -43,18 +44,20 @@ val ReportsAndBookmarksFromKeyKinds = fun filterBookmarksAndReportsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = ReportsAndBookmarksFromKeyKinds, - authors = listOf(pubkey), + authors = pubkeys, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index 43051262b8..c3e957a1bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -47,19 +48,21 @@ val FollowAndMutesFromKeyKinds = fun filterFollowsAndMutesFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = FollowAndMutesFromKeyKinds, - authors = listOf(pubkey), - limit = 100, + authors = pubkeys, + limit = 100 * pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index a713795e90..4f9ca34a34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -20,25 +20,28 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterLastPostsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( - authors = listOf(pubkey), - limit = 100, + ExplainedFilter( + purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, + authors = pubkeys, + limit = 100 * pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index c73b0dd9c0..e01043fe6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,96 +35,162 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch +/** + * The live notification tail, for **every** logged-in account, in one subscription. + * + * Notifications are `#p`-scoped, so a relay that serves several of the user's accounts can be asked + * about all of them in one filter naming every pubkey — the query is identical in shape, only wider. + * That is what keeps this within a relay's `max_subscriptions`: one REQ per relay instead of one per + * (account, relay). With four accounts open, the per-account form pushed strfry relays past their + * 20-subscription cap and they answered `ERROR: too many concurrent REQs` — a NOTICE, carrying no + * subscription id, so the client could not even tell which REQ had been dropped. Those filters stayed + * "live" in our books and silently never delivered. + * + * Gift wraps deliberately do **not** merge this way. They are unsolicited and their content is opaque + * to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed + * account consume the shared `limit` and starve every other account's DMs. Each account keeps its own + * budget there — see [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager]. + * + * ## The `limit` here is shared, and deliberately not scaled + * + * [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager] + * multiplies its limits by the number of merged accounts; this one does not, and the difference is the + * kind of event. Metadata is replaceable, so the limit is a safety bound and widening it costs nothing. + * Notifications are a stream, so the limit is a page size: scaling it by four accounts would ask four + * times the data of every relay on every cold start, and most would clamp it anyway (`max_limit` is 500 + * on strfry — already below the 2000 the summary filter asks for). + * + * So on a cold start a busy account can crowd a quiet one out of the shared newest-N. What recovers it + * is [AccountNotificationsHistoryEoseManager], which pages backward per account and stays unmerged for + * exactly that reason. + */ class AccountNotificationsEoseFromInboxRelaysManager( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() +) : SingleSubEoseManager(client, allKeys) { + override fun distinct(key: AccountQueryState) = key.account.userProfile() /** - * Downloads most notifications from the user's own inbox relays. - * But also connects to all the follows relays to check for new notifications that are not in the user's - * own inbox. + * One filter set per inbox relay, naming every account that reads from it. + * + * The `since` floor is per relay rather than per account, because the merged filter is per relay: + * the **oldest** of the participating accounts' floors wins, so widening the query for one account + * can never cut another one short. */ override fun updateFilter( - key: AccountQueryState, + keys: List, since: SincePerRelayMap?, ): List { - // Backward-paging boundary: once the feed has filled a page, ask for everything older than - // its oldest card. It stays null until then — see the note on the missing week floor below, - // which is what let it stay null forever on a quiet inbox. - val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() - - val inbox = - key.account.notificationRelays.flow.value.flatMap { - // No `since` floor on the first fetch. These filters are scoped by `#p` to my own - // key and carry a relay-side `limit`, so an all-time query costs one index scan and - // returns at most `limit` events, newest first — exactly what Home does (it passes - // `since ?: boundary`, i.e. null on a cold start). - // - // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for - // anyone whose last mention was older than a week: EOSE `since` is in-memory only, - // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above - // could never rescue it — it only arms once the feed holds a full page, and the feed - // could not fill because the query only ever asked for a week. A fresh install of an - // established account hit the same deadlock. - val notificationSince = since?.get(it)?.time ?: pagingBoundary - - filterSummaryNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + - filterNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + val accountsPerRelay = mutableMapOf>() + keys.forEach { key -> + key.account.notificationRelays.flow.value.forEach { relay -> + accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key) } + } - // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here. - // It lives on the group's host relay and used to be one `#h` filter per relay carrying every - // joined group id — but this subscription also carries the inbox filters above, which have no - // `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi- - // channel) filter to "global", a class that by design never receives channel-scoped events. So - // those filters answered the stored query at EOSE and then went deaf until the next launch. - // - // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to - // exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler, - // both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live. - return inbox + return accountsPerRelay.flatMap { (relay, accounts) -> + val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + + // An account that joins a relay this subscription already covers would otherwise inherit + // the cursor the earlier accounts earned, and never ask for anything older than it. Drop + // the stored cursor AND ignore it for this pass — not just the former, which would leave + // the refetch depending on `since` being a live view of the map we just mutated. + val gained = authorsPerRelay.gainedAuthors(relay, pubkeys) + if (gained) clearEoseFor(relay) + + // A cold-start floor, NOT paging — backward paging lives in + // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it + // does, the EOSE time wins and this is never consulted. + // + // `since` means *newer than*, so this floors the query at the depth the feed already reaches + // rather than asking all-time again. It stays null until the feed holds a full page — and a + // background account has no feed at all (no screen ever mounted one), which is why the key + // carries none and this reads null there. Null for ANY account on the relay means no floor + // at all, since a floor derived from one account's feed would truncate the others'. + val floors = accounts.map { it.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() } + val pagingBoundary = if (floors.any { it == null }) null else floors.filterNotNull().min() + + // No `since` floor on the first fetch. These filters are scoped by `#p` to my own + // keys and carry a relay-side `limit`, so an all-time query costs one index scan and + // returns at most `limit` events, newest first — exactly what Home does (it passes + // `since ?: boundary`, i.e. null on a cold start). + // + // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for + // anyone whose last mention was older than a week: EOSE `since` is in-memory only, + // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above + // could never rescue it — it only arms once the feed holds a full page, and the feed + // could not fill because the query only ever asked for a week. A fresh install of an + // established account hit the same deadlock. + val notificationSince = (if (gained) null else since?.get(relay)?.time) ?: pagingBoundary + + // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested + // here. It lives on the group's host relay and used to be one `#h` filter per relay carrying + // every joined group id — but this subscription also carries the inbox filters below, which + // have no `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or + // multi-channel) filter to "global", a class that by design never receives channel-scoped + // events. So those filters answered the stored query at EOSE and then went deaf until the + // next launch. + // + // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped + // to exactly one channel — RelayGroupJoinedChatTailSubAssembler and + // BuzzDmJoinedChatTailSubAssembler, both mounted app-wide from LoggedInPage, so coverage is + // unchanged and delivery is live. + filterSummaryNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + + filterNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + } } - val userJobMap = mutableMapOf>() + /** + * Per-account watchers, rebuilt as accounts come and go. + * + * There is only one subscription now, so these cannot hang off a per-key `newSub`. They are keyed + * by user and reconciled here: an account that leaves has its jobs cancelled, and one that arrives + * gets its own. Re-entrancy is safe — the watchers call `invalidateFilters()`, which lands back + * here and finds every account already watched. + */ + private val authorsPerRelay = MergedAuthorTracker() + + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.notificationRelays.flow.sample(1000).collectLatest { - invalidateFilters() - } - }, - // No group/Buzz-DM watchers here any more: those filters moved to the per-channel - // subscriptions, which mount and unmount with the channel itself. - key.account.scope.launch(Dispatchers.IO) { - key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - }, - ) + override fun updateSubscriptions(keys: Set) { + val wanted = keys.associateBy { it.account.userProfile() } - return super.newSub(key) + (userJobMap.keys - wanted.keys).toList().forEach { user -> + userJobMap.remove(user)?.forEach { it.cancel() } + } + + wanted.forEach { (user, key) -> + if (user !in userJobMap) { + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.notificationRelays.flow.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + // Only a screen can fill a feed, so there is nothing to watch for a + // background account. + listOfNotNull( + key.feedContentStates?.let { feeds -> + key.account.scope.launch(Dispatchers.IO) { + feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + } + }, + ) + } + } + + super.updateSubscriptions(keys) } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + override fun destroy() { + authorsPerRelay.clear() + userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } + userJobMap.clear() + super.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 7cfc8cd385..a60993c8f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01N import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -30,24 +30,38 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( client: INostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountUiQueryState) = key.account.userProfile() /** - * Downloads most notifications from the user's own inbox relays. - * But also connects to all the follows relays to check for new notifications that are not in the user's - * own inbox. + * Most notifications arrive on the user's own inbox relays. This is the straggler probe for the + * rest: other clients sometimes deliver a mention to the author's own relays instead of the + * recipient's inbox, so those only ever turn up if we go and look. + * + * It looks at a **rotating window of [RELAYS_PER_PASS] relays**, not at every relay the follows + * post to. The whole set was ~330 relays on a normal account, and subscribing to all of them + * held roughly 670 filters permanently — by a wide margin the largest thing the client ran, for + * a job that only needs to sweep the space eventually, not watch all of it at once. The window + * slides every [PASS_DURATION_MS], so every relay is still visited, just never all at the same + * time. + * + * The window is a contiguous slice of a URL-sorted list rather than a random draw: a fresh + * random pick on each invalidation would re-REQ a different set every few seconds, which costs + * more than the subscriptions it replaced. Deterministic order means the window only moves when + * the rotation timer says so. */ override fun updateFilter( - key: AccountQueryState, + key: AccountUiQueryState, since: SincePerRelayMap?, ): List { // only loads this after the feed is built, so it stays null on a quiet inbox. No week floor @@ -55,16 +69,30 @@ class AccountNotificationsEoseFromRandomRelaysManager( // newest either way — the floor only ever hid notifications older than a week, and since the // boundary above needs a full page to arm, a quiet inbox could never page past it. val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() - return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { + val candidates = + (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value) + .sortedBy { it.url } + if (candidates.isEmpty()) return emptyList() + + val start = (passIndex * RELAYS_PER_PASS).mod(candidates.size) + val window = List(minOf(RELAYS_PER_PASS, candidates.size)) { candidates[(start + it).mod(candidates.size)] } + + return window.flatMap { val since = since?.get(it)?.time ?: defaultSince filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) } } + /** + * Which slice of the sorted relay list the current pass is on. Bumped by the rotation job below; + * `mod` at the read site keeps it valid however far it runs. + */ + @Volatile private var passIndex = 0 + val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { + override fun newSub(key: AccountUiQueryState): Subscription { val user = user(key) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = @@ -80,6 +108,14 @@ class AccountNotificationsEoseFromRandomRelaysManager( invalidateFilters() } }, + // Slides the window. Cancelled with the others in endSub, so it stops with the account. + key.account.scope.launch(Dispatchers.IO) { + while (isActive) { + delay(PASS_DURATION_MS) + passIndex++ + invalidateFilters() + } + }, ) return super.newSub(key) @@ -92,4 +128,15 @@ class AccountNotificationsEoseFromRandomRelaysManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + /** + * Relays watched per pass. Small on purpose: this is a background sweep for misdelivered + * mentions, and the inbox relays carry the real traffic. + */ + const val RELAYS_PER_PASS = 5 + + /** How long a window stays put before sliding. Long enough that re-REQ churn stays negligible. */ + const val PASS_DURATION_MS = 5 * 60 * 1000L + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt index 485f8fb9f9..5f15938130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.notificationHistory return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 257f535dd2..899e24ec45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -30,7 +32,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -154,7 +155,9 @@ fun filterNotificationsHistoryToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -181,7 +184,9 @@ fun filterGroupNotificationsHistoryToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -191,20 +196,22 @@ fun filterGroupNotificationsHistoryToPubkey( ) } -fun filterSummaryNotificationsToPubkey( +fun filterSummaryNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = pubkeys, kinds = SummaryKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 2000, since = since, ), @@ -212,20 +219,22 @@ fun filterSummaryNotificationsToPubkey( ) } -fun filterNotificationsToPubkey( +fun filterNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 500, since = since, ), @@ -233,9 +242,11 @@ fun filterNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds2, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 200, since = since, ), @@ -243,9 +254,11 @@ fun filterNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds3, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 10, since = since, ), @@ -271,7 +284,9 @@ fun filterGroupNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -292,7 +307,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -302,7 +319,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -312,7 +331,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -322,7 +343,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index fc3a5b8f3a..97098d765e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -87,7 +89,8 @@ class NwcNotificationsEoseManager( RelayBasedFilter( relay = wallet.uri.relayUri, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NWC, kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), authors = listOf(wallet.uri.pubKeyHex), tags = mapOf("p" to listOf(signer.pubKey)), @@ -121,7 +124,7 @@ class NwcNotificationsEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 02d351be88..911e6fb132 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.chatroomList.giftWrapHistory return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt new file mode 100644 index 0000000000..2142ffe504 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt @@ -0,0 +1,124 @@ +/* + * 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.service.relayClient.reqCommand.account.nip60Cashu + +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +/** + * The account's NIP-60 wallet and NIP-61 nutzap inbox. + * + * This used to run from a collector inside `CashuWalletState`, on the account's own scope, which made + * the wallet the only account-level subscription whose lifetime was decided by the model rather than + * by a mount. It ran for every [com.vitorpamplona.amethyst.model.Account] object that happened to be + * resident — including accounts loaded purely so pushed gift wraps could be decrypted, which have no + * wallet anyone is looking at — and the attempt to fix that bolted a "is this pubkey subscribed + * anywhere" flow onto the model, so a model object was reading the relay layer's bookkeeping to + * decide whether to talk to relays. + * + * As a manager in [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler]'s + * group it starts and stops with every other account-level loader: the screen's mount for the account + * on show, the registry for the rest, and whatever the foreground/background rule becomes without this + * having to know about it. Exactly how NWC already worked. + * + * Per user, never merged: each account's wallet reads its own outbox for its own events and its own + * inbox for nutzaps addressed to it, so there is no shared query to fold them into. + */ +class CashuWalletEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val account = key.account + val wallet = account.cashuWalletState + + // NIP-65 outbox for our own wallet events; inbox + DM relays plus whatever our own kind:10019 + // advertises for inbound nutzaps, since another client may have published that with relays + // unrelated to our NIP-65 lists. + return cashuWalletFilters( + CashuWalletQueryState( + pubkey = account.userProfile().pubkeyHex, + ownEventRelays = account.outboxRelays.flow.value, + inboxRelays = + account.notificationRelays.flow.value + + account.dmRelays.flow.value + + (wallet.nutzapInfoEvent.value?.relays() ?: emptyList()), + ), + since, + ) + } + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + + // The relay sets and the nutzap-info event all move the query, so each one re-invalidates. + // Sampled because a relay-list edit can land as a burst of list events. + userJobMap[user] = + listOf( + key.account.outboxRelays.flow, + key.account.notificationRelays.flow, + key.account.dmRelays.flow, + ).map { flow -> + key.account.scope.launch(Dispatchers.IO) { + flow.sample(1000).collectLatest { invalidateFilters() } + } + } + + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.cashuWalletState.nutzapInfoEvent + .sample(1000) + .collectLatest { invalidateFilters() } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap.remove(key)?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 3f12d0bd05..2560fcc2cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @Stable class ChannelFinderQueryState( val channel: Channel, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ChannelFinderFilterAssemblyGroup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index c14d14bd19..f06d9a28d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelLoaderSubAssembler( client: INostrClient, allKeys: () -> Set, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { +) : AccountScopedSingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index c10fe0477e..ce2042c354 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -54,7 +55,9 @@ fun filterMissingChannelsById(keys: List): List, - val account: Account, -) : MutableQueryState { + override val account: Account, +) : MutableQueryState, + AccountScopedQuery { override fun flow(): Flow = searchQuery } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index a527046727..df31d45479 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -37,7 +37,7 @@ fun filterByAddress( val list = mapOfSet { if (note.event == null) { - potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.address) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index dc38aef1ca..70973ab169 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet @@ -53,7 +54,8 @@ fun filterByAuthor( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SEARCH, kinds = MetadataKindList, authors = myUser, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index 0a56940449..1bab42b2ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -38,16 +38,16 @@ fun filterByEvent( val list = mapOfSet { if (note !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } // loads threading that is event-based note.replyTo?.forEach { parentNote -> - if (parentNote !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> - add(relayUrl, note.idHex) + if (parentNote !is AddressableNote && parentNote.event == null) { + potentialRelaysToFindEvent(LocalCache, parentNote).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, parentNote.idHex) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt index 0351c87307..53e4dae18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun searchPeopleByName( @@ -33,7 +34,8 @@ fun searchPeopleByName( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SEARCH, kinds = listOf(MetadataEvent.KIND), search = searchString, limit = 1000, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 1a6e0be1d5..69014e6708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -31,7 +33,6 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -112,7 +113,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds1, search = searchString, limit = 100, @@ -121,7 +123,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds2, search = searchString, limit = 100, @@ -130,7 +133,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds3, search = searchString, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index 66718a9ea1..32942920eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -42,7 +42,7 @@ class RelaySpeedLogger( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 403fcaf39f..f6032b8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime +typealias EOSEAccountFast = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast open class EOSEByKey( cacheSize: Int = 200, @@ -113,60 +114,3 @@ open class EOSEAccountKey( time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } - -class EOSEAccountFast( - cacheSize: Int = 20, -) { - private val users: LruCache = LruCache(cacheSize) - private val lock = Any() - - fun addOrUpdate( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) { - synchronized(lock) { - val relayList = users[user] - if (relayList == null) { - val newList = EOSERelayList() - users.put(user, newList) - - newList.addOrUpdate(relayUrl, time) - } else { - relayList.addOrUpdate(relayUrl, time) - } - } - } - - fun removeEveryoneBut(list: Set) { - synchronized(lock) { - users.snapshot().forEach { - if (it.key !in list) { - users.remove(it.key) - } - } - } - } - - fun removeDataFor(user: T) { - synchronized(lock) { - users.remove(user) - } - } - - fun since(key: T): SincePerRelayMap? = - synchronized(lock) { - users[key]?.relayList?.toMutableMap() - } - - fun sinceRelaySet(key: T): Set? = - synchronized(lock) { - users[key]?.relayList?.keys?.toSet() - } - - fun newEose( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) = addOrUpdate(user, relayUrl, time) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt new file mode 100644 index 0000000000..4ef51cc3cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt @@ -0,0 +1,64 @@ +/* + * 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.service.resourceusage + +/** + * Refcounts a boolean session so overlapping holders don't close each other's + * segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState] + * exposes two independent location flows that can both be listening at once — + * the "Around Me" feed plus an open geohash chat — and a bare + * [SessionTimeIntegrator] would close the segment when either one stops. + * + * The count and the transition it drives are taken under one lock. An + * [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is + * not enough: two threads can leave the counter at 1 while the last + * `setActive(false)` lands after the `setActive(true)`, latching the session + * off with a holder still active. + * + * Takes the setter as a lambda rather than a [SessionTimeIntegrator] because + * that is all it needs, and so tests need no accountant or store file. + * + * Reports **transitions only**, not every call — the contract the name implies, + * and what keeps the class honest for a future caller that reacts to the + * callback rather than integrating it. (For [SessionTimeIntegrator] specifically + * a 1 -> 2 re-entry would have been harmless: it splits one segment into two + * contiguous pieces that `account()` re-adds, and its starts increment is + * already guarded on `prev == null`.) + * + * Releases must be paired with acquires. This class cannot tell an unpaired + * release from a real one, so callers guarantee the pairing; see `LocationFlow`, + * which throws rather than reaching `awaitClose` when nothing registered. + */ +class RefCountedSession( + private val setSessionActive: (Boolean) -> Unit, +) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) { + synchronized(lock) { + val wasActive = holders > 0 + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + val isActive = holders > 0 + if (isActive != wasActive) setSessionActive(isActive) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index a8ba773138..5abc1998e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -20,10 +20,22 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import java.util.concurrent.ConcurrentHashMap /** * Counts relay websocket traffic into the usage ledger. Frame sizes are @@ -31,29 +43,210 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command * (relay JSON is ASCII-dominant), consistent with how RelayStats counts. * Excludes WS framing/compression; good enough for "which subsystem is * eating my data plan" comparisons. + * + * Beyond the byte totals this also carries the relay-churn diagnostics: a + * per-verb split of those same bytes, a connection-lifetime histogram, and + * per-relay failure/short-session counts. See + * plans/2026-07-29-relay-churn-diagnostics.md for what each answers and how to + * read them together. + * + * The verb split takes its name straight from the wire label the command already + * knows (`Command.label()` / `Message.label()`), so `Σ verb == Σ msg` holds by + * construction and a new subtype needs no change here. */ class RelayUsageListener( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) : RelayConnectionListener { + /** + * Relay -> when its current session became ready. Touched from the per-relay + * OkHttp dispatcher threads, hence concurrent. Keyed by [NormalizedRelayUrl] to + * match the other per-relay caches (`RelayStats`, `RelayLimitsTracker`). + * + * Entries are consumed on disconnect. Three ways a session escapes the map + * unrecorded, all counted rather than prevented — see [UsageKeys.RELAY_LIFE_OVERWRITE], + * [UsageKeys.RELAY_LIFE_ORPHAN], and [UsageKeys.relayConnects] for process death. + */ + private val connectedSince = ConcurrentHashMap() + + /** + * Relay -> subscription ids currently open on this connection, so a REQ that + * replaces an in-flight subscription can be told apart from one that opens a new + * one. Cleared on disconnect, because the relay forgets them too — every REQ + * after a reconnect is legitimately new. + * + * Bounded by the live subscription count per relay (tens), not by session length. + */ + private val openSubs = ConcurrentHashMap>() + + /** + * Subscription id -> the purpose that opened it, for attributing inbound frames: + * an EVENT names only its subscription, never why the client asked for it. + * + * Deliberately **not** [openSubs]. Sharing one map conflated two different + * lifetimes and lost 41 % of the download to `unattributed` in the 2026-08-02 + * reading: a CLOSE removed the id, and a disconnect dropped the whole relay's + * map, while frames already in flight were still arriving. "Is this subscription + * open" and "what did this subscription belong to" answer different questions and + * expire at different times — the second stays true after the first turns false. + * + * Keyed by subscription id alone, without the relay. The same id is used across + * relays for one logical subscription, so the purpose is a property of the id; + * this also means a frame arriving after a reconnect still attributes. + * + * Bounded by [MAX_TRACKED_SUBS] with wholesale eviction rather than an LRU: this + * is a diagnostic on a hot path, ids are recycled steadily, and a rare reset that + * sends a few frames to `unattributed` is cheaper than per-frame bookkeeping. + * `unattributed` staying small is what says the bound is generous enough. + */ + private val subPurpose = ConcurrentHashMap() + + /** + * Event ids delivered recently, as the first 64 bits of the id. + * + * Held as a Long rather than the 64-char hex, which saves the id strings + * themselves — but a boxed `Long` plus its map node still costs ~56 bytes an + * entry, so a full window is ~3 MB, not the few hundred KB the raw payload + * suggests. Worth knowing before raising [MAX_TRACKED_EVENTS] on a 512 MB-class + * device; an unboxed open-addressed `LongArray` would be ~400 KB if it ever needs + * to grow. 64 bits makes a collision between distinct ids negligible where a + * 32-bit hash would not be. + * + * The window only needs to span the fan-out, not the session: the same event + * arrives from every relay carrying it within seconds, so near-term memory + * catches the duplication this measures. Cleared wholesale at [MAX_TRACKED_EVENTS] + * for the same reason [subPurpose] is — the alternative is per-frame LRU + * bookkeeping on the hottest path in the app. A clear undercounts duplicates that + * straddle it, so the ratio is a floor. + */ + private val recentEventIds = ConcurrentHashMap.newKeySet() + + /** + * Relay -> when this dial was decided, so the pre-request cost can be separated + * from the handshake. Consumed by whichever of `onConnected`/`onCannotConnect` + * ends the dial, so an entry never outlives the attempt that made it. + */ + private val dialStartedAt = ConcurrentHashMap() + + /** Notice texts already logged, so one wording costs one line however often it arrives. */ + private val loggedNotices = ConcurrentHashMap.newKeySet() + override fun onSent( relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean, ) { - if (success) { - accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong()) + if (!success) return + + val bytes = cmdStr.length.toLong() + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes) + accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes) + + when (cmd) { + is ReqCmd -> { + accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1) + + val purpose = purposeOf(cmd) + accountant.add(UsageKeys.relayPurposeSent(purpose), 1) + accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes) + + if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear() + subPurpose[cmd.subId] = purpose + + // Already open on this connection, so this REQ replaces a live + // subscription rather than starting one. + // computeIfAbsent, not getOrPut: the latter is get-then-put and two + // threads racing the first REQ after a (re)connect would each build a + // set, the losing put's subId vanishing with its orphaned set. + // `sendIfConnected` deliberately calls listeners outside PoolRequests' + // stripe lock, so same-relay concurrency here is by design. + val known = openSubs.computeIfAbsent(relay.url) { ConcurrentHashMap.newKeySet() } + if (!known.add(cmd.subId)) { + accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1) + } + // Within the window after this relay's connect, so almost certainly + // part of syncState's replay rather than a user action. A time + // window because nothing on this side marks a frame as belonging to + // it; see UsageKeys.relaySubsReplay. + val since = connectedSince[relay.url] + if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) { + accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1) + } + } + is CloseCmd -> { + accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1) + openSubs[relay.url]?.remove(cmd.subId) + } } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, ) { - accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) + val bytes = msgStr.length.toLong() + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayMsg(mobile, fg, received = true), bytes) + accountant.add(UsageKeys.relayVerb(msg.label(), received = true, mobile, fg), bytes) + + // Attribute the inbound side to whoever asked for it. Only frames that name a + // subscription can be attributed; NOTICE and OK are relay-wide and are left out + // rather than guessed at, which is why this does not reconcile to msg.rx. + // Resolved once: the duplicate check below needs the same answer, and an + // EVENT names only its subscription, never why the client asked for it. + val purpose = subIdOf(msg)?.let { subPurpose[it] ?: UsageKeys.PURPOSE_UNATTRIBUTED } + if (purpose != null) { + accountant.add(UsageKeys.relayPurposeDown(purpose), bytes) + accountant.add(UsageKeys.relayPurposeDownCount(purpose), 1) + } + + if (msg is EventMessage) { + accountant.add(UsageKeys.relayEventsSeen(mobile, fg), 1) + idPrefix(msg.event.id)?.let { key -> + if (recentEventIds.size >= MAX_TRACKED_EVENTS) recentEventIds.clear() + if (!recentEventIds.add(key)) { + accountant.add(UsageKeys.relayEventsDup(mobile, fg), 1) + accountant.add(UsageKeys.relayEventsDupBytes(mobile, fg), bytes) + if (purpose != null) accountant.add(UsageKeys.relayPurposeDupBytes(purpose), bytes) + } + } + } + + // A refused subscription arrives here and nowhere else: the NOTICE carries no + // subscription id, so RelayReqRefusals (wired to CLOSED) never sees it. + if (msg is NoticeMessage) { + val reason = UsageKeys.noticeReason(msg.message) + accountant.add(UsageKeys.relayNotice(reason), 1) + if (reason == UsageKeys.NOTICE_UNCLASSIFIED) { + // The counter alone cannot say whether an absent `toomanysubs` means no + // refusals or an allowlist that misses how this relay words them. + // + // INFO, not DEBUG: a debug build defaults to LogLevel.INFO + // (Amethyst.DEFAULT_LOG_LEVEL, with VERBOSE_LOGS off), so a DEBUG line + // here is dropped before it reaches the sink and this said nothing at + // all. Demote it once the allowlist stops needing evidence. + // + // One line per distinct wording rather than per frame: the ledger + // already has the count, what is missing is the variety. Truncated and + // capped because the text is server-controlled. + if (loggedNotices.size < MAX_DISTINCT_NOTICES && loggedNotices.add(msg.message)) { + Log.i(TAG) { "Unclassified NOTICE from ${relay.url.url}: ${msg.message.take(MAX_NOTICE_LOG)}" } + } + } + } + } + + /** Dial attempts. Unlike [onCannotConnect] this really is one per dial. */ + override fun onConnecting(relay: IRelayClient) { + accountant.add(UsageKeys.relayDials(isMobile(), isForeground()), 1) + dialStartedAt[relay.url] = nowMs() } // Every completed (re)connection paid a TCP+TLS handshake; high daily @@ -64,13 +257,109 @@ class RelayUsageListener( pingMillis: Int, compressed: Boolean, ) { - accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1) + val mobile = isMobile() + val fg = isForeground() + // Doubles as the lifetime histogram's denominator — one session begins here. + accountant.add(UsageKeys.relayConnects(mobile, fg), 1) + // Consumed unconditionally: the gap needs a handshake to subtract, but the + // stamp has to go either way or a dial that cannot be timed leaks its entry. + val dialedAt = dialStartedAt.remove(relay.url) + // pingMillis is the transport's own handshake timing; <= 0 means it could + // not measure it, and a fabricated 0 would be worse than no record. + if (pingMillis > 0) { + accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1) + if (dialedAt != null) { + val gap = nowMs() - dialedAt - pingMillis + if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1) + } + } + + if (connectedSince.put(relay.url, nowMs()) != null) { + accountant.add(UsageKeys.RELAY_LIFE_OVERWRITE, 1) + } } + override fun onDisconnected(relay: IRelayClient) { + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayDisconnects(mobile, fg), 1) + + openSubs.remove(relay.url) + val startedAt = connectedSince.remove(relay.url) + if (startedAt == null) { + // A dial that never became ready, or a second disconnect for one session. + accountant.add(UsageKeys.RELAY_LIFE_ORPHAN, 1) + return + } + + val elapsed = (nowMs() - startedAt).coerceAtLeast(0) + accountant.add(UsageKeys.relayLife(elapsed, mobile, fg), 1) + } + + /** + * The [SubPurpose] behind a REQ, from the first filter that declares one. + * + * One subscription id carries one purpose in practice, so the first is the + * subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s + * predates #3832's tagging and is counted separately rather than guessed at. + */ + private fun purposeOf(cmd: ReqCmd): String = + cmd.filters + .firstNotNullOfOrNull { it.purposeOrNull() } + ?.let { UsageKeys.purposeKeyPart(it) } + ?: UsageKeys.PURPOSE_UNEXPLAINED + + /** + * The first 64 bits of an event id, or null if it is not a well-formed id. + * + * Parsed in place rather than `substring(0, 16).toULongOrNull(16)`: this runs on + * every inbound EVENT frame, and the substring would be a String plus its backing + * array per event, thrown away immediately. + */ + private fun idPrefix(id: String): Long? { + if (id.length < 16) return null + var acc = 0L + for (i in 0 until 16) { + val digit = Character.digit(id[i], 16) + if (digit < 0) return null + acc = (acc shl 4) or digit.toLong() + } + return acc + } + + /** The subscription a frame belongs to, when it names one. */ + private fun subIdOf(msg: Message): String? = + when (msg) { + is EventMessage -> msg.subId + is EoseMessage -> msg.subId + is ClosedMessage -> msg.subId + is CountMessage -> msg.queryId + else -> null + } + override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1) + // A dial that ends here never reaches onConnected, so nothing else would + // ever consume its start stamp. + dialStartedAt.remove(relay.url) + } + + companion object { + private const val TAG = "RelayUsage" + + /** NOTICE text is server-controlled; cap what reaches the log. */ + private const val MAX_NOTICE_LOG = 200 + + /** Ceiling on distinct wordings held in memory; relay prose is unbounded. */ + private const val MAX_DISTINCT_NOTICES = 200 + + /** Ceiling on remembered subscription-id purposes. See [subPurpose]. */ + private const val MAX_TRACKED_SUBS = 4_000 + + /** Recent-event-id window. See [recentEventIds]. */ + private const val MAX_TRACKED_EVENTS = 50_000 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index eaa90e8d10..3c4cde0ccf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -38,7 +38,9 @@ import java.util.concurrent.atomic.AtomicLong * hands off the accumulated value atomically, whereas remove+sum on a * LongAdder can strand a racing increment on an orphaned cell. Entries stay * in the map after a drain — the key space is small and fixed (dims x areas), - * so this costs a few hundred boxed zeros at most. + * so this costs a few hundred boxed zeros at most. The churn counters roughly + * tripled it, but every one of them is still compile-time bounded: no counter + * is keyed on a relay url, a host, or any other runtime string. * * Counters added from inside a pre-flush hook (the CPU sampler, the segment * integrators closing an open segment) never re-arm the debounce: they are @@ -74,8 +76,16 @@ class ResourceUsageAccountant( amount: Long, ) { if (amount <= 0) return - live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount) + // Plain get first: computeIfAbsent locks the bin head when the key is present + // but not the head node, and the churn counters roughly tripled the key count + // (so collisions) on a path that runs per relay frame. + (live[key] ?: live.computeIfAbsent(key) { AtomicLong() }).addAndGet(amount) if (inHookRun.get() == true) return + // Plain read before the CAS: in steady state a flush is always already armed, + // and the churn counters made this 5-8 calls per relay frame — every one of + // which would otherwise be a read-modify-write on the same shared cache line + // from every relay's socket thread, only to fail. + if (flushScheduled.get()) return if (flushScheduled.compareAndSet(false, true)) { scope.launch { delay(flushDebounceMs) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 73c4f510f3..4387cb2b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -75,16 +75,49 @@ class ResourceUsageReportAssembler { sb.append("\nTechnical details (per epoch-day):\n") sb.append("```\n") - days.toSortedMap().forEach { (day, counters) -> + val sorted = days.toSortedMap() + val included = newestDaysWithin(sorted, MAX_DUMP_CHARS) + sorted.forEach { (day, counters) -> + if (day !in included) return@forEach sb.append("day $day (today=$today)\n") counters.toSortedMap().forEach { (key, value) -> sb.append(" $key = $value\n") } } + val omitted = sorted.size - included.size + if (omitted > 0) { + sb.append("($omitted earlier day(s) omitted to keep this report sendable; ") + sb.append("the summary tables above still cover them)\n") + } sb.append("```\n") return sb.toString() } + /** + * The most recent days whose dumps fit in [budget], newest first, always + * including at least the newest even if it alone exceeds it. + * + * Bounded by size rather than by a day count because the per-day size is not a + * constant: it tracks how many distinct counters the build emits, and that has + * grown by more than an order of magnitude. A fixed day count would have to be + * re-tuned every time a counter family is added, and would be wrong in the + * meantime. + */ + private fun newestDaysWithin( + days: Map>, + budget: Int, + ): Set { + val included = mutableSetOf() + var left = budget + for (day in days.keys.sortedDescending()) { + val size = days.getValue(day).entries.sumOf { it.key.length + DUMP_LINE_OVERHEAD } + if (included.isNotEmpty() && size > left) break + included.add(day) + left -= size + } + return included + } + private fun summaryTable(s: UsageSummary): String = buildString { append("| Metric | Value |\n") @@ -132,6 +165,25 @@ class ResourceUsageReportAssembler { /** Markdown table header/body separator row. */ private const val TABLE_SEPARATOR = "| --- | --- |\n" + /** + * Character budget for the raw per-day dump. + * + * This report exists to be sent to the developers as a NIP-17 DM, and relays + * commonly cap events between 64 and 256 KB — so an unbounded dump does not + * merely inconvenience, it makes the report unsendable by exactly the users + * whose ledgers are most worth seeing. It also travels through a ~1 MB Binder + * transaction when shared. + * + * The ledger keeps 30 days and a busy day now emits ~1,200 counters, which is + * ~52 KB of dump per day — so "every retained day" would be ~1.5 MB. This + * keeps the newest days and says how many it dropped; the summary tables + * above are unaffected and still cover the whole window. + */ + private const val MAX_DUMP_CHARS = 64 * 1024 + + /** ` ` + ` = ` + the value, per dumped line. */ + private const val DUMP_LINE_OVERHEAD = 24 + fun formatBytes(bytes: Long): String = when { bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index 7e7824631b..fc63089343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -34,13 +34,31 @@ import java.io.File * Jackson + Mutex + write-to-tmp-then-rename + version envelope. * * Day keys are UTC epoch-days (stringified for JSON). Buckets older than - * [keepDays] are pruned on every merge, so the file stays small (a few KB). + * [keepDays] are pruned on every merge, so the file stays small (a few KB, on a + * key space the churn counters tripled but left compile-time bounded). The whole + * file is re-serialized on every flush (debounced to ~30s while traffic flows), + * so that size is paid on each write, not just at rest. * Also carries the high-consumption alert state (last prompt time, opt-out) * so the whole feature has exactly one file. */ class ResourceUsageStore( private val storageFile: File, - private val keepDays: Long = 30, + /** + * Retention, in days. + * + * Seven because that is the widest window anything reads: the summary tables and + * the trend chart both span `today - 6 .. today`, the alert evaluator looks at + * two days, and the report's raw dump is byte-bounded well below a week. At 30 — + * the previous value — twenty-three days were rewritten on every flush and read + * by nothing. + * + * That is not free: [persist] rewrites the whole file on every merge, and the + * relay-churn counters took a day's bucket from ~57 keys to ~241. Retention is + * therefore a write-amplification setting as much as a history setting — cutting + * it to a week is what keeps those counters at ~18% over the previous file size + * rather than ~5x. + */ + private val keepDays: Long = 7, ) { data class UsageFile( val version: Int = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index e43cde73a9..fd222cf993 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -20,6 +20,18 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.AUTH_REQUIRED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.BLOCKED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.ERROR +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.INVALID +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RATE_LIMITED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RESTRICTED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.UNSUPPORTED +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver +import java.util.concurrent.ConcurrentHashMap + /** * Counter-key grammar for the resource-usage ledger. Keys are flat strings so * the on-disk store is schema-free — adding a counter never needs a migration. @@ -29,8 +41,28 @@ package com.vitorpamplona.amethyst.service.resourceusage * - visibility: `fg` (an activity is started) vs `bg` * - direction: `rx` (downloaded) vs `tx` (uploaded) * - * Counters are sizes, durations, and counts only — never URLs, relay names, or - * content. See plans/2026-07-12-resource-usage-ledger.md. + * Counters are sizes, durations, and counts only — never content, and never a + * full URL, and never a relay name. + * See plans/2026-07-12-resource-usage-ledger.md. + * + * ## Reserved segments — read before adding a counter + * + * [sumMatching] matches by dot-segment *membership*, not by prefix, and every + * headline figure in [UsageSummary] is built from it. A new key that happens to + * contain one of these segments silently joins that sum: + * + * rx tx msg connms connects connfails reqs bursts activems + * worker runs + every value in [HTTP_ROLES] + * + * Concretely: a key named `relay.rx.event.mobile.bg` would be counted by + * `traffic(MOBILE, BG)` *in addition to* `relay.msg.mobile.bg.rx`, doubling the + * reported data usage and halving the effective threshold of the + * background-mobile-data alert. That is why the relay verb split below uses + * `up`/`down` rather than `tx`/`rx`. + * + * `ResourceUsageLedgerTest.newKeysDoNotDisturbSummary` is the regression guard: + * it asserts [UsageSummary.from] is value-identical with and without every key + * this object can produce. */ object UsageKeys { const val MOBILE = "mobile" @@ -54,6 +86,42 @@ object UsageKeys { val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER) + /** + * `mobile.bg` — the network x visibility pair every counter is split by. + * + * Table-backed rather than interpolated: this is evaluated on every relay frame + * and every HTTP response, and there are only four possible answers. Declared + * first because the key tables below are built from it at class-init. + */ + fun dim( + mobile: Boolean, + foreground: Boolean, + ): String = DIMS[dimIndex(mobile, foreground)] + + private val DIMS = arrayOf("$WIFI.$BG", "$WIFI.$FG", "$MOBILE.$BG", "$MOBILE.$FG") + + private fun dimIndex( + mobile: Boolean, + foreground: Boolean, + ): Int = (if (mobile) 2 else 0) or (if (foreground) 1 else 0) + + /** + * The four `.[.]` keys, indexed by [dimIndex]. + * + * Used for every `relay.*` key, because all of them are built from a relay + * callback — per frame for [relayMsg]/[relayVerb], per dial/connect/disconnect + * for the rest. The `net.*` builders below still interpolate: their key space is + * role x dim x metric and they are called once per HTTP response, so the table + * would be larger and buy less. If you add a `relay.*` counter, table it. + */ + private fun dimKeys( + prefix: String, + suffix: String? = null, + ): Array { + val tail = if (suffix == null) "" else ".$suffix" + return Array(DIMS.size) { "$prefix.${DIMS[it]}$tail" } + } + /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ fun net( role: String, @@ -87,25 +155,556 @@ object UsageKeys { mobile: Boolean, foreground: Boolean, received: Boolean, - ): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}" + ): String = (if (received) RELAY_MSG_RX else RELAY_MSG_TX)[dimIndex(mobile, foreground)] + + private val RELAY_MSG_RX = dimKeys("relay.msg", RX) + private val RELAY_MSG_TX = dimKeys("relay.msg", TX) /** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */ fun relayConnMs( mobile: Boolean, foreground: Boolean, - ): String = "relay.connms.${dim(mobile, foreground)}" + ): String = RELAY_CONNMS[dimIndex(mobile, foreground)] - /** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */ + private val RELAY_CONNMS = dimKeys("relay.connms") + + /** + * `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a + * TCP+TLS handshake. + * + * Also the [relayLife] histogram's denominator — one session begins per + * `onConnected` — which is what makes the histogram's deficit measurable rather + * than assumed: + * + * connects − Σ life buckets − orphan = still open at report time + lost to process death + * + * Without that subtraction a leak, a still-open session and a session lost to a + * background kill are indistinguishable. + */ fun relayConnects( mobile: Boolean, foreground: Boolean, - ): String = "relay.connects.${dim(mobile, foreground)}" + ): String = RELAY_CONNECTS[dimIndex(mobile, foreground)] - /** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */ + private val RELAY_CONNECTS = dimKeys("relay.connects") + + /** + * `relay.connfails.mobile.bg` — every `onCannotConnect`. + * + * NOT a failed-dial count, despite the name. `BasicRelayClient.onFailure` + * raises `onCannotConnect` with no `isReady` test, so a connection that lived + * for ten minutes and then dropped increments both this and [relayConnects] + * from a single dial. Use [relayDials] for the actual number of dials. + */ fun relayConnectFails( mobile: Boolean, foreground: Boolean, - ): String = "relay.connfails.${dim(mobile, foreground)}" + ): String = RELAY_CONNFAILS[dimIndex(mobile, foreground)] + + private val RELAY_CONNFAILS = dimKeys("relay.connfails") + + /** + * `relay.dials.mobile.bg` — dial attempts, from `onConnecting`. + * + * Fires exactly once per dial, after the transport gate and the connect mutex + * and before the socket is built, so this is the honest denominator that + * [relayConnectFails] is not. + */ + fun relayDials( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_DIALS[dimIndex(mobile, foreground)] + + private val RELAY_DIALS = dimKeys("relay.dials") + + /** `relay.disc.mobile.bg` — every `onDisconnected`, whatever the cause. */ + fun relayDisconnects( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_DISC[dimIndex(mobile, foreground)] + + private val RELAY_DISC = dimKeys("relay.disc") + + /** + * `relay.subs.sent.mobile.bg` — REQ commands sent. + * + * A count to sit beside the `relay.verb.up.req` byte total: PR #3832 raised the + * number of live subscriptions per relay (background accounts now subscribe too) + * and measured refusals against nos.lol's cap of 20. Divided by [relayConnects] + * this is REQs per connection, which is what separates "we reconnect too often" + * from "each reconnect asks for too much" — different fixes. + */ + fun relaySubsSent( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_SENT[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_SENT = dimKeys("relay.subs.sent") + + /** `relay.subs.closed.mobile.bg` — CLOSE commands sent; sent minus closed is net subscription growth. */ + fun relaySubsClosed( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_CLOSED[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_CLOSED = dimKeys("relay.subs.closed") + + /** + * `relay.subs.replay.mobile.bg` — REQs sent within [REPLAY_WINDOW_MS] of that + * relay's connect, i.e. the post-connect resubscribe burst. + * + * An estimate, not an exact split. `PoolRequests.syncState` replays every desired + * filter from a coroutine launched at `onConnected`, but nothing on the listener + * side marks a frame as belonging to it, so this is a time window. It is the + * measurement behind the source report's inference that ~24 KB per connection + * "is exactly the size of a full REQ subscription replay" — which was arithmetic + * on a daily total, not an observation. + */ + fun relaySubsReplay( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_REPLAY[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_REPLAY = dimKeys("relay.subs.replay") + + /** How long after a connect a REQ still counts as part of the resubscribe burst. */ + const val REPLAY_WINDOW_MS = 2_000L + + /** + * `relay.notice.toomanysubs` — NOTICE frames by reason. + * + * Exists because a refused subscription is otherwise invisible. Per PR #3832's + * own known issue, `ERROR: too many concurrent REQs` arrives as a NOTICE, which + * carries no subscription id and so never reaches `RelayReqRefusals.onRefused` + * (wired to CLOSED only). The relay drops the REQ while the client still believes + * it is live — it never EOSEs, its `since` never advances, and `syncState` then + * re-requests its full backlog on every reconnect, forever. A non-trivial count + * here means subscription pressure is a *cause* of the download volume rather + * than a symptom of the reconnect count. + * + * The reason comes from a fixed allowlist, never from the relay's text. Relay + * prose is server-controlled and this key is persisted for 30 days; `RelayObserver` + * had to fix exactly this bug, where free-form CLOSED prose became its own tally + * key and cardinality grew with the number of distinct sentences relays wrote. + */ + fun relayNotice(reason: String): String = RELAY_NOTICE[reason] ?: RELAY_NOTICE.getValue(NOTICE_UNCLASSIFIED) + + const val NOTICE_TOO_MANY_SUBS = "toomanysubs" + const val NOTICE_RATE_LIMITED = "ratelimited" + const val NOTICE_AUTH_REQUIRED = "authrequired" + const val NOTICE_RESTRICTED = "restricted" + const val NOTICE_INVALID = "invalid" + const val NOTICE_BLOCKED = "blocked" + const val NOTICE_ERROR = "error" + const val NOTICE_UNSUPPORTED = "unsupported" + + /** The query itself was too expensive — too many kinds/steps/filters. Observed on nostr.land, relay.layer.systems. */ + const val NOTICE_QUERY_COST = "querycost" + + /** The relay refuses REQs outright. Observed on sendit.nosflare.com. */ + const val NOTICE_REQ_REFUSED = "reqrefused" + + /** Relay chatter that costs bytes but means nothing — keepalives, per-query PERF telemetry. */ + const val NOTICE_BENIGN = "benign" + + /** Deliberately not `other`: that is an [HTTP_ROLES] value and a reserved segment. */ + const val NOTICE_UNCLASSIFIED = "unclassified" + + val NOTICE_REASONS = + listOf( + NOTICE_TOO_MANY_SUBS, + NOTICE_RATE_LIMITED, + NOTICE_AUTH_REQUIRED, + NOTICE_RESTRICTED, + NOTICE_INVALID, + NOTICE_BLOCKED, + NOTICE_ERROR, + NOTICE_UNSUPPORTED, + NOTICE_QUERY_COST, + NOTICE_REQ_REFUSED, + NOTICE_BENIGN, + NOTICE_UNCLASSIFIED, + ) + + private val RELAY_NOTICE = NOTICE_REASONS.associateWith { "relay.notice.$it" } + + /** + * Classifies a NOTICE into one of [NOTICE_REASONS]. + * + * Matches on content markers rather than the NIP-01 machine-readable prefix + * alone, because the case this exists for does not have a useful one: strfry + * sends `ERROR: too many concurrent REQs`, whose prefix is just `error`. + * [RelayObserver.prefixOf] is consulted for the standard prefixes it does + * handle correctly. + */ + fun noticeReason(message: String): String { + val text = message.lowercase() + // Several relays prefix a NOTICE with the subscription id it concerns + // ("Kgo0HH: closed: too many steps"), which makes the *subscription id* the + // machine-readable prefix and hides the real one. Try the remainder too. + val prefix = RelayObserver.prefixOf(message) + val inner = RelayObserver.prefixOf(message.substringAfter(':', "")) + val prefixes = setOf(prefix, inner) + return when { + "too many" in text && ("req" in text || "subscription" in text || "concurrent" in text) -> NOTICE_TOO_MANY_SUBS + // A cost refusal is still a refusal: the REQ is dropped, so it never + // EOSEs and its `since` never advances. + "too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST + "does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED + "keepalive" in text || "perf:" in text -> NOTICE_BENIGN + // Wire codes come from the enum rather than being hand-written a third + // time (after the enum itself and the NOTICE_* key segments). + RATE_LIMITED.code in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED + AUTH_REQUIRED.code in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED + RESTRICTED.code in prefixes -> NOTICE_RESTRICTED + INVALID.code in prefixes -> NOTICE_INVALID + BLOCKED.code in prefixes -> NOTICE_BLOCKED + UNSUPPORTED.code in prefixes -> NOTICE_UNSUPPORTED + ERROR.code in prefixes -> NOTICE_ERROR + else -> NOTICE_UNCLASSIFIED + } + } + + /** + * The bar that decides reconnect behaviour, and so also what counts as a short + * session: below it a disconnect keeps the growing backoff, + * at or above it the backoff resets to 1s. (`NostrClient.KEEP_ALIVE_INTERVAL_MS` + * is the same 60s, but it is private, so this reads the one that is public.) + * + * Derived rather than copied: the plan retunes `STABLE_CONNECTION_IN_SECS` once + * the histogram is read, and a hand-written 60_000 here would silently stop + * meaning "session that kept the backoff growing" at that point. + * `UsageKeyHelpersTest.lifeBucketsAreHalfOpen` asserts the resulting bucket + * labels, so a retune surfaces as a test failure rather than as a histogram that + * quietly answers the wrong question. + */ + const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L + + /** + * A half-open millisecond histogram: ascending upper [bounds], the derived + * bucket labels, and the `..` key table they index. + * + * Shared by all three histograms below (`relay.life`, `relay.hs`, `relay.gap`), + * which differ only in their bounds and in whether the label reads in seconds or + * milliseconds. Deriving the names from the bounds is what keeps a bound change + * from leaving a label lying about it. + */ + private class MsHistogram( + prefix: String, + private val bounds: LongArray, + label: (Long) -> String, + ) { + init { + // [indexOf] linear-scans for the first bound greater than the elapsed + // time, so the bounds must ascend. One of relay.life's is derived from + // BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five + // minutes — exactly the retune commit 2 of the churn plan contemplates — + // would push it past the two bounds after it. The buckets between would + // become unreachable and the labels would start lying. Fail at class-init + // with the reason rather than as a puzzling boundary-test failure. + require(bounds.asList() == bounds.sorted()) { + "$prefix bounds must ascend, got ${bounds.toList()}. " + + "SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match." + } + } + + val names = Array(bounds.size + 1) { i -> if (i < bounds.size) "lt${label(bounds[i])}" else "gte${label(bounds.last())}" } + + private val keys = Array(names.size) { dimKeys("$prefix.${names[it]}") } + + fun indexOf(ms: Long): Int { + for (i in bounds.indices) { + if (ms < bounds[i]) return i + } + return bounds.size + } + + fun nameOf(ms: Long): String = names[indexOf(ms)] + + fun key( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = keys[indexOf(ms)][dimIndex(mobile, foreground)] + } + + /** + * Bounds for the [relayLife] histogram deliberately straddle [SHORT_SESSION_MS]: + * a mean cannot tell a tight cluster sitting on that bar from a bimodal mix; + * this can. + */ + private val LIFE = MsHistogram("relay.life", longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000)) { "${it / 1000}s" } + + fun lifeBucket(elapsedMs: Long): String = LIFE.nameOf(elapsedMs) + + /** + * `relay.life.lt60s.mobile.bg` — connections that closed after living this long. + * [relayConnects] is the denominator; see its doc for the deficit equation. + */ + fun relayLife( + elapsedMs: Long, + mobile: Boolean, + foreground: Boolean, + ): String = LIFE.key(elapsedMs, mobile, foreground) + + /** + * `relay.life.overwrite` — a connect arrived for a relay that already had an + * unconsumed start stamp. + * + * Expected during a pool teardown: `NostrClient` runs `disconnect()` then + * `connect()` synchronously, so a stale failure callback for the old socket + * can land after the new socket is already open. The new stamp overwrites the + * old, and the stale disconnect then consumes the new one — booking a + * near-zero lifetime for a session that never ended. Bias runs toward `lt5s`, + * so read this before reading the histogram's short buckets. + */ + const val RELAY_LIFE_OVERWRITE = "relay.life.overwrite" + + /** `relay.life.orphan` — a disconnect with no matching start stamp. */ + const val RELAY_LIFE_ORPHAN = "relay.life.orphan" + + /** + * `relay.verb.up.req.mobile.bg` / `relay.verb.down.eose.mobile.bg` — the + * [relayMsg] bytes, split by wire verb. Parameterised on direction for the same + * reason [relayMsg] is: one memo strategy, not two. + * + * `verb` is the command's own `label()` (`REQ`, `EVENT`, ...) lowercased, so a + * new `Command`/`Message` subtype maps itself and nothing can land in a + * catch-all bucket unnoticed. Deliberately `up`/`down` rather than `tx`/`rx` — + * see the reserved-segment note above. + * + * Keys are memoized because this is on the per-frame path: the verb x dim space + * is a handful of entries, so steady state is a map lookup and an array index + * with no string building at all. + */ + fun relayVerb( + verb: String, + received: Boolean, + mobile: Boolean, + foreground: Boolean, + ): String = + (if (received) VERB_DOWN_KEYS else VERB_UP_KEYS) + .getOrPut(verb) { dimKeys("relay.verb.${if (received) DOWN else UP}.${verb.lowercase()}") }[dimIndex(mobile, foreground)] + + private const val UP = "up" + private const val DOWN = "down" + + private val VERB_UP_KEYS = ConcurrentHashMap>() + private val VERB_DOWN_KEYS = ConcurrentHashMap>() + + /** + * `relay.purpose.home_feed.sent` / `.bytes` — REQ frames and REQ bytes by the + * [SubPurpose] that asked for them. + * + * The counter that turns "REQ traffic is 64 % of upload" into an actionable + * name. Purpose travels on the filter itself (PR #3832's `ExplainedFilter`, + * which survives the `copy(since = …)` assemblers do after every EOSE), so this + * is a read, not a new registry. + * + * Cardinality is the enum, so it is bounded and stable. [PURPOSE_UNEXPLAINED] is + * its own bucket rather than folded into the enum's OTHER: a filter carrying no + * purpose at all means an assembler #3832 did not reach, which is a different + * fact from one that declared itself uncategorised — and if that bucket is large, + * the attribution below cannot be trusted. + * + * Memoized for the same reason [relayVerb] is, and more urgently: [relayPurposeDown] + * and [relayPurposeDownCount] both run on every inbound frame that names a + * subscription, so interpolating would build two strings per frame on the hottest + * path in the app. The purpose space is the enum plus the three fallbacks below. + */ + fun relayPurposeSent(purpose: String): String = purposeKeys(purpose)[P_SENT] + + fun relayPurposeBytes(purpose: String): String = purposeKeys(purpose)[P_BYTES] + + /** + * `relay.purpose.moderation.down` — bytes received on subscriptions opened for + * that purpose, resolved through the subscription id the frame carries. + * + * The upload counters answer "who is asking"; this answers "who is being + * answered", which is the larger number: inbound EVENT payload is ~74 % of relay + * traffic against ~26 % outbound. Without it, a fix to the REQ churn can only be + * credited with the upload it removes, when the interesting question is how much + * of the download it was causing — every re-subscription can make the relay + * re-send everything that matches. + */ + fun relayPurposeDown(purpose: String): String = purposeKeys(purpose)[P_DOWN] + + /** + * `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes. + * + * Bytes alone cannot separate "many small events delivered repeatedly" from "few + * large ones", and those want opposite fixes. With a count, `down / downn` is the + * average frame size per purpose, and the total frame count set against + * `crypto.verify.count` — which the cache pays once per event it accepts — bounds + * how much of the download is the same events arriving from different relays + * under the outbox fan-out. + */ + fun relayPurposeDownCount(purpose: String): String = purposeKeys(purpose)[P_DOWNN] + + /** + * `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how + * many carried an event already delivered. + * + * [relayEventsDupBytes] measures duplication across the whole client, which says + * how much is wasted but not where. The seventh reading needs exactly this split: + * `user_profile` was 57 % of download at ~17 KB per event, and whether that is + * mostly the same events arriving from many relays or mostly distinct large ones + * points at completely different fixes — suppress redundant delivery, or stop + * fetching the large thing per relay. + */ + fun relayPurposeDupBytes(purpose: String): String = purposeKeys(purpose)[P_DUPBYTES] + + private const val P_SENT = 0 + private const val P_BYTES = 1 + private const val P_DOWN = 2 + private const val P_DOWNN = 3 + private const val P_DUPBYTES = 4 + + private val PURPOSE_SUFFIXES = arrayOf("sent", "bytes", "down", "downn", "dupbytes") + + private val PURPOSE_KEYS = ConcurrentHashMap>() + + /** The five `relay.purpose..*` keys, indexed by the `P_` constants above. */ + private fun purposeKeys(purpose: String): Array = PURPOSE_KEYS.getOrPut(purpose) { Array(PURPOSE_SUFFIXES.size) { "relay.purpose.$purpose.${PURPOSE_SUFFIXES[it]}" } } + + /** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */ + const val PURPOSE_UNATTRIBUTED = "unattributed" + + /** A REQ whose filters carry no [ExplainedFilter] purpose. */ + const val PURPOSE_UNEXPLAINED = "unexplained" + + /** The enum's own OTHER, renamed: bare `other` is an [HTTP_ROLES] value and a reserved segment. */ + const val PURPOSE_OTHER = "otherpurpose" + + /** + * The key segment for a [SubPurpose]. The one place the enum is turned into a + * key, so the reserved-segment rename cannot drift between the producer and the + * test that guards it — which is exactly how it drifted the first time. + * + * Tabled by ordinal: this runs per REQ, and `name.lowercase()` would allocate a + * fresh String each time for one of a dozen fixed answers. + */ + fun purposeKeyPart(purpose: SubPurpose): String = PURPOSE_PARTS[purpose.ordinal] + + private val PURPOSE_PARTS = + Array(SubPurpose.entries.size) { + val purpose = SubPurpose.entries[it] + if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase() + } + + /** + * `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already + * has open on the current connection. + * + * The distinction the churn question turns on. A REQ that opens a new + * subscription is work; a REQ that replaces one already in flight is the client + * changing its mind, and at ~1 KB each that is pure cost. Measured against + * [relaySubsSent] it says what fraction of the upload is re-subscription rather + * than subscription. + */ + fun relaySubsResent( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_RESENT[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent") + + /** Half-open bounds, in ms, shared by the two connect-timing histograms below. */ + private val CONNECT_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000) + + /** + * `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the + * transport measured it. + * + * This is `onConnected`'s `pingMillis`, which `BasicOkHttpWebSocket` computes as + * `receivedResponseAtMillis - sentRequestAtMillis` and which this listener + * previously discarded. PR #3843 is the cautionary tale: `RelayObserver` derived + * the same quantity from `onConnecting -> onConnected` instead, and on a large + * fan-out published a 33.5 s median that was the client's own backlog rather than + * relay latency. Those timestamps bracket the request itself, so everything + * before it — queueing, DNS, TCP, TLS — is excluded. Zero or negative means the + * transport could not time it, and nothing is recorded rather than a fabricated 0. + */ + fun relayHandshake( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = HANDSHAKE.key(ms, mobile, foreground) + + private val HANDSHAKE = MsHistogram("relay.hs", CONNECT_BOUNDS_MS) { "${it}ms" } + + /** + * `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the + * upgrade request going out: dispatcher queueing, DNS, TCP, TLS. + * + * `(onConnected wall clock - onConnecting wall clock) - handshake`. This is the + * share of connect latency the app is responsible for rather than the relay, and + * it is what decides whether a high never-became-ready rate is relays being + * unreachable or ~500 simultaneous dials saturating name resolution and sockets. + * The dispatcher's own cap is not the constraint on a phone + * (`maxRequests = 1024`, `maxRequestsPerHost = 10`), so a large value here points + * at resolution and socket setup, not at a queue. + */ + fun relayDialGap( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = DIAL_GAP.key(ms, mobile, foreground) + + private val DIAL_GAP = MsHistogram("relay.gap", CONNECT_BOUNDS_MS) { "${it}ms" } + + /** + * `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many + * carried an event id already delivered recently. + * + * The outbox model asks many relays for the same authors, so one event is + * delivered once per relay that carries it. Relay download is ~65 % of all data + * on the release build, so the duplication factor decides whether the largest + * number in the ledger is content or repetition — a question no other counter + * here can answer, and one [VERIFY_COUNT] only proxies (it counts what the cache + * accepted, not what arrived, and only while dedup-before-verify holds). + * + * [relayEventsDupBytes] is the number that matters: bytes that arrived and were + * already held. + */ + fun relayEventsSeen( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_SEEN[dimIndex(mobile, foreground)] + + fun relayEventsDup( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_DUP[dimIndex(mobile, foreground)] + + fun relayEventsDupBytes( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_DUPB[dimIndex(mobile, foreground)] + + private val RELAY_EV_SEEN = dimKeys("relay.events.seen") + private val RELAY_EV_DUP = dimKeys("relay.events.dup") + private val RELAY_EV_DUPB = dimKeys("relay.events.dupbytes") + + /** + * `relay.trigger.netid` — reconnect *decisions*, by cause, not reconnects + * performed. + * + * Two reasons this is an upper bound, both of which matter when reading it: + * `NostrClient.reconnect` emits into a debounced flow that `subscribe` / + * `count` / `publish` / `onDisconnected` also feed very frequently, so a + * teardown can be coalesced away before it runs; and the flow's initial value + * fires one teardown per client construction with no trigger attributed. + */ + fun relayTrigger(cause: String): String = "relay.trigger.$cause" + + const val TRIGGER_NETID = "netid" + const val TRIGGER_TRANSPORT = "transport" + const val TRIGGER_TOR_POLICY = "torpolicy" + const val TRIGGER_CLASSIFICATION = "class" + const val TRIGGER_COLD_START = "coldstart" + const val TRIGGER_OFF = "off" + const val TRIGGER_BUZZ = "buzz" /** `worker.scheduledPost.runs` */ fun workerRuns(worker: String): String = "worker.$worker.runs" @@ -187,18 +786,32 @@ object UsageKeys { const val BATTERY_DRAIN_FG = "battery.drain.fg" const val BATTERY_DRAIN_BG = "battery.drain.bg" - fun dim( - mobile: Boolean, - foreground: Boolean, - ): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}" - - /** Sums every counter whose key matches all the given dot-delimited parts. */ + /** + * Sums every counter whose key matches all the given dot-delimited parts. + * + * Scans segments in place rather than `key.split('.')`: [UsageSummary.from] makes + * ~54 of these passes over the whole day bucket, the usage screen builds 16 + * summaries per entry on the main thread, and the churn counters roughly tripled + * the key count — none of which can ever match (that is what + * `noNewKeyContainsAReservedSegment` guarantees), so every split was pure waste. + */ fun Map.sumMatching(vararg parts: String): Long { var total = 0L for ((key, value) in this) { - val segments = key.split('.') - if (parts.all { it in segments }) total += value + if (parts.all { key.hasSegment(it) }) total += value } return total } + + /** True when `segment` is one of this key's whole dot-delimited segments. */ + private fun String.hasSegment(segment: String): Boolean { + var from = 0 + while (from <= length) { + var end = indexOf('.', from) + if (end < 0) end = length + if (end - from == segment.length && regionMatches(from, segment, 0, segment.length)) return true + from = end + 1 + } + return false + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 5deaa6928f..7bcd89d292 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -166,7 +166,7 @@ object BlossomPaymentHandler { val preimageResult = CompletableDeferred() try { - account.sendZapPaymentRequestFor(invoice, null) { response -> + account.zaps.sendZapPaymentRequestFor(invoice, null) { response -> // CompletableDeferred.complete is idempotent, so extra callbacks are harmless. preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 2f2ac4eba9..0af898dac4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -203,6 +203,13 @@ fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.st fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") +/** + * Tapping the always-on service notification. That notification's whole subject is the relay + * connections it is holding open, so it lands on the screen that explains them rather than on + * whatever tab happened to be last open. + */ +fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true) + private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$") fun uriToRoute( @@ -213,6 +220,9 @@ fun uriToRoute( val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull() return Route.Notification(scrollToEventId = scrollTo) } + if (isActiveSubscriptionsRoute(uri)) { + return Route.ActiveSubscriptions + } if (isHashtagRoute(uri)) { return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 78c89dc8e0..6a6085c555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Dao import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -258,11 +257,3 @@ class NewMessageTagger( return null } } - -interface Dao { - fun getOrCreateUser(hex: HexKey): User - - fun getOrCreateNote(hex: HexKey): Note - - fun getOrCreateAddressableNote(address: Address): AddressableNote? -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 6e08f8b3e2..67af29c2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -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(initialValue = null, linkText) { - value = accountViewModel.account.peekConcordInvite(linkText) + value = accountViewModel.account.concord.peekConcordInvite(linkText) } val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt new file mode 100644 index 0000000000..ed4aba59d0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes +import com.vitorpamplona.amethyst.commons.util.prettyMime +import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +/** + * The renderer for a declared file that none of the media viewers can display — a webxdc app, + * an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia] + * returns null for. + * + * It exists so those files have somewhere to land other than the video player: an unknown blob + * used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a + * zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the + * card costs no network round-trip — unlike routing the URL through the OpenGraph previewer, + * which would try to download the blob just to rediscover the type the event already declared. + */ +@Composable +fun FileAttachmentCard( + url: String, + description: String?, + mimeType: String?, + sizeInBytes: Long?, +) { + val uriHandler = LocalUriHandler.current + val filename = remember(url) { extractFilename(url) } + val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) } + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .clickable { uriHandler.openUri(url) }, + ) { + FileAttachmentRow( + symbol = MaterialSymbols.AttachFile, + // The alt/content text names the file for a human ("Webxdc app: Quake"); + // the hashed URL basename is the fallback when the event omits it. + title = description?.ifBlank { null } ?: filename, + subtitle = subtitle, + titleMaxLines = 2, + ) + + Spacer(modifier = DoubleVertSpacer) + } +} + +/** + * The icon + title + subtitle row shared by every card that stands in for a file it can't + * render inline: this one and the PDF placeholder/skeleton in + * [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard]. + */ +@Composable +internal fun FileAttachmentRow( + symbol: MaterialSymbol, + title: String, + subtitle: String?, + titleMaxLines: Int = 1, +) { + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } +} + +/** "APK · 16 MB", dropping either half when the event doesn't declare it. */ +private fun fileSubtitle( + mimeType: String?, + sizeInBytes: Long?, +): String? = + listOfNotNull( + mimeType?.ifBlank { null }?.let(::prettyMime), + sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes), + ).joinToString(" · ").ifEmpty { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 02592b6a06..9a96186328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -25,6 +25,8 @@ import android.net.Uri import androidx.annotation.VisibleForTesting import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap +import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -37,6 +39,7 @@ object ShareHelper { private const val DEFAULT_IMAGE_EXTENSION = "jpg" private const val DEFAULT_VIDEO_EXTENSION = "mp4" private const val SHARED_FILE_PREFIX = "shared_media" + private const val GENERIC_BINARY_MIME_TYPE = "application/octet-stream" data class SharableFile( val uri: Uri, @@ -65,6 +68,25 @@ object ShareHelper { private val MP4_BRAND_MP42 = "mp42".toByteArray() private val MOV_BRAND_QT = "qt ".toByteArray() + /** + * Picks the MIME type to put on an `ACTION_SEND` intent. + * + * `Intent.type` has to be a real `type/subtype` — an `IntentFilter` matches the two halves + * separately, so a slash-less value matches nothing and the chooser opens empty. The declared + * type is author-supplied and may be unusable (see [normalizeMimeType]), so it is treated as a + * hint; [fileExtension] is the safer signal because [getMediaExtension] sniffs it from the + * file's magic numbers rather than trusting the event. + */ + internal fun resolveShareMimeType( + declaredMimeType: String?, + fileExtension: String, + ): String = + normalizeMimeType(declaredMimeType) + ?: mimeTypeMap[fileExtension.lowercase()] + // Unreachable today: getMediaExtension only ever returns keys of mimeTypeMap. Kept so + // the return type stays a well-formed MIME if that ever stops holding. + ?: GENERIC_BINARY_MIME_TYPE + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3e560530cc..1122b73a57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -1103,7 +1103,7 @@ private suspend fun shareImageFile( val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) // Determine mime type, use provided or derive from extension - val determinedMimeType = mimeType ?: "image/$fileExtension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension) // Create share intent val shareIntent = @@ -1161,7 +1161,7 @@ private suspend fun shareVideoFile( sharedFile = sharableFile // Determine mime type - val determinedMimeType = mimeType ?: "video/$extension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension) // Create share intent val shareIntent = @@ -1227,7 +1227,7 @@ private suspend fun shareLocalVideoFile( val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile) // Determine mime type - val determinedMimeType = mimeType ?: "video/$extension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension) // Create share intent val shareIntent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index c352077e42..0b975ede46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) { private fun FilenameRow( filename: String, subtitle: String, -) { - Row( - modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - symbol = MaterialSymbols.PictureAsPdf, - contentDescription = null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = filename, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } -} +) = FileAttachmentRow( + symbol = MaterialSymbols.PictureAsPdf, + title = filename, + subtitle = subtitle, +) private fun renderFirstPage( file: java.io.File, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index da098ef816..7a59ab20f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -50,6 +50,9 @@ import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert @@ -138,6 +141,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen @@ -238,6 +242,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodca import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.PollResultsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen @@ -252,6 +257,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions.ActiveSubscriptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen @@ -262,6 +268,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen @@ -339,6 +346,15 @@ fun AppNavigation( CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, + // Provide the shared finder CompositionLocals so any commons composable that + // uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note) + // overloads works when rendered on Android (they error() if unprovided). Android's + // own UI uses the AccountViewModel overloads and doesn't strictly need these, but + // providing them removes the runtime trap for shared composables reaching the + // logged-in tree. (The :napplet process never renders these composables.) + LocalUserFinder provides accountViewModel.dataSources().userFinder, + LocalUserFinderAccount provides accountViewModel.account, + LocalEventFinder provides accountViewModel.dataSources().eventFinder, ) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { @@ -577,6 +593,7 @@ fun BuildNavigation( composableFromEnd { MessagesSettingsScreen(accountViewModel, nav) } composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } + composableFromEnd { DrawerSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } @@ -591,6 +608,8 @@ fun BuildNavigation( composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + + composableFromEndArgs { ActiveSubscriptionsScreen(accountViewModel, nav) } composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } @@ -613,6 +632,7 @@ fun BuildNavigation( composableFromEndArgs { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ShareNoteAsQrScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } + composableFromEndArgs { PollResultsScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { UrlScreen(it, accountViewModel, nav) } @@ -742,6 +762,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteLinksScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordEditScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 1457927832..1fc4a00534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -20,13 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity @@ -58,29 +56,3 @@ fun keyboardAsState(): State { } } } - -/** - * 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 51f545d95c..5959cb5c25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.os.Build import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.serialization.Serializable /** - * Stable identifiers for every drawer destination that the user can pin to the bottom bar. - * Order in this enum has no semantic meaning — the user picks a subset and an order at runtime. + * Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a + * subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections). + * Order in this enum has no semantic meaning. */ @Serializable enum class NavBarItem { @@ -84,6 +84,18 @@ enum class NavBarItem { FAVORITE_ALGO_FEEDS, } +private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name } + +/** + * Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings + * blob synced from a newer client can name a destination that doesn't exist here yet, and that must + * degrade to "ignore this one row" rather than failing the decode of the whole blob. + */ +fun navBarItemsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] } + +/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */ +fun Set.toNames(): List = map { it.name }.sorted() + data class NavBarItemDef( val id: NavBarItem, val labelRes: Int, @@ -443,34 +455,6 @@ val DefaultBottomBarItems: List = /** The default bottom bar as unified entries (all built-in; favorites are added by the user). */ val DefaultBottomBarEntries: List = 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 = - listOf( - NavBarItem.HOME, - NavBarItem.MESSAGES, - NavBarItem.VIDEO, - NavBarItem.BROWSER, - NavBarItem.DISCOVER, - NavBarItem.NOTIFICATIONS, - ) - -val DrawerYouItems: List = - listOf( - NavBarItem.PROFILE, - NavBarItem.MY_LISTS, - NavBarItem.BOOKMARKS, - NavBarItem.WEB_BOOKMARKS, - NavBarItem.DRAFTS, - NavBarItem.SCHEDULED_POSTS, - NavBarItem.INTEREST_SETS, - NavBarItem.BLOSSOM_DATA, - NavBarItem.EMOJI_PACKS, - NavBarItem.WALLET, - NavBarItem.NOSTR_SIGNER, - ) - /** * A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The * catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat @@ -479,6 +463,7 @@ val DrawerYouItems: List = */ data class NavBarCategory( val titleRes: Int, + val icon: MaterialSymbol, val items: List, ) @@ -491,6 +476,7 @@ val BottomBarCategories: List = listOf( NavBarCategory( R.string.bottom_bar_category_main, + MaterialSymbols.Home, listOf( NavBarItem.HOME, NavBarItem.MESSAGES, @@ -501,6 +487,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_chats, + MaterialSymbols.Group, listOf( NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, @@ -510,6 +497,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_you, + MaterialSymbols.AccountCircle, listOf( NavBarItem.PROFILE, NavBarItem.MY_LISTS, @@ -527,6 +515,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_feeds, + MaterialSymbols.Subscriptions, listOf( NavBarItem.ARTICLES, NavBarItem.LONGS, @@ -553,6 +542,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_apps, + MaterialSymbols.Apps, listOf( NavBarItem.BROWSER, NavBarItem.FAVORITE_APPS, @@ -563,43 +553,9 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_other, + MaterialSymbols.Settings, listOf( NavBarItem.SETTINGS, ), ), ) - -val DrawerFeedsItems: List = - 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, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f0221e563d..6009583b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef @@ -584,42 +582,17 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { + // Per-account, synced through the NIP-78 app-specific data event, and edited on the + // Side Menu settings screen. Empty (the default) means the full stock drawer. + val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle() + Column(modifier) { - CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav) - CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav) - CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav) - - CollapsibleSection(title = R.string.drawer_section_create) { - NavigationRow( - title = R.string.share_hls_video, - icon = MaterialSymbols.SettingsInputAntenna, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.NewHlsVideo, - ) - - if (isDebug) { - NavigationRow( - title = R.string.route_chess, - icon = MaterialSymbols.ChessKnight, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Chess, - ) - } - } - - CollapsibleSection(title = R.string.drawer_section_system) { - IconRowRelays( - accountViewModel = accountViewModel, - onClick = { - nav.closeDrawer() - nav.nav(Route.EditRelays) - }, - ) - - NavBarCatalog[NavBarItem.SETTINGS]?.let { - CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav) + DrawerSections.forEach { section -> + // Keyed by section: hiding the last row of a section removes it from the drawer + // entirely, and without a key the sections below would slide up into its slots and + // inherit its CollapsibleSection expanded/collapsed state. + key(section.id) { + CatalogSection(section, hidden, accountViewModel, nav) } } @@ -634,22 +607,64 @@ fun ListContent( } } +/** The Create section's rows — composer entry points, none of which is a catalog destination. */ +@Composable +private fun CreateRows(nav: INav) { + NavigationRow( + title = R.string.share_hls_video, + icon = MaterialSymbols.SettingsInputAntenna, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.NewHlsVideo, + ) + + if (isDebug) { + NavigationRow( + title = R.string.route_chess, + icon = MaterialSymbols.ChessKnight, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Chess, + ) + } +} + /** - * Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog]. - * Profile gets the primary-colored tint; every other item uses onBackground. + * Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't + * switched off. Profile gets the primary-colored tint; every other item uses onBackground. + * + * A section with nothing left to show renders nothing at all — an empty, permanently collapsed + * heading is just noise. Two sections always have something: Create is entirely fixed rows, and + * System carries the relay-status row (not a catalog destination — it shows a live counter). */ @Composable fun CatalogSection( - titleRes: Int, - ids: List, + section: DrawerSection, + hidden: Set, accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary val onBackground = MaterialTheme.colorScheme.onBackground - CollapsibleSection(title = titleRes) { - ids.forEach { id -> + val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) } + if (visible.isEmpty() && !section.hasFixedRows) return + + CollapsibleSection(title = section.titleRes) { + when (section.id) { + DrawerSectionId.CREATE -> CreateRows(nav) + DrawerSectionId.SYSTEM -> + IconRowRelays( + accountViewModel = accountViewModel, + onClick = { + nav.closeDrawer() + nav.nav(Route.EditRelays) + }, + ) + else -> {} + } + + visible.forEach { id -> NavBarCatalog[id]?.let { def -> val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground if (def.id == NavBarItem.SCHEDULED_POSTS) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt new file mode 100644 index 0000000000..5c649a9a60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.drawer + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem + +/** + * Which drawer rows the user cannot hide. + * + * Settings is the only one, and it is mandatory for a specific reason: it is the route back to the + * screen that hides rows in the first place. Hiding it would let a user lock themselves out of their + * own configuration. Everything else the drawer always shows — the profile header, the relay-status + * row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row, + * so it is present by construction and never appears in the hidden set. + */ +val MandatoryDrawerItems: Set = 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, + 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, + item: NavBarItem, + ): Set = + 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): Set = hidden - MandatoryDrawerItems + + /** The rows of [section] to render, in the section's fixed order. */ + fun visibleItems( + section: DrawerSection, + hidden: Set, + ): List = 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, + ): 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, + section: DrawerSection, + ): Set = hidden + section.items.filter { it !in MandatoryDrawerItems } + + /** Shows every row of [section] again. */ + fun showAll( + hidden: Set, + section: DrawerSection, + ): Set = hidden - section.items.toSet() + + /** Total hidden rows across every section — the count the settings screen shows at the top. */ + fun totalHidden(hidden: Set): Int = DrawerSections.sumOf { hiddenCount(it, hidden) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt new file mode 100644 index 0000000000..2cf323c39b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.drawer + +import android.os.Build +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem + +/** + * The drawer's layout: which destinations it lists, under which heading, in which order. + * + * One list drives two screens — [ListContent] renders the visible rows of each section, and the Side + * Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a + * section's list therefore surfaces it in the drawer *and* in its configuration screen without + * touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't + * filed into exactly one section. + * + * Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a + * menu whose headings move around is harder to learn, not easier. The only per-account choice is + * which rows are visible — see [DrawerItemVisibility]. + */ +@Immutable +data class DrawerSection( + val id: DrawerSectionId, + val titleRes: Int, + val icon: MaterialSymbol, + val items: List, + /** + * 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 = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.BROWSER, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) + +private val DrawerYouItems: List = + 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 = + 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 = + 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 = setOf(NavBarItem.FAVORITE_APPS) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt new file mode 100644 index 0000000000..c36e3bc30b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +/** How long to wait for the IME inset to reach zero before navigating anyway. */ +const val IME_SETTLE_TIMEOUT_MS = 700L + +/** + * Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation + * in the app serializes the IME and window animations instead of overlapping them. + * + * Navigating while the keyboard is up races the window animation against the IME's close animation. + * On release builds — fast enough that the window animation wins — the IME + * [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before + * its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it + * stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left — + * freezes at the keyboard height until some later inset pass happens to rebalance it. + * + * This is not a composer-screen problem, which is why it lives here rather than in the screens. + * Any destination that can hold focus in a text field can strand the padding on the way out, by any + * exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the + * clearest case — it focuses its field on arrival, so the keyboard is already up before the user + * has done anything, and every way out of it is a navigation. + */ +fun interface ImeSettler { + suspend fun settle() + + companion object { + /** For [EmptyNav] and previews, where there is no window to read insets from. */ + val None = ImeSettler { } + } +} + +/** + * Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler + * and the padding can never disagree about whether the keyboard is gone. + * + * Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded + * by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this + * guards against, navigation still proceeds rather than stranding the user on the screen. + */ +@Composable +fun rememberImeSettler(): ImeSettler { + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + return remember(density, imeInsets, keyboard, focusManager) { + ImeSettler { + if (imeInsets.getBottom(density) > 0) { + focusManager.clearFocus(true) + keyboard?.hide() + withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) { + snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 1526d0be72..ac0adde7bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -44,6 +44,13 @@ import kotlin.reflect.KClass class Nav( val controller: NavHostController, override val navigationScope: CoroutineScope, + /** + * Awaited before every transition below. Leaving a screen while the soft keyboard is still + * animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes + * through this class, so this is the one place that has to get it right — no screen, top bar + * or back handler needs to think about the keyboard on its way out. + */ + private val ime: ImeSettler = ImeSettler.None, ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) @@ -63,6 +70,7 @@ class Nav( override fun nav(route: Route) { navigationScope.launch { + ime.settle() if (getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) } @@ -71,6 +79,7 @@ class Nav( override fun nav(computeRoute: suspend () -> Route?) { navigationScope.launch { + ime.settle() val route = computeRoute() if (route != null && getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) @@ -80,6 +89,7 @@ class Nav( override fun newStack(route: Route) { navigationScope.launch { + ime.settle() controller.navigate(route) { popUpTo(route) { inclusive = true @@ -91,6 +101,7 @@ class Nav( override fun navBottomBar(route: Route) { navigationScope.launch { + ime.settle() controller.navigate(route) { // Clear sibling bottom-nav entries but keep Home (the start // destination) below, so back-swipe from any tab returns to @@ -110,7 +121,28 @@ class Nav( } // Mark this entry as a tab root: hides the back arrow in canPop // and skips the horizontal slide in composableFromEnd. - controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true + // saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a + // single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one + // pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps + // pinned, tapping the second one landed on the first one's URL, and the lookup below then threw + // `No destination with route …WebApp/ is on the NavController's back stack`. + // + // When the entry we asked for isn't there, take the tab fresh (no restoreState, and no + // launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel + // state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose + // destination nothing else shares still restore normally, which is what this is here for. + val entry = + runCatching { controller.getBackStackEntry(route) }.getOrNull() + ?: run { + controller.navigate(route) { + popUpTo(Route.Home) { + inclusive = false + saveState = true + } + } + runCatching { controller.getBackStackEntry(route) }.getOrNull() + } + entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true) } } @@ -149,6 +181,7 @@ class Nav( override fun popBack() { navigationScope.launch { + ime.settle() controller.navigateUp() } } @@ -159,6 +192,7 @@ class Nav( klass: KClass, ) { navigationScope.launch { + ime.settle() controller.navigate(route) { popUpTo(klass) { inclusive = true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt index f6c42c0aa3..8b9a2d0e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController fun rememberNav(): Nav { val navController = rememberNavController() val scope = rememberCoroutineScope() + val ime = rememberImeSettler() - return remember(navController, scope) { - Nav(navController, scope) + return remember(navController, scope, ime) { + Nav(navController, scope, ime) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 2a327ec93b..b5bb51626e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -457,6 +457,8 @@ sealed class Route { @Serializable object BottomBarSettings : Route() + @Serializable object DrawerSettings : Route() + @Serializable object HomeTabsSettings : Route() @Serializable object ProfileUiSettings : Route() @@ -497,6 +499,9 @@ sealed class Route { @Serializable object EditRelays : Route() + /** Diagnostic: explains why the app currently holds the subscriptions it holds. */ + @Serializable object ActiveSubscriptions : Route() + @Serializable object EventSync : Route() @Serializable object RequestToVanish : Route() @@ -570,6 +575,10 @@ sealed class Route { val noteId: String, ) : Route() + @Serializable data class PollResults( + val noteId: String, + ) : Route() + @Serializable data class Hashtag( val hashtag: String, ) : Route() @@ -825,6 +834,10 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable data class ConcordInviteLinks( + val communityId: String, + ) : Route() + @Serializable object ConcordCreate : Route() // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt new file mode 100644 index 0000000000..09e1ab078c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -0,0 +1,147 @@ +/* + * 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.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboard +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.cachedTranslation +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.types.displayedNoteText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** Both texts of a translated note, held while the user picks which one to copy. */ +@Immutable +data class CopyTextChoice( + val original: String, + val translated: String, +) + +/** + * The "Copy Text" flow shared by every menu that copies an event's text. + * + * The copy menus sit far from the `TranslatableRichTextViewer` that rendered (and possibly + * translated) the note, so instead of plumbing the translated string down the hierarchy this + * flow re-derives it from [cachedTranslation]: the process-wide translation cache keyed by + * (content, language settings). By the time any copy menu is reachable the note has been + * rendered, which is what populated that cache — so a hit means the user is looking at a + * translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly. + * + * What gets copied — and what the cache is keyed on — is [displayedNoteText], the same string + * the viewer rendered, not the raw event content: a NIP-14 subject is part of what the user is + * reading and of what was translated. + * + * Returns the click handler for the menu entry, taking the note the menu belongs to and the + * version of it the screen is showing (the same note unless the post was edited — the body + * comes from the version, the subject from the note itself, exactly as the viewer composes + * them). [onCopied] runs after the text lands on the + * clipboard, [onDismiss] when the chooser is cancelled without copying **or** when the note + * can't be decrypted at all (a read-only account, a refused signer) so the menu still closes + * instead of hanging on a copy that will never happen. Callers must keep their menu in + * composition until one of the two runs, because the chooser dialog is emitted from this + * composable. + */ +@Composable +fun copyNoteTextAction( + accountViewModel: AccountViewModel, + onCopied: () -> Unit, + onDismiss: () -> Unit, +): (note: Note, versionShown: Note) -> Unit { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val choice = remember { mutableStateOf(null) } + + val copy: (String) -> Unit = { text -> + scope.launch { + clipboardManager.setText(text) + onCopied() + } + } + + choice.value?.let { options -> + CopyTextChooserDialog( + onCopyOriginal = { + choice.value = null + copy(options.original) + }, + onCopyTranslated = { + choice.value = null + copy(options.translated) + }, + onDismiss = { + choice.value = null + onDismiss() + }, + ) + } + + return { note, versionShown -> + accountViewModel.decryptOrNull(versionShown) { decrypted -> + if (decrypted == null) { + onDismiss() + } else { + val original = displayedNoteText(note, decrypted) + val translated = cachedTranslation(original, accountViewModel) + if (translated == null) { + copy(original) + } else { + choice.value = CopyTextChoice(original, translated) + } + } + } + } +} + +@Composable +fun CopyTextChooserDialog( + onCopyOriginal: () -> Unit, + onCopyTranslated: () -> Unit, + onDismiss: () -> Unit, +) { + M3ActionDialog( + title = stringRes(R.string.copy_text), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow( + icon = MaterialSymbols.ContentCopy, + text = stringRes(R.string.copy_text_original), + onClick = onCopyOriginal, + ) + M3ActionRow( + icon = MaterialSymbols.Translate, + text = stringRes(R.string.copy_text_translated), + onClick = onCopyTranslated, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d78cb1919d..0363421c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.textNoteModifications import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -275,8 +276,8 @@ fun CardBody( val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) // Concord moderation: only present when this account may actually act. - val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null } - val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) } + val canConcordBan = remember(note) { accountViewModel.account.concord.concordBanTarget(note) != null } + val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) } val showConcordBanDialog = remember { mutableStateOf(false) } if (showConcordBanDialog.value) { @@ -299,20 +300,31 @@ fun CardBody( ) } + // "Copy Text" copies the version on screen: an edited post renders its newest modification + // by default (EditState.updateModifications), and the 3-dot menu already copies that one. + // Reading `edits` is a hard-referenced in-memory fold, so no cache scan here. + val noteVersionToCopy = remember(note) { note.textNoteModifications().lastOrNull() ?: note } + + // When the rendered note was translated, tapping Copy Text opens a chooser + // (Copy Original / Copy Translated) on top of this popup; the popup stays up + // until the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = { + showToast(R.string.copied_note_text_to_clipboard) + onDismiss() + }, + onDismiss = onDismiss, + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { NoteQuickActionItem( icon = MaterialSymbols.ContentCopy, label = stringRes(R.string.quick_action_copy_text), ) { - accountViewModel.decrypt(note) { - scope.launch { - clipboardManager.setText(it) - showToast(R.string.copied_note_text_to_clipboard) - } - } - - onDismiss() + copyNoteText(note, noteVersionToCopy) } VerticalDivider(color = primaryLight) NoteQuickActionItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 77d435e736..f8546909b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { totalZapped = totalZapped() wasZappedByLoggedInAccount = false - wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0) + wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0) canZap.value = checkIfCanZap() tallies.forEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4cc64d396a..a4cb62e6a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -190,7 +190,7 @@ class UserSuggestionState( if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { rankPriorityFirst( - account.cache.findUsersStartingWith(prefix, account), + account.cache.search.findUsersStartingWith(prefix, account), priorityPubkeys(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index e05844f5c5..ba13c44b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.LightRedColor @@ -129,14 +130,21 @@ fun noteActionSections( ) } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = handlers.onDismiss, + onDismiss = handlers.onDismiss, + ) + val copyAndShare = buildList { add( NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) { - accountViewModel.decrypt(noteVersionToCopy) { - scope.launch { clipboardManager.setText(it) } - } - handlers.onDismiss() + copyNoteText(note, noteVersionToCopy) }, ) add( @@ -371,7 +379,7 @@ fun noteActionSections( // message's author (both return null unless it's a Concord message this // account may act on). Promote/demote is instant; a ban re-keys the // community, so it defers to the surface's confirmation dialog. - val concordAdmin = accountViewModel.account.concordAdminTarget(note) + val concordAdmin = accountViewModel.account.concord.concordAdminTarget(note) if (concordAdmin != null) { val isAdmin = concordAdmin.third add( @@ -384,7 +392,7 @@ fun noteActionSections( }, ) } - if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) { + if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) { add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 5bbf7bfdfa..03c78556de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes -// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a +// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a // fixed pixel count subtracted from raw size.width, so its share of the tile grows as density // falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable // resolution on low-density screens. Deriving the size from the available column width keeps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 62d91d39aa..96730249ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,50 +46,103 @@ fun FileHeaderDisplay( ) { val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return + val mimeType = remember(note) { event.mimeType() } + val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - val content: BaseMediaContent = - remember(note) { - val blurHash = event.blurhash() - val thumbHash = event.thumbhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - val mimeType = event.mimeType() - - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - } - } - + // The sensitivity gate wraps both branches: a content warning is about the file, not about + // which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate. SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, - accountViewModel = accountViewModel, - ) + if (content == null) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + } else { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } } } + +/** + * Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob. + * + * Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71 + * video event the kind itself asserts nothing about how to render the payload. A null here means + * the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player. + */ +internal fun FileHeaderEvent.toMediaContent( + note: Note, + url: String, + mimeType: String?, +): BaseMediaContent? { + val blurHash = blurhash() + val thumbHash = thumbhash() + val hash = hash() + val dimensions = dimensions() + val description = fileDescription() + val uri = note.toNostrUri() + + return when (RichTextParser.classifyMedia(url, mimeType)) { + MediaContentKind.IMAGE -> + MediaUrlImage( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.VIDEO -> + MediaUrlVideo( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.PDF -> + MediaUrlPdf( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + null -> null + } +} + +/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */ +@Composable +internal fun FileHeaderAttachmentCard( + event: FileHeaderEvent, + url: String, + mimeType: String?, +) { + val description = remember(event) { event.fileDescription() } + val sizeInBytes = remember(event) { event.size()?.toLong() } + + FileAttachmentCard( + url = url, + description = description, + mimeType = mimeType, + sizeInBytes = sizeInBytes, + ) +} + +/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */ +private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt index c3e621001b..005e1ba19e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -150,7 +150,7 @@ fun GoalProgressBar( LaunchedEffect(key1 = zapsState) { zapsState?.note?.let { - val newZapAmount = accountViewModel.account.calculateZappedAmount(note) + val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note) var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat() if (percentage > 1) percentage = 1f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 0bcc0df398..50d10b29cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -174,6 +176,7 @@ fun InnerRenderPoll( event = event, pollState = note.pollState(), accountViewModel = accountViewModel, + footerContent = { if (!makeItShort) PollResultsLink(note, nav) }, galleryUser = { user -> ClickableUserPicture( user, @@ -205,6 +208,38 @@ fun InnerRenderPoll( } } +/** + * The way out of the card. The avatar stack tops out at four faces and the "+N" chip counts people + * the card has no room to show — this is the door to the rest of them. + * + * Rendered only from [RenderResults], i.e. only once the card is already showing the tally. Putting + * it beside the voting controls would hand every reader a one-tap bypass of the vote-first gate, + * and — since opening the screen counts as opting in — permanently so. + * + * Shown at zero votes too. A card with no votes is exactly when a reader doubts the number, and the + * page is the only place that can answer them: it says whether it is still loading, and what the + * relays claim exists against what we hold. Hiding the link there left the page's own "no votes + * yet" state unreachable through the only door into it. + */ +@Composable +private fun PollResultsLink( + note: Note, + nav: INav, +) { + val tally by note.pollState().responses.collectAsStateWithLifecycle() + val voters = tally.totalVoters() + + Text( + text = pluralStringResource(R.plurals.poll_results_vote_count_link, voters, voters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clickable { nav.nav(Route.PollResults(note.idHex)) } + .padding(vertical = 4.dp), + ) +} + @Stable class PollCard( val options: List, @@ -231,6 +266,7 @@ fun RenderPollCard( pollState: PollResponsesCache, accountViewModel: AccountViewModel, galleryUser: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit = {}, labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { val card = @@ -278,6 +314,7 @@ fun RenderPollCard( onViewResults = { accountViewModel.markPollResultsViewed(event.id, event.endsAt()) }, hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) }, resultContent = galleryUser, + footerContent = footerContent, labelContent = labelContent, ) } @@ -290,28 +327,29 @@ fun RenderPollCard( onViewResults: () -> Unit = {}, hasViewedResults: () -> Boolean = { false }, resultContent: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit = {}, labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { Column( verticalArrangement = SpacedBy5dp, ) { if (card.isMyPoll) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { val haveIVoted = card.haveIVoted() if (haveIVoted) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { // waits for vote val haveIVoted by card.haveIVotedFlow.collectAsStateWithLifecycle(haveIVoted) if (haveIVoted) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else if (card.hasEnded() || hasViewedResults()) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { var viewingResults by remember { mutableStateOf(false) } if (viewingResults) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { when (card.type) { PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) @@ -437,6 +475,7 @@ private fun ColumnScope.RenderMultiChoiceOptions( private fun RenderResults( card: PollCard, resultContent: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit, labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { val showGallery = @@ -451,6 +490,8 @@ private fun RenderResults( labelContent(pollItem.code, pollItem.label) } } + + footerContent() } @Composable @@ -536,6 +577,14 @@ private fun RenderClosedItem( UserGallery(tally, resultContent) } + // The percentage alone never said how many people that was. + Text( + text = tally.size.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(end = 6.dp), + ) + Text( text = "${(tally.percent * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, @@ -567,37 +616,54 @@ fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier { } } +/** Faces drawn before the rest collapse into a "+N" chip. */ +private const val GALLERY_FACES = PollResponsesCache.GALLERY_FACES + @Composable fun UserGallery( tally: TallyResults, galleryUser: @Composable RowScope.(user: User) -> Unit, -) { - if (tally.users.isNotEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((-10).dp), - ) { - tally.users.take(4).forEach { - key(it.pubkeyHex) { - galleryUser(it) - } - } +) = UserGallery(tally.topUsers(GALLERY_FACES), tally.size, galleryUser) - if (tally.users.size > 4) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .size(Size25dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), - ) { - Text( - text = "+" + showCount(tally.users.size - 4), - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurface, - ) - } +/** + * The faces behind a count: a few overlapping avatars, then "+N" for everyone who didn't fit. + * + * Takes the shown users and the true total rather than a tally, so the poll card and the poll + * results screen draw the same widget from their different sources instead of each owning a copy + * that can drift in size, spacing or cap. + */ +@Composable +fun UserGallery( + shown: List, + total: Int, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + if (total <= 0) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + shown.forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (total > shown.size) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(total - shown.size), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 059fa68747..8e999414f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -65,6 +65,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.util.prettyMime import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note @@ -766,27 +767,6 @@ fun RenderSoftwareAsset( } } -internal fun prettyMime(mime: String): String = - when (mime) { - "application/vnd.android.package-archive" -> "APK" - "application/vnd.apple.ipa" -> "IPA" - "application/x-apple-diskimage" -> "DMG" - "application/vnd.apple.installer+xml" -> "PKG" - "application/x-msi" -> "MSI" - "application/vnd.appimage" -> "AppImage" - "application/vnd.flatpak" -> "Flatpak" - "application/vnd.oci.image.manifest.v1+json" -> "OCI" - "application/x-executable" -> "ELF" - "application/x-mach-binary" -> "Mach-O" - "application/vnd.microsoft.portable-executable" -> "EXE" - "application/vsix" -> "VSIX" - "application/x-chrome-extension" -> "CRX" - "application/x-xpinstall" -> "XPI" - "application/wasm" -> "WASM" - "application/webbundle" -> "Web Bundle" - else -> mime - } - internal fun formatBytes(bytes: Long): String { if (bytes < 1024L) return "$bytes B" val kb = bytes / 1024.0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index b9954841e1..02347f4c91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -69,6 +69,27 @@ enum class ReplyRenderType { NONE, } +/** + * The text [RenderTextEvent] puts on screen for [note] given its decrypted [body]: a NIP-14 + * subject the body doesn't already repeat is prepended to it. + * + * This is the exact string handed to `TranslatableRichTextViewer`, so it is also the key the + * translation cache stores the result under. The copy-text menus look their translation up by + * the same function — keying on the raw body instead would miss the entry for every + * subject-carrying note and silently copy the untranslated text. + */ +fun displayedNoteText( + note: Note, + body: String, +): String { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null } + return if (subject != null && !body.contains(subject, ignoreCase = true)) { + "$subject\n\n$body" + } else { + body + } +} + @Composable fun RenderTextEvent( note: Note, @@ -177,15 +198,7 @@ fun RenderTextEvent( body } - val eventContent = - remember(newBody) { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null } - if (!subject.isNullOrBlank() && !newBody.contains(subject, ignoreCase = true)) { - "$subject\n\n$newBody" - } else { - newBody - } - } + val eventContent = remember(newBody) { displayedNoteText(note, newBody) } // A boosted note inside a zap/nutzap/onchain activity card is always shown as a // compact 2-line preview, even when the logged-in user is only a zap-split diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index d00bd6d2e8..1d8cdcc3d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -88,7 +89,9 @@ fun VideoDisplay( val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE val uri = note.toNostrUri() if (isImage) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 0abc16ac93..4f5c661810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -55,7 +56,9 @@ fun JustVideoDisplay( val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a247e1cf1e..3825dd0f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Dao import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.UiSettingsFlow @@ -88,11 +89,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 @@ -548,7 +549,7 @@ class AccountViewModel( // public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an // existing Concord reaction is a follow-up; for now this only adds one.) if (note.inGatherers?.any { it is ConcordChannel } == true) { - launchSigner { account.reactToConcordMessage(note, reaction) } + launchSigner { account.concord.reactToConcordMessage(note, reaction) } return } @@ -606,15 +607,15 @@ class AccountViewModel( /** Ban the author of a Concord channel message (no-op unless this account may ban them). */ fun banConcordMember(note: Note) { - val (communityId, member) = account.concordBanTarget(note) ?: return - launchSigner { account.banConcordMember(communityId, member) } + val (communityId, member) = account.concord.concordBanTarget(note) ?: return + launchSigner { account.concord.banConcordMember(communityId, member) } } /** Toggle the Admin role on the author of a Concord channel message (owner only). */ fun toggleConcordAdmin(note: Note) { - val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return + val (communityId, member, isAdmin) = account.concord.concordAdminTarget(note) ?: return launchSigner { - if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member) + if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member) } } @@ -624,7 +625,7 @@ class AccountViewModel( member: HexKey, makeAdmin: Boolean, ) = launchSigner { - if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) + if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member) } /** @@ -640,7 +641,7 @@ class AccountViewModel( member: HexKey, roleIds: List, ) = launchSigner { - if (!account.grantConcordRole(communityId, member, roleIds)) { + if (!account.concord.grantConcordRole(communityId, member, roleIds)) { toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed) } } @@ -651,7 +652,7 @@ class AccountViewModel( member: HexKey, ban: Boolean, ) = launchSigner { - if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) + if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member) } /** @@ -663,7 +664,7 @@ class AccountViewModel( communityId: String, member: HexKey, ) = launchSigner { - account.refoundConcordCommunity(communityId, setOf(member)) + account.concord.refoundConcordCommunity(communityId, setOf(member)) } /** @@ -683,7 +684,7 @@ class AccountViewModel( else -> emptyList() } }.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } - account.importConcordCommunities(pinnedRelays) + account.concord.importConcordCommunities(pinnedRelays) } /** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */ @@ -691,12 +692,12 @@ class AccountViewModel( communityId: String, channelIdHex: String, ) = viewModelScope.launch(Dispatchers.IO) { - account.sendConcordTyping(communityId, channelIdHex) + account.concord.sendConcordTyping(communityId, channelIdHex) } fun sendBuzzTyping(channel: RelayGroupChannel) = viewModelScope.launch(Dispatchers.IO) { - account.sendBuzzTyping(channel) + account.relayGroups.sendBuzzTyping(channel) } @Immutable @@ -843,7 +844,7 @@ class AccountViewModel( afterTimeInSeconds: Long, ): Boolean = withContext(Dispatchers.IO) { - account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds) + account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds) } suspend fun calculateZapAmount(zappedNote: Note): String { @@ -854,7 +855,7 @@ class AccountViewModel( val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex) return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.IO) { - val nwc = account.calculateZappedAmount(zappedNote) + val nwc = account.zaps.calculateZappedAmount(zappedNote) showAmount(nwc + java.math.BigDecimal(ownPendingOnchain)) } } else { @@ -866,7 +867,7 @@ class AccountViewModel( val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.IO) { - val newZapAmount = account.calculateZappedAmount(zappedNote) + val newZapAmount = account.zaps.calculateZappedAmount(zappedNote) var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() if (percentage > 1) { @@ -1202,7 +1203,7 @@ class AccountViewModel( .isNotEmpty() /** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */ - fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay() + fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay() /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 @@ -1214,7 +1215,7 @@ class AccountViewModel( offer: String, amountMillisats: Long, ) = launchSigner { - account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> when (response) { is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent) is IErrorResponseLike -> @@ -1585,6 +1586,29 @@ class AccountViewModel( account.decryptContent(note)?.let { onReady(it) } } + /** + * [decrypt] that always answers: [onReady] gets null when the content can't be read — a + * read-only account holding no key, a DM this account isn't part of, or a signer that + * refused/timed out. [decrypt] stays silent in those cases, which strands callers that must + * finish either way (a menu that only closes once the copy resolves, say). + */ + fun decryptOrNull( + note: Note, + onReady: (String?) -> Unit, + ) = launchSigner { + val decrypted = + try { + account.decryptContent(note) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // launchSigner still gets the exception to toast/log the signer failure. + onReady(null) + throw e + } + onReady(decrypted) + } + /** * Runs an action that has both a tracked and a direct broadcast variant, * picking the path the user selected via the "Tracked broadcasts" setting. @@ -1668,12 +1692,12 @@ class AccountViewModel( fun joinRelayGroup( channel: RelayGroupChannel, code: String? = null, - ) = launchSigner { account.joinRelayGroup(channel, code) } + ) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) } - fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) } /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ - fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) } /** * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without @@ -1682,7 +1706,7 @@ class AccountViewModel( fun archiveRelayGroup( channel: RelayGroupChannel, archived: Boolean, - ) = launchSigner { account.archiveRelayGroup(channel, archived) } + ) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) } /** * Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops @@ -1721,7 +1745,7 @@ class AccountViewModel( * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. */ - fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) } /** * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with @@ -1731,7 +1755,7 @@ class AccountViewModel( fun unhideBuzzDm( relay: NormalizedRelayUrl, participants: List, - ) = launchSigner { account.openBuzzDm(relay, participants) } + ) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) } /** * Keep the channel off Messages without touching membership. Local and reversible — I stay in the @@ -1745,7 +1769,7 @@ class AccountViewModel( /** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */ fun leaveChannelInvite(channel: RelayGroupChannel) = launchSigner { - account.leaveRelayGroup(channel) + account.relayGroups.leaveRelayGroup(channel) BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) } @@ -1756,7 +1780,7 @@ class AccountViewModel( * what makes leaving a community whose own relays are dead work at all — the list lives in *our* * outbox, not in the community's relays. */ - fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) } + fun leaveConcordCommunity(communityId: String) = launchSigner { account.concord.leaveConcordCommunity(communityId) } fun createRelayGroup( relay: NormalizedRelayUrl, @@ -1771,7 +1795,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay, groupId, name, @@ -1789,47 +1813,47 @@ class AccountViewModel( fun createRelayGroupInvite( channel: RelayGroupChannel, code: String, - ) = launchSigner { account.createRelayGroupInvite(channel, code) } + ) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) } fun postRelayGroupThread( channel: RelayGroupChannel, title: String, body: String, - ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + ) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) } fun pinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) } fun unpinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) } fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, - ) = launchSigner { account.removeRelayGroupUser(channel, pubkey) } + ) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) } fun putRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, roles: List, - ) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) } + ) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) } /** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */ fun addCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, role: String? = null, - ) = launchSigner { account.addCommunityMember(relay, pubkey, role) } + ) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) } /** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */ fun removeCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, - ) = launchSigner { account.removeCommunityMember(relay, pubkey) } + ) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) } fun editRelayGroupMetadata( channel: RelayGroupChannel, @@ -1843,7 +1867,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel, name, about, @@ -1986,6 +2010,15 @@ class AccountViewModel( fun bottomBarItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.bottomBarItems + fun hiddenDrawerItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.hiddenDrawerItems + + /** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */ + fun changeHiddenDrawerItems(items: Set) { + if (account.applyHiddenDrawerItems(items)) { + launchSigner { account.sendNewAppSpecificData() } + } + } + fun changeBottomBarItems(items: List) { // 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 @@ -2096,7 +2129,7 @@ class AccountViewModel( fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key) - override fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex) + override fun getOrCreateUser(pubkey: HexKey): User = LocalCache.getOrCreateUser(pubkey) fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex) @@ -2330,8 +2363,8 @@ class AccountViewModel( mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return - val relays = account.marmotGroupRelays(nostrGroupId) - account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) } suspend fun sendMarmotGroupMediaMessage( @@ -2356,21 +2389,21 @@ class AccountViewModel( account.signer.pubKey, template, ) - val relays = account.marmotGroupRelays(nostrGroupId) - account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) } fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId) suspend fun createMarmotGroup(nostrGroupId: String) { - account.createMarmotGroup(nostrGroupId) + account.marmot.createMarmotGroup(nostrGroupId) } suspend fun publishMarmotKeyPackage() { - account.publishMarmotKeyPackage() + account.marmot.publishMarmotKeyPackage() } - suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage() + suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage() /** * Whether this account has a kind:10051 KeyPackage Relay List (MIP-00) @@ -2394,12 +2427,12 @@ class AccountViewModel( } suspend fun leaveMarmotGroup(nostrGroupId: String) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.leaveMarmotGroup(nostrGroupId, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.leaveMarmotGroup(nostrGroupId, relays) } suspend fun resetMarmotState() { - account.resetMarmotState() + account.marmot.resetMarmotState() } fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() @@ -2407,30 +2440,30 @@ class AccountViewModel( suspend fun addMarmotGroupMember( nostrGroupId: String, memberPubKey: String, - ): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) + ): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) suspend fun removeMarmotGroupMember( nostrGroupId: String, targetLeafIndex: Int, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) } suspend fun grantMarmotGroupAdmin( nostrGroupId: String, targetPubKey: String, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } suspend fun revokeMarmotGroupAdmin( nostrGroupId: String, targetPubKey: String, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } /** @@ -2486,8 +2519,8 @@ class AccountViewModel( imageUploadKey = icon.upload.imageUploadKey, ) } - val relays = account.marmotGroupRelays(nostrGroupId) - account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) } override fun onCleared() { @@ -2745,7 +2778,7 @@ class AccountViewModel( onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, ) = launchSigner { - account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) onSent() } @@ -2801,7 +2834,7 @@ class AccountViewModel( if (effectiveZapType != LnZapEvent.ZapType.NONZAP) { // NIP-57 Appendix F: include amount + lnurl so the receipt can be validated. val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32) - account.createZapRequestFor( + account.zaps.createZapRequestFor( user = user, message = message, zapType = effectiveZapType, @@ -3004,10 +3037,6 @@ fun mockAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { nwcFilters }, - cashuWalletFilterAssembler = { - com.vitorpamplona.amethyst.commons.relayClient.assemblers - .CashuWalletFilterAssembler(client) - }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) @@ -3064,10 +3093,6 @@ fun mockVitorAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { nwcFilters }, - cashuWalletFilterAssembler = { - com.vitorpamplona.amethyst.commons.relayClient.assemblers - .CashuWalletFilterAssembler(client) - }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 42f9e17572..fc7a33d5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -308,7 +308,7 @@ class GiftWrapEventHandler( // already folded the state they carried, so drop the durable wrap note now // to keep LocalCache from growing without bound. if (event is EphemeralGiftWrapEvent) { - cache.unlinkAndRemove(listOf(eventNote)) + cache.pruner.unlinkAndRemove(listOf(eventNote)) } return } @@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow( // Rotate KeyPackages if needed if (result.needsKeyPackageRotation) { - account.publishMarmotKeyPackages() + account.marmot.publishMarmotKeyPackages() } // Fire the "You've been added to " notification. Welcomes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt index ab1c3b4def..62b8d3fec7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent @@ -42,7 +43,8 @@ fun filterMyAppRecommendations( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ADD_ONS, kinds = listOf(AppRecommendationEvent.KIND), authors = authors, limit = 100, @@ -61,7 +63,8 @@ fun filterRecentAppDefinitions(relays: Set): List filterBadgesByMutedAuthors(feedSettings, since, defaultSince) is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) private fun filterBadgesByAuthorsOnRelay( relay: NormalizedRelayUrl, @@ -58,7 +60,8 @@ private fun filterBadgesByAuthorsOnRelay( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ADD_ONS, kinds = listOf(BadgeDefinitionEvent.KIND), authors = authors.sorted(), limit = BADGE_FEED_LIMIT, @@ -118,7 +121,8 @@ private fun filterBadgesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ADD_ONS, kinds = listOf(BadgeDefinitionEvent.KIND), limit = BADGE_FEED_LIMIT, since = sinceValue, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt index 4212caab49..754aab2c50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/FilterReceivedBadgeAwards.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent @@ -43,7 +44,8 @@ fun filterReceivedBadgeAwards( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.ADD_ONS, kinds = listOf(BadgeAwardEvent.KIND), tags = mapOf("p" to pTags), limit = 500, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt index 02892b5a27..065964fd39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt @@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class ProfileBadgesQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ProfileBadgesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 35f9774204..77b637efdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount import com.vitorpamplona.amethyst.ui.note.externalLinkForNote @@ -186,16 +187,21 @@ fun BookmarkGroupItemOptionsMenu( } } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = onDismiss, + onDismiss = onDismiss, + ) + // Copy & Share section M3ActionSection { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - scope.launch { - clipboardManager.setText(it) - } - } - onDismiss() + copyNoteText(note, lastNoteVersion) } M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 2e615a33d2..82df6800dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -49,8 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProb import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame -import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry -import org.json.JSONObject +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong /** @@ -337,39 +336,6 @@ class EmbeddedWebAppController( putLong(NappletBrowserContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos()) } - private fun parseImeEvent(payload: String): ImeEvent? { - val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null - return when (o.optString("type")) { - "ime.focus" -> - ImeEvent.Focus( - inputType = o.optString("inputType", "text"), - enterKeyHint = o.optString("enterKeyHint", ""), - multiline = o.optBoolean("multiline", false), - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.blur" -> ImeEvent.Blur - "ime.state" -> - ImeEvent.State( - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.pagesel" -> - ImeEvent.PageSelection( - active = o.optBoolean("active", false), - text = o.optString("text", ""), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) - "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) - else -> null - } - } - private inline fun send( what: Int, crossinline block: Bundle.() -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index a6a5f16670..55fe5a3a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -436,7 +436,10 @@ private fun AgentKeyPicker( delay(150) suggestions = withContext(Dispatchers.IO) { - LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8) + LocalCache.search + .findUsersStartingWith(query.trim(), accountViewModel.account) + .map { it.pubkeyHex } + .take(8) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index f0c06abf53..2433459063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -109,9 +109,7 @@ class AgentConsoleViewModel : ViewModel() { relay?.let { val newlyJoined = BuzzWorkspaces.join(it) viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) } - // A join makes the relay first-party; if the socket was already open its one-shot AUTH - // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + if (newlyJoined) reconnectPoolAfterJoin(account.client) } refresh() } @@ -153,7 +151,7 @@ class AgentConsoleViewModel : ViewModel() { // (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries. account.client.fetchAllWithHooks( filters = relays.associateWith { filters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index 1aa34aaccf..e3f106f595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() { onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> if (requireApproval) { - account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null } else { - account.fileBuzzJob(relay, channelId, text) != null + account.relayGroups.fileBuzzJob(relay, channelId, text) != null } } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } fun upvote( jobId: HexKey, jobAuthor: HexKey?, ) = act({}) { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) true } fun cancel(jobId: HexKey) = act({}) { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index 6f3ea3d510..f1fd9926d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery( // rather than returning empty. account.client.fetchAllWithHooks( filters = relays.associateWith { discoveryFilters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> (event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) } @@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata( .groupBy({ it.value }, { it.key }) .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } if (byRelay.isEmpty()) return - account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } + account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index da30fcc4dc..008372414c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -211,7 +211,7 @@ private fun DmRowCard( addMemberOpen = false scope.launch { val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) - accountViewModel.account.addBuzzDmMember(channel, hex) + accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 0e3427d717..dccf0cb851 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -130,9 +130,7 @@ class BuzzDmListViewModel : ViewModel() { val newlyJoined = BuzzWorkspaces.join(relay) viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) } - // A join makes the relay first-party; if the socket was already open its one-shot AUTH - // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + if (newlyJoined) reconnectPoolAfterJoin(account.client) // Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids // from a relay round-trip, so waiting on it left the Direct Messages section visibly empty @@ -200,7 +198,7 @@ class BuzzDmListViewModel : ViewModel() { ) account.client.fetchAllWithHooks( filters = relays.associateWith { filters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> (event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay } @@ -215,7 +213,7 @@ class BuzzDmListViewModel : ViewModel() { .groupBy({ it.value }, { it.key }) .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } if (byRelay.isEmpty()) return - account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } + account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } /** @@ -254,7 +252,7 @@ class BuzzDmListViewModel : ViewModel() { fun removeFromMessages(row: DmRow) { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { - account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) + account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) } } @@ -268,7 +266,7 @@ class BuzzDmListViewModel : ViewModel() { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { val me = account.userProfile().pubkeyHex - account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) + account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) // The relay's new 30622 normally arrives on the live subscription; refresh anyway so the // row returns even if this screen's socket missed the snapshot. refresh() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt new file mode 100644 index 0000000000..9d419d535b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Re-dials the whole relay pool after a Buzz workspace join. + * + * NIP-42 sends its AUTH challenge once, on connect. If the socket was already open + * before the join (the common case — the relay is in the user's lists and connected + * at startup), that challenge was spent while the relay was still NOT first-party, + * so the connection is unauthenticated and every `#p=me`-gated read on it is + * refused. Joining makes the relay first-party (see `AuthCoordinator.isFirstParty`); + * this forces the relay to re-challenge so the connection authenticates. + * + * Shared by the three join sites that need the re-challenge, both to keep the + * reconnect flags identical and to give the churn ledger one place to attribute from: + * without the counter, `Σ(relay.trigger.*)` would only account for the + * connectivity-driven teardowns `RelayProxyClientConnector` reports, and a full pool + * teardown is the most expensive thing either can do. + * + * `BuzzInviteScreen` is a fourth join+pre-approve site that deliberately does NOT + * reconnect — it hands off to the in-app browser rather than reading a `#p=me`-gated + * subscription — so `relay.trigger.buzz` undercounts joins, not re-challenges. + */ +internal fun reconnectPoolAfterJoin(client: INostrClient) { + // Guarded like every other ledger write that reaches the application singleton + // (MediaPlayTimeTracker, the workers): a diagnostics counter must never break a + // user-visible join, and `Amethyst.instance` is lateinit. + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.relayTrigger(UsageKeys.TRIGGER_BUZZ), 1) } + client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 1930e11780..abd18633d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -118,7 +118,7 @@ class BuzzNewDmViewModel : ViewModel() { val me = account.userProfile().pubkeyHex val already = _participants.value.toSet() val ranked = - LocalCache + LocalCache.search .findUsersStartingWith(text.trim(), account) .asSequence() .map { it.pubkeyHex } @@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() { _status.value = Status.Sending viewModelScope.launch(Dispatchers.IO) { try { - val channelId = account.openBuzzDm(relay, others) + val channelId = account.relayGroups.openBuzzDm(relay, others) val groupId = channelId?.let { GroupId(it, relay) } withContext(Dispatchers.Main) { onOpened(groupId) } } catch (e: CancellationException) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 59a0aa3247..a3976d203f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -107,14 +107,8 @@ class BuzzRelayImportViewModel : ViewModel() { val newlyJoined = BuzzWorkspaces.join(normalized) viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) } - // NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this - // join (the common case — the relay is in the user's lists and connected at startup), that - // challenge was spent while the relay was still NOT first-party, so the connection is - // unauthenticated and the persistent group-roster (39002) subscription is refused. Joining - // makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the - // relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and - // every other `#p=me`-gated read on the shared socket. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + // Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin]. + if (newlyJoined) reconnectPoolAfterJoin(account.client) // Track "already added" against the live kind-10009 list, scoped to this relay, so the rows // follow every add/remove — from here, from the channel's top bar, or from another device. @@ -149,7 +143,7 @@ class BuzzRelayImportViewModel : ViewModel() { ), ), ), - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, event -> (event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) } @@ -172,7 +166,7 @@ class BuzzRelayImportViewModel : ViewModel() { Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), ), ), - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index d634500dd6..62ead90720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() { fun file(request: String) = act { account, relay, channelId -> - account.fileBuzzJob(relay, channelId, request) + account.relayGroups.fileBuzzJob(relay, channelId, request) } fun upvote( jobId: String, jobAuthor: String?, ) = act { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) } fun cancel(jobId: String) = act { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) } private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 31b3145ee9..4dc32ff52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() { task: String, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> - account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.approveBuzzWorkflowRun(relay, runId) != null + account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.denyBuzzWorkflowRun(relay, runId) != null + account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } /** @@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() { return } viewModelScope.launch(Dispatchers.IO) { - val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml) + val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml) withContext(Dispatchers.Main) { onResult(newId) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt index 79c1cddd0e..51d04db05e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class CalendarsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class CalendarsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt index 4b47c5e53c..5ada462939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows @@ -49,4 +50,4 @@ fun makeCalendarsFilter( is LocationTopNavPerRelayFilterSet -> filterCalendarsByGeohashes(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterCalendarsByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt index 11b8c7e654..fa51b0e133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterCalendarsByAuthors( @@ -38,7 +39,8 @@ fun filterCalendarsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, authors = authorList, kinds = AllCalendarKinds, limit = 500, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt index 508ef13cc7..bae9664e49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt index cc1d0338d6..60cd1d69ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterCalendarsByGeohashes( @@ -37,7 +38,8 @@ fun filterCalendarsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.TAG_FEED, kinds = CalendarAppointmentKinds, tags = mapOf("g" to geotags.sorted()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt index 04a1c983ef..6c5bfae372 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterCalendarsByHashtag( @@ -35,7 +36,8 @@ fun filterCalendarsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.TAG_FEED, kinds = CalendarAppointmentKinds, tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt index 65f0dc4c9a..8606dfbe76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.TimeUtils fun filterCalendarsGlobal( @@ -38,7 +39,8 @@ fun filterCalendarsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, kinds = AllCalendarKinds, limit = 500, since = sinceForRelay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 9366082074..4bd284de17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey // This allows multiple screen to be listening to tags, even the same tag class ChatroomQueryState( val room: ChatroomKey, - val account: Account, -) { + override val account: Account, +) : AccountScopedQuery { val listId = room.hashCode().toString() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt index 4cbe77bffb..c9e30794a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler( // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index 5072ce8db0..2874a8914c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -111,7 +112,8 @@ private fun toMeFilter( ) = RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), authors = authors.toList(), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), @@ -131,7 +133,8 @@ private fun fromMeFilter( ) = RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), tags = mapOf("p" to pTags.toList()), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index ce87e19328..d87f54987d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage @@ -169,7 +169,7 @@ fun NewGroupDMScreen( WatchAndLoadMyEmojiList(accountViewModel) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 390d718d9b..fc64efe0ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow( onSendNewMessage: () -> Unit, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { if (channelScreenModel.message.text.isNotBlank()) { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 532b172116..57dffc1084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -167,11 +167,19 @@ fun ConcordChannelListScreen( // Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the // fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer. + // Rank alone isn't enough on a split epoch: publishing any Control edition also takes the + // control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3), + // so the affordance waits for the key too. + // hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned + // moderator kept seeing every control here. The editions they authored were dropped by everyone's + // fold, which made these buttons silently no-op — worse than absent, and the same trap this file + // already avoids for the Roles… menu. val canManageChannels = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) - } == true + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS) + } == true && + session?.controlPlaneKeys()?.canWrite == true // channelIdHex == null → create; else → rename that channel. var channelEditor by remember { mutableStateOf(null) } @@ -190,9 +198,9 @@ fun ConcordChannelListScreen( channelEditor = null scope.launch { if (editor.channelIdHex == null) { - account.createConcordChannel(communityId, newName) + account.concord.createConcordChannel(communityId, newName) } else { - account.renameConcordChannel(communityId, editor.channelIdHex, newName) + account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName) } } }, @@ -208,7 +216,7 @@ fun ConcordChannelListScreen( confirmButton = { TextButton(onClick = { channelToDelete = null - scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) } + scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) } }) { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm)) } @@ -234,10 +242,21 @@ fun ConcordChannelListScreen( } }, actions = { + // Rank + the Control write key (CORD-02 §2), like [canManageChannels] above. val canEdit = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA) + } == true && + session?.controlPlaneKeys()?.canWrite == true + + // Minting an invite hands out a working key to the community, so it takes + // CREATE_INVITE like any other privileged action. This button used to be the one + // control on the screen with no gate at all. + val canInvite = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE) } == true IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { @@ -248,22 +267,24 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title)) } } - IconButton( - enabled = !minting, - onClick = { - minting = true - scope.launch { - try { - inviteLink = account.mintConcordInvite(communityId) - } finally { - // Always clear the flag — a thrown mint would otherwise leave the - // button disabled until the screen is recreated. - minting = false + if (canInvite) { + IconButton( + enabled = !minting, + onClick = { + minting = true + scope.launch { + try { + inviteLink = account.concord.mintConcordInvite(communityId) + } finally { + // Always clear the flag — a thrown mint would otherwise leave the + // button disabled until the screen is recreated. + minting = false + } } - } - }, - ) { - SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + }, + ) { + SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + } } // Overflow, mirroring the NIP-29 relay-group top bar: destructive membership @@ -273,6 +294,17 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed + // there are this account's own, authored by link-signer keys only we hold. + // Gating on the bit would mean a demoted admin could no longer retire the + // links they had already handed out — exactly when that matters most. + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) }, + onClick = { + menuOpen = false + nav.nav(Route.ConcordInviteLinks(communityId)) + }, + ) DropdownMenuItem( text = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index dcd7651d6d..d6a5116be7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -596,7 +596,7 @@ private fun ConcordFileUploadDialog( onceUploaded = { uploads -> val imetas = uploads.mapNotNull { it.toConcordImeta() } if (imetas.isNotEmpty()) { - accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas) + accountViewModel.account.concord.sendConcordChannelImageMessage(community, channel, "", imetas) } onUpload() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index e92283510e..5d2a043e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -120,7 +120,7 @@ fun ConcordCreateScreen( scope.launch { val communityId = try { - accountViewModel.account.createConcordCommunity( + accountViewModel.account.concord.createConcordCommunity( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 63397ae827..91a1262982 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -163,7 +163,7 @@ fun ConcordEditScreen( scope.launch { val ok = try { - account.editConcordMetadata( + account.concord.editConcordMetadata( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt new file mode 100644 index 0000000000..ef10b8c2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */ +private sealed interface LinksState { + data object Loading : LinksState + + data class Loaded( + val links: List, + ) : LinksState + + /** + * The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering + * "no links yet" here would tell a creator that the link they came to kill does not exist. + */ + data object Unreadable : LinksState +} + +/** + * Every invite link this account minted for one community, with the ability to retire one + * (CORD-05 §2). + * + * The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives — + * so this shows only links *this account* minted, from any of its devices. Another admin's links are + * invisible here and un-revokable from here, because the secret that authors their coordinate was + * never ours. That is a property of the protocol, not a gap in the screen. + * + * Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is + * bookkeeping the user never sees), so there is no cache to observe. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordInviteLinksScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var state by remember(communityId) { mutableStateOf(LinksState.Loading) } + var reloads by remember(communityId) { mutableIntStateOf(0) } + var confirming by remember { mutableStateOf(null) } + var revoking by remember { mutableStateOf(false) } + + LaunchedEffect(communityId, reloads) { + state = LinksState.Loading + state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable + } + + val communityName = + remember(account, communityId) { + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.name + .orEmpty() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold) + if (communityName.isNotBlank()) { + Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + when (val current = state) { + is LinksState.Loading -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable)) + + is LinksState.Loaded -> + if (current.links.isEmpty()) { + CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(current.links, key = { it.token }) { link -> + InviteLinkRow( + link = link, + enabled = !revoking, + onCopy = { scope.launch { clipboard.setText(link.url) } }, + onRevoke = { confirming = link }, + ) + HorizontalDivider() + } + } + } + } + } + + confirming?.let { link -> + AlertDialog( + onDismissRequest = { if (!revoking) confirming = null }, + title = { Text(stringRes(R.string.concord_invite_revoke_title)) }, + text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) }, + confirmButton = { + TextButton( + enabled = !revoking, + onClick = { + revoking = true + scope.launch { + try { + val ok = account.concord.revokeConcordInvite(communityId, link.token) + accountViewModel.toastManager.toast( + R.string.concord_invite_links_title, + if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed, + ) + // Re-read either way: on success the link is gone from the list, and on + // failure the list is the only thing that can say whether it changed. + reloads++ + } finally { + revoking = false + confirming = null + } + } + }, + ) { + Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(enabled = !revoking, onClick = { confirming = null }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun CenteredMessage( + padding: PaddingValues, + message: String, +) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Text( + message, + // This Box sits on the bare window background, so LocalContentColor is still the M3 + // default black — see the sibling invite screen, where that made the text invisible. + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun InviteLinkRow( + link: ConcordInviteListEntry, + enabled: Boolean, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + // The token prefix is what tells two links to the same community apart; their URLs share + // a long prefix, so they are useless as labels until well past where the row wraps. + Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text( + stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))), + style = MaterialTheme.typography.bodySmall, + ) + Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + IconButton(enabled = enabled, onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_to_clipboard)) }, + onClick = { + menuOpen = false + onCopy() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onRevoke() + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index d66aef1608..7b19436713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -116,7 +116,7 @@ fun ConcordInviteScreen( LaunchedEffect(link, state) { if (state is RedeemState.Working) { state = - when (val result = accountViewModel.account.joinConcordViaInvite(link)) { + when (val result = accountViewModel.account.concord.joinConcordViaInvite(link)) { is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId) is ConcordInviteResult.InvalidLink -> RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false) @@ -124,6 +124,8 @@ fun ConcordInviteScreen( RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false) is ConcordInviteResult.Revoked -> RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.Banned -> + RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false) is ConcordInviteResult.Expired -> RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> @@ -161,6 +163,10 @@ fun ConcordInviteScreen( Text( stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), + // Explicit: this Column sits on the bare window background with no Surface + // above it, so LocalContentColor is still the M3 default black — which renders + // every one of these labels invisible in the dark theme. + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) } @@ -170,6 +176,7 @@ fun ConcordInviteScreen( Text( stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) if (failed.canRetry) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 57b146653b..a9e166c46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -133,7 +133,10 @@ fun ConcordMembersScreen( } val iAmOwner = state?.authority?.isOwner(myPubKey) == true - val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + // hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban / + // Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn, + // which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md. + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true // The roles this viewer may actually hand out. The fold drops a grant whose granter does diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index cd9e4280c4..9d8f1b00b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -34,8 +35,8 @@ import kotlinx.coroutines.Job /** One screen's request to keep the user's joined Concord Channels live. */ class ConcordChannelQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Keeps every joined Concord community's planes live while a Concord-bearing @@ -81,7 +82,11 @@ class ConcordChannelSubAssembler( // per-filter result cap (the "Soapbox shows 1 of 12 channels" bug). See // [ConcordSubscriptionPlanner.controlIsolatedFilters]. val all = - ConcordSubscriptionPlanner.controlIsolatedFilters(entries, since = since) { entry -> + ConcordSubscriptionPlanner.controlIsolatedFilters( + entries, + since = since, + accountPubKey = account.userProfile().pubkeyHex, + ) { entry -> account.concordSessions .sessionFor(entry.id) ?.state diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt index af85aac007..a6cb65f165 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId @@ -42,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow /** One open Concord Channel whose older history the screen wants paged in. */ class ConcordChannelHistoryQueryState( - val account: Account, + override val account: Account, val communityId: String, val channelId: String, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live @@ -129,7 +132,10 @@ class ConcordChannelHistorySubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_CHATS, + purposeDetail = "concord channel history", + entityIds = listOf(key.communityId), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), // All epoch planes at once: the relay serves them interleaved by created_at, so // one backward cursor walks across the Refounding boundaries; "exhausted" then @@ -164,7 +170,7 @@ class ConcordChannelHistorySubAssembler( // cursors so a late callback can't move another channel's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt index 36dc1e6995..7c287c1241 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt @@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader( val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return@LaunchedEffect - account.warmConcordChannelPreviews(listOf(entry)) + account.concord.warmConcordChannelPreviews(listOf(entry)) } } @@ -72,6 +72,6 @@ fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) { LaunchedEffect(communities, revision) { // Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain. delay(1500) - account.warmConcordChannelPreviews(communities) + account.concord.warmConcordChannelPreviews(communities) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 71f86fb4f8..7f308df781 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -150,7 +150,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) { // (1) Load + membership/epoch change: one complete sweep of the whole set. LaunchedEffect(sig) { - if (communities.isNotEmpty()) account.syncConcordControlPlanes(communities) + if (communities.isNotEmpty()) account.concord.syncConcordControlPlanes(communities) } // (2) Reconnect: re-sweep when a relay of ours transitions disconnected → connected. @@ -174,7 +174,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) { val now = TimeUtils.nowMillis() if (now - lastSweep < RECONNECT_RESWEEP_MIN_INTERVAL_MS) return@collect lastSweep = now - account.syncConcordControlPlanes(liveCommunities) + account.concord.syncConcordControlPlanes(liveCommunities) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 5a37ff3e8a..3f57d276b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() { val editing = editingMessage.value if (editing != null) { - account.editConcordChannelMessage(editing, text) + account.concord.editConcordChannelMessage(editing, text) editingMessage.value = null } else { val parent = replyTo.value - account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) + account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) } message.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 6a8f7d9ac7..7c55b9db06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent @@ -47,7 +48,8 @@ fun filterGoalForLiveActivities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(GoalEvent.KIND), ids = listOf(goalId), limit = 1, @@ -56,7 +58,8 @@ fun filterGoalForLiveActivities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt index 4f46520970..a3797580b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMessagesToEphemeralChat( channel: EphemeralChatChannel, @@ -34,7 +35,8 @@ fun filterMessagesToEphemeralChat( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt index 8194cedba4..837b6da8e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter /** * Subscribes to the ephemeral kind-20000 geohash chat for [channel]'s cell on the @@ -41,7 +42,11 @@ fun filterMessagesToGeohashChat( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.GEOHASH_CHATS, + // The cell IS the chat's identity, so the screen can list one row per location + // instead of collapsing every joined cell into one unnamed entry. + entityIds = listOf(channel.geohash), kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to listOf(channel.geohash)), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c380bab385..6a5410c356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent @@ -39,7 +40,8 @@ fun filterMessagesToLiveActivities( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.LIVE_CHAT, kinds = listOf( LiveActivitiesChatMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt index 1163f14ad3..2c61617087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMessagesToPublicChat( @@ -34,7 +35,9 @@ fun filterMessagesToPublicChat( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = listOfNotNull(channel.idHex), kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt index af8242ccf7..650e55c344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMyMessagesToEphemeralChat( channel: EphemeralChatChannel, @@ -36,7 +37,8 @@ fun filterMyMessagesToEphemeralChat( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt index 006ca28320..2258d81255 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent fun filterMyMessagesToLiveActivities( @@ -36,7 +37,8 @@ fun filterMyMessagesToLiveActivities( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(LiveActivitiesChatMessageEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), authors = listOf(pubKey), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt index b3736bc15d..a83ed76a73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMyMessagesToPublicChat( @@ -36,7 +37,9 @@ fun filterMyMessagesToPublicChat( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = listOfNotNull(channel.idHex), kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), authors = listOf(pubKey), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt index a4d966671b..f56aa7f54f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter /** * The relay-signed metadata for a NIP-29 group (name/picture/about + admin, @@ -48,8 +49,8 @@ fun filterRelayGroupState( relays.flatMap { val floor = since?.get(it)?.time listOf( - RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), - RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), ) } @@ -62,7 +63,7 @@ fun filterRelayGroupState( if (pinnedIds.isEmpty()) { emptyList() } else { - relays.map { RelayBasedFilter(relay = it, filter = Filter(ids = pinnedIds)) } + relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } return directory + pins diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index f4519f4bcb..ae5275e8f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() { val geohashes = parseGeohashes() val existing = channel if (existing == null) { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay = relay!!, groupId = groupId, name = name, @@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() { channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null, ) } else { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel = existing, name = name, about = about, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt index 8b67b05bf0..69f7969579 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt @@ -21,16 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt index 0ebe1be439..d3ca4ea0ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One Buzz DM channel whose recent chat is kept live. */ class BuzzDmJoinedChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Always-on **live tail** for the recent chat of every Buzz DM channel the viewer belongs to — the DM diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt index b03f81acc7..8e3d16982a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt @@ -22,25 +22,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.filterRelayGroupState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag /** One on-screen group card's request to warm a single group. */ class RelayGroupCardWarmupQueryState( - val account: Account, + override val account: Account, val channel: RelayGroupChannel, /** When true, prefetch only recent content — the caller's screen already streams metadata. */ val contentOnly: Boolean = false, /** How many recent content events to prefetch ahead of a tap. */ val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT, -) +) : AccountScopedQuery /** * Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive @@ -94,7 +96,8 @@ class RelayGroupCardWarmupSubAssembler( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_CARD_WARMUP_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), limit = key.contentLimit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 95003a354e..d432e55357 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent @@ -51,7 +53,6 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -127,12 +128,12 @@ fun buildRelayGroupLiveStateFilter(groupId: GroupId): List = listOf( RelayBasedFilter( relay = groupId.relayUrl, - filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = listOf(groupId.id), kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), // Pins stay in their own filter for the same reason the directory filters split them out. RelayBasedFilter( relay = groupId.relayUrl, - filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = listOf(groupId.id), kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), ) @@ -286,8 +287,8 @@ fun buildRelayGroupStateFilters( val scope = mapOf(D_TAG to ids.distinct()) val since = sinceForRelay(relay) listOf( - RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), - RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = ids.distinct(), kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = ids.distinct(), kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } @@ -324,7 +325,9 @@ fun buildRelayGroupJoinedChatTailFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -359,7 +362,9 @@ fun buildRelayGroupPreviewFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -379,7 +384,9 @@ fun buildRelayGroupAuxFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_AUX_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -395,7 +402,9 @@ fun buildRelayGroupOpenChatTailFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_OPEN_TAIL_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -418,7 +427,9 @@ fun buildRelayGroupHistoryFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -438,7 +449,8 @@ fun buildRelayGroupDirectoryFilter( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = RELAY_GROUP_DIRECTORY_LIMIT, since = sinceEpoch, @@ -461,7 +473,9 @@ fun buildRelayGroupThreadsHistoryFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -478,7 +492,9 @@ fun buildRelayGroupThreadsFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index 66cd9bdad0..9685069cb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -41,9 +42,9 @@ import kotlinx.coroutines.flow.StateFlow /** One joined group whose recent chat is kept live for the Messages-list preview. */ class RelayGroupJoinedChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Always-on **live tail** for the recent chat of every NIP-29 group the user has joined — the group diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt index 3f55679e1b..7a2dc48cf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -33,8 +34,8 @@ import kotlinx.coroutines.Job /** One request to keep the relay-signed state of the user's joined groups live. */ class RelayGroupJoinedStateQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Keeps the relay-signed **state** of every group the user has joined live and current — name, picture, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt index 3cad4be707..f75b814bf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose older history the chat screen wants paged in. */ class RelayGroupOpenChatHistoryQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever NIP-29 group chat screen is open. The live @@ -125,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt index af33acea2e..e151b15ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -37,9 +38,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose recent chat the screen wants live. */ class RelayGroupOpenChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Per-open-group **live tail**: the recent chat window of the *currently open* group, `#h`-scoped on diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt index 6eb149347f..cb2d8cc4b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose older forum threads the Threads tab wants paged in. */ class RelayGroupOpenThreadsHistoryQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever NIP-29 group's Threads tab is open. The Threads @@ -122,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt index 5d3172b3c6..f510d09c74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -20,16 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors @@ -58,4 +59,4 @@ fun filterRelayGroupsDiscovery( is RelayTopNavPerRelayFilterSet -> filterRelayGroupsByRelay(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt index 5dde33785b..cd99f96616 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class RelayGroupsDiscoveryQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class RelayGroupsDiscoveryFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt index 6acb4c5cef..533e638f0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors @@ -88,7 +89,9 @@ class RelayGroupsDiscoverySubAssembler( ) } - return base + extra + // `base` is scoped by its own builder; these host-relay rosters are built here, so they get + // the same selection stamped on them rather than showing up as an unexplained extra. + return base + extra.scopedTo(feedSettings) } /** Relays that host NIP-29 groups the user is connected to: joined (kind-10009) + favorited (kind-10012). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt index 3fdbb97692..65d8cf66fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -31,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** One screen's request for the full channel directory of a single relay. */ class RelayGroupsOnRelayQueryState( val relay: NormalizedRelayUrl, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt index 812a427e61..a10dfb13f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -21,13 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -68,7 +69,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, authors = authorList, kinds = listOf(GroupMetadataEvent.KIND), limit = 200, @@ -79,7 +81,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND), tags = mapOf(PTag.TAG_NAME to authorList), limit = 200, @@ -98,7 +101,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(DTag.TAG_NAME to rosterGroupIds), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt index 6d4050bcdc..05316192db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt index 64e5295a7e..87aeb967e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt index f0c140077b..0ae3bbc0fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -39,7 +40,8 @@ fun filterRelayGroupsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt index 908337a8fc..c8634d931b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -37,7 +38,8 @@ fun filterRelayGroupsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt index 7974bd50bf..2768dfb6ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt index 41cd15fbbf..06595c754b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent @@ -51,7 +52,8 @@ fun filterRelayGroupsDirectory( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_DISCOVERY_KINDS, limit = 500, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt index 144b6061ad..5a54a354f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a4e1818b7d..a8573a9032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel : val pk = user.pubkeyHex if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) { try { - accountViewModel.account.putRelayGroupUser(channel, pk, emptyList()) + accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList()) } catch (e: Exception) { if (e is CancellationException) throw e Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index f14cd1d59e..8176dc4d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -78,7 +78,7 @@ fun EditFieldRow( onSendNewMessage: suspend () -> Unit, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 286b506641..649ab893af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @Stable class ChatroomListState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ChatroomListFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index bf7f2fee05..60a41ede35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.chatroomList.nip04History return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 20d2371956..6be3d19400 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingEphemeralChats( @@ -52,7 +53,8 @@ fun filterFollowingEphemeralChats( // Metadata comes from any relay relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = mapOf("d" to it.value.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt index bb63652938..29f3466e53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.geohash.GeohashRelays import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -53,7 +54,11 @@ fun filterFollowingGeohashChats( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.GEOHASH_CHATS, + // One filter per relay carries every cell that relay serves, so all of them ride + // along and the screen fans them into a row each. + entityIds = cells.sorted(), kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to cells), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index c16e287939..aaadd69211 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -56,7 +57,9 @@ fun filterFollowingPublicChatsCreationEvent( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = it.value.sorted(), kinds = listOf(ChannelCreateEvent.KIND), ids = it.value.sorted(), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 5e898c7640..f99e8a4453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -57,7 +58,9 @@ fun filterLastMessageFollowingPublicChats( // Metadata comes from any relay relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = it.value.sorted(), kinds = listOf(ChannelMetadataEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, @@ -67,7 +70,9 @@ fun filterLastMessageFollowingPublicChats( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = it.value.sorted(), kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index c000c246d1..29edf0025d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -36,7 +37,8 @@ fun filterNip04DMsFromMe( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index 4b1148e066..dbe23014ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -36,7 +37,8 @@ fun filterNip04DMsToMe( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 01616b65a6..0780530666 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt index a1a0caeb74..5649dbd192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent @@ -64,7 +65,8 @@ class CommunityRulesFilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, kinds = listOf(CommunityRulesEvent.KIND), authors = signers.sorted(), tags = mapOf("a" to listOf(commEvent.addressTag())), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index e0561e6eec..143da7c09a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -54,7 +55,8 @@ fun filterCommunityPosts( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, authors = community.moderatorKeys(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -72,7 +74,8 @@ fun filterCommunityPostsFromModerators( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, authors = authors.sorted(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -89,7 +92,8 @@ fun filterCommunityPostsFromEverybody( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.COMMUNITY_FEED, tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt index 1ac72e047c..b5f19f4752 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasourc import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class CommunitiesListQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class CommunitiesListFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index dc49d49b5e..01ccad2648 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -22,16 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag class DiscoveryQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery class DiscoveryFilterAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt index 0b32f8924e..4caa8189af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors @@ -54,4 +55,4 @@ fun makeLongFormFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt index c45cc47a10..db43249573 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterLongFormAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterLongFormAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LongTextNoteEvent.KIND), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt index ff298fbbf0..9f161f4f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -39,7 +40,8 @@ fun filterLongFormAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(LongTextNoteEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt index 5817a1c822..e2b6c60aa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterLongFormByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterLongFormByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LongTextNoteEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt index 907214fe8e..d01bdbf7bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index d7b4d6b1d0..bca1c7f02b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -41,7 +42,8 @@ fun filterLongFormByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index 9abbaaefb4..b365e48079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -41,7 +42,8 @@ fun filterLongFormByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("t" to hashtags), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt index 2427aea0c6..0d1d254efe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,7 +40,8 @@ fun filterLongFormGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt index f3d9e910bf..3b7d225445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors @@ -54,4 +55,4 @@ fun makePublicChatsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt index f1ba91c61b..46536f2687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -57,7 +59,8 @@ fun filterPublicChatsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt index d97ffa378b..9944e7dc6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -41,7 +42,8 @@ fun filterPublicChatsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, authors = authorList, kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt index b5d96f3ade..f000bdd7ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -58,7 +60,8 @@ fun filterPublicChatsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt index 73068610ae..ce9919dcbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index e4fbc745bb..d94b5a3166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index 47033dd904..a544c8a11f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -43,7 +44,8 @@ fun filterPublicChatsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt index a2261c1945..aa448d0477 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -42,7 +43,8 @@ fun filterPublicChatsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, @@ -55,7 +57,8 @@ fun filterPublicChatsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt index 4e8a8a8ba4..f87c6e1bd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors @@ -54,4 +55,4 @@ fun makeFollowSetsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt index 4d7e1a8507..c7d179fdba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -41,7 +42,8 @@ fun filterFollowSetsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -56,7 +58,8 @@ fun filterFollowSetsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(FollowListEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt index 6153bdcb74..0d28f32ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -39,7 +40,8 @@ fun filterFollowSetsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authorList, kinds = listOf(FollowListEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt index 017e5474ee..c3e944c20b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterFollowSetsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterFollowSetsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(FollowListEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt index fb11096666..99be6c1d8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt index ef53490894..53c2c58f90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -40,7 +41,8 @@ fun filterFollowSetsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt index f85d36bc73..02a4703da0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -41,7 +42,8 @@ fun filterFollowSetsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 315e6172f5..6ecddb7b5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsGlobal( @@ -38,7 +39,8 @@ fun filterFollowSetsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt index 6f5c5a5170..12cab5f61b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors @@ -54,4 +55,4 @@ fun makeLiveActivitiesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt index 7ed9ba7962..e61c53753d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -58,7 +60,8 @@ fun filterLiveActivitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt index 1f576d4306..a6a266bce7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -42,7 +43,8 @@ fun filterLiveActivitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, @@ -53,7 +55,8 @@ fun filterLiveActivitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("p" to authorList), kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt index 402d490881..1667601ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -59,7 +61,8 @@ fun filterLiveActivitiesByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt index b7bdf4cd84..a5911dead4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index 7e5b1d0323..e458cda38c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 4ecad2cc7e..4fa08d63bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -44,7 +45,8 @@ fun filterLiveActivitiesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt index 2a0ac27a22..27b83d9d8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -44,7 +45,8 @@ fun filterLiveActivitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 30, since = since ?: TimeUtils.oneWeekAgo(), @@ -53,7 +55,8 @@ fun filterLiveActivitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND), limit = 50, since = since ?: TimeUtils.oneDayAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt index 0b1513406a..e63a9392af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors @@ -54,4 +55,4 @@ fun makeCommunitiesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt index deffb3a7b6..eadc45dac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterCommunitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf("a" to communityList), limit = 300, @@ -51,7 +53,8 @@ fun filterCommunitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt index 1a9305d6d5..1b8fda38d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -40,7 +41,8 @@ fun filterCommunitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt index e03f9aafba..319fcfbd95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = listOf(community.pubKeyHex), kinds = listOf(CommunityDefinitionEvent.KIND), tags = mapOf("d" to listOf(community.dTag)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt index 6feb68c1e9..1010de5216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt index e51ca971ba..9393e18407 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -41,7 +42,8 @@ fun filterCommunitiesByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt index f5b4a505bd..341be760da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -42,7 +43,8 @@ fun filterCommunitiesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("t" to hashtags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 7b9eb13088..9b7127947a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,7 +42,8 @@ fun filterCommunitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND), limit = 200, since = since, @@ -50,7 +52,8 @@ fun filterCommunitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, limit = 100, since = since ?: TimeUtils.oneMonthAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt index a78047c893..4623328113 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors @@ -54,4 +55,4 @@ fun makeContentDVMsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt index 86fa3a7b32..919a800fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterContentDVMsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(AppDefinitionEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt index 2b7e0c42b3..7302c37ee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -39,7 +40,8 @@ fun filterContentDVMsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt index 5a2de14d4a..fc0b52a574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterContentDVMsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(AppDefinitionEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt index 5e964daa0a..8e89fa12a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index d83d1a7bd5..7c5620f290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index d5825d91f4..c6ed995c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -41,7 +42,8 @@ fun filterContentDVMsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt index 9c3b2f960c..e636f5e4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun filterContentDVMsGlobal( @@ -39,7 +40,8 @@ fun filterContentDVMsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt index 80d824fa55..bb0f59d628 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -20,15 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors @@ -54,4 +55,4 @@ fun makeClassifiedsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt index 8f79ce5cf3..b765994423 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterClassifiedsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt index df3b190d69..94d7cd3cf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -39,7 +40,8 @@ fun filterClassifiedsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(ClassifiedsEvent.KIND), limit = authorList.size * 10, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt index 102c9b02f9..27eb96b534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = listOf(ClassifiedsEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt index 929ecf44de..174341b5ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 570fe9e192..91f4624bbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index bf227d5e00..9e9cae2ed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -41,7 +42,8 @@ fun filterClassifiedsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt index 9059eca75a..f0258b6bbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +41,8 @@ fun filterClassifiedsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), limit = 30, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index d0b55dc108..bb2673f7bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -56,6 +56,53 @@ interface EmbeddedImeBridge { fun sendImeOp(json: String) } +/** + * Asks the page to re-announce its focused field (as an [ImeEvent.ReFocus]). A warm tab keeps the page's DOM + * focus while it sits off-screen, so no `focusin` ever fires for it again and this is the only way the host + * learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when + * an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors. + */ +fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString()) + +/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */ +fun parseImeEvent(payload: String): ImeEvent? { + val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null + return when (o.optString("type")) { + "ime.focus" -> parseFocus(o) + "ime.wantkb" -> ImeEvent.WantKeyboard + "ime.refocus" -> ImeEvent.ReFocus(parseFocus(o)) + "ime.blur" -> ImeEvent.Blur + "ime.state" -> + ImeEvent.State( + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) + "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) + else -> null + } +} + +private fun parseFocus(o: JSONObject) = + ImeEvent.Focus( + inputType = o.optString("inputType", "text"), + enterKeyHint = o.optString("enterKeyHint", ""), + multiline = o.optBoolean("multiline", false), + readOnly = o.optBoolean("readOnly", false), + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + /** What the focused page field reports up to the host keyboard. */ sealed interface ImeEvent { /** A field took focus; carries enough to configure the keyboard and seed the editing buffer. */ @@ -63,12 +110,40 @@ sealed interface ImeEvent { val inputType: String, val enterKeyHint: String, val multiline: Boolean, + /** + * The field is `readonly`: focusable and selectable, but not typeable. Native Chrome focuses such a + * field without raising the keyboard, so the host must not either — otherwise the user gets a keyboard + * whose keystrokes the page discards. + */ + val readOnly: Boolean = false, val text: String, val selStart: Int, val selEnd: Int, val geometry: SelectionGeometry? = null, ) : ImeEvent + /** + * The user tapped inside a field that is **already** focused in the page: put the keyboard back up. No + * focus event follows a tap on a field that never blurred, so this is the only signal that the user wants + * the keyboard back after dismissing it — or after a tab switch released the host's mirror. + * + * Deliberately payload-free (it fires on every tap in a field): a host that no longer mirrors the field + * answers it with an [requestImeResync] and takes the state from the [ReFocus] that comes back. + */ + data object WantKeyboard : ImeEvent + + /** + * The page's answer to [requestImeResync]: the editing state of a field that is already focused there. + * Distinct from [Focus] because page focus never changed — the host must not restart the input on a field + * it is already mirroring (that would kill a live composing region mid-word). + * + * Carries no geometry: measuring a caret rect forces a synchronous layout in the page, and the host has no + * use for it here (the `ime.carettap` that rides along with a tap carries fresh geometry). + */ + data class ReFocus( + val focus: Focus, + ) : ImeEvent + /** The field lost focus — dismiss the keyboard. */ data object Blur : ImeEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index 7246b19577..9f4bf544d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -149,9 +149,27 @@ object EmbeddedTabHost { contentBounds = bounds } + // Tabs whose soft keyboard was up at the moment the user left them. A warm tab keeps its page focus while + // it sits off-screen, so coming back is a *resume*: the keyboard returns only for the tabs that had one, + // the way Android restores a window's IME state. A tab the user deliberately typed on and then dismissed + // the keyboard for comes back with the caret still in the field but the page unobstructed. + private val keyboardUpOnLeave = mutableSetOf() + + /** Records whether the soft keyboard was up as [id] stops being the active tab. */ + fun noteKeyboardOnLeave( + id: String, + wasUp: Boolean, + ) { + if (wasUp) keyboardUpOnLeave.add(id) else keyboardUpOnLeave.remove(id) + } + + /** Consumes the "restore the keyboard" mark for [id] (a restore happens at most once per leave). */ + fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id) + fun evict(id: String) { val w = warm.firstOrNull { it.id == id } ?: return if (activeId == id) activeId = null + keyboardUpOnLeave.remove(id) warm.remove(w) w.controller.teardown() } @@ -165,6 +183,7 @@ object EmbeddedTabHost { fun evictAll() { activeId = null + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } @@ -177,6 +196,8 @@ object EmbeddedTabHost { * its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface. */ fun rebuildAll() { + // Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto. + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 77021fa742..73472ede01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -348,7 +348,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides // while dragging or scrolling, handles hide while scrolling — are expressed in one place. val sel = remember { SelectionUiState() } + // The keyboard collapsing while this view still mirrors the page field means the user put it away on a + // field they are still on (BACK, or the IME's own hide button) — record it so returning to this tab + // doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT + // look like this: measured on device, the switch takes focus off the view in the same frame, so + // isMirroringPageField() is already false there and the mark this tab was owed survives. + LaunchedEffect(activeId, imeBottomPx) { + if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed() + } DisposableEffect(imeBridge) { + val boundId = activeId + // A warm tab keeps its page focus while parked, so returning to it fires no focus event: ask the + // page to re-announce whatever field is still focused. Restores the mirror (so a tap can raise the + // keyboard) and, when the user left mid-typing, brings the keyboard straight back. + // Consumed only when there is a bridge to ask (the mark is one-shot, so spending it on a bind that + // can't send the resync would drop the restore this tab was owed). + var pendingRestore = imeBridge != null && boundId != null && EmbeddedTabHost.takeKeyboardRestore(boundId) imeView.bind(imeBridge) imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) } imeView.onEdited = { sel.onEdited() } @@ -362,8 +377,31 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // Cancel any in-flight scroll-hide from the page phase: otherwise the field's own // selection-reveal scrolls keep it armed and the new field handles/toolbar never appear. sel.scrolling = false + sel.onFieldReadOnly(event.readOnly) sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) } + ImeEvent.WantKeyboard -> { + // Still mirroring this field (the keyboard was merely dismissed) → just put it back. + // Otherwise the mirror was released by a tab switch and holds another tab's buffer: + // ask for the state first and raise once it lands. + if (imeView.isMirroringPageField()) { + imeView.raiseKeyboard() + } else { + pendingRestore = true + imeBridge.requestImeResync() + } + } + is ImeEvent.ReFocus -> { + // Raise only if something asked for it — a tap that rang the doorbell above, or a tab + // left with the keyboard up. A plain tab return re-takes the field silently. + imeView.onPageReFocus(event.focus, pendingRestore) + pendingRestore = false + // Re-arm "the field has text" so a tap can show the insertion handle again (a tab + // switch resets it). Geometry stays null here, so this can't pop a handle up on its + // own — native shows nothing until the user touches the field. + sel.onFieldReadOnly(event.focus.readOnly) + sel.onFieldGeometry(null, event.focus.text.isNotEmpty()) + } ImeEvent.Blur -> { imeView.onPageBlur() sel.onBlur() @@ -377,11 +415,29 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { is ImeEvent.CaretTap -> sel.onCaretTap(event.geometry) } } + // Posted, not sent inline: the session's own `active` effect resumes a paused (parked) WebView in + // this same effect pass, and a message delivered to a still-paused page can be lost. One turn of + // the main looper later, the resume is already on its way over the same (FIFO) channel. + imeView.post { imeBridge?.requestImeResync() } onDispose { imeBridge?.onImeEvent = null imeView.onRangeSelectionChanged = null imeView.onEdited = null sel.reset() + // Remember whether the keyboard was up BEFORE onPageBlur takes it down, so returning to this + // tab resumes exactly the state it was left in. The page keeps its focus (and caret) either + // way: blurring it here would fire the page's own blur handlers — validation, autocomplete + // dismissal, submit-on-blur — for a switch the user never made inside the page. + // + // Ask the mirror what it *intends* rather than sampling the window: by the time this dispose + // runs, the nav transition has already snapped `WindowInsets.imeAnimationTarget` to 0 and + // taken focus off the view, so both would report "no keyboard" for every tab the user left + // mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField] + // also answers the other half: only a keyboard THIS mirror holds counts, so typing in the + // browser's own address bar never arms a restore for a page field. + if (boundId != null) { + EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField()) + } imeView.onPageBlur() imeView.bind(null) } @@ -475,25 +531,41 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // In-field (/ +

+
hello world
+
+
+
+
+
+
+
+
+ + + + +
+ +
+ + diff --git a/tools/ime-test/perf.html b/tools/ime-test/perf.html new file mode 100644 index 0000000000..b1efbb4d78 --- /dev/null +++ b/tools/ime-test/perf.html @@ -0,0 +1,206 @@ + + + + + +Embed vs direct — runtime perf probe + + + +

Runtime perf probe

+

Open this same URL twice — once as an embedded tab, once in the full-screen browser — and compare. Both are the same WebView in the same process; any gap is host-induced.

+ + + +
+ + + + + + + + diff --git a/tools/ime-test/shim-events.mjs b/tools/ime-test/shim-events.mjs new file mode 100644 index 0000000000..14882505a7 --- /dev/null +++ b/tools/ime-test/shim-events.mjs @@ -0,0 +1,177 @@ +// Regression test for the embedded-WebView IME relay's page→host protocol. +// +// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium +// with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*` +// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs +// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so +// a Kotlin test of it would pass without parsing anything. +// +// cd tools/ime-test && npm i playwright-core && node shim-events.mjs +// +// Exits 0 if every expectation holds, 1 otherwise. Override the browser with CHROMIUM_PATH, and the shim +// under test with argv[2] (useful for diffing a candidate against the committed one). + +import { chromium } from 'playwright-core' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const SHIM = process.argv[2] ?? resolve(HERE, '../../commons/src/commonMain/composeResources/files/napplet/shim.js') +const CHROMIUM = process.env.CHROMIUM_PATH ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome' + +const HTML = `ime + +

plain page text, not editable

+ + +
+ editable span text +
+` + +const browser = await chromium.launch({ executablePath: CHROMIUM, args: ['--no-sandbox'] }) +const context = await browser.newContext() + +// Stand in for the native bridge the `:napplet` process installs: collect what the page sends, and keep the +// reply channel so host→page ops (`ime.resync`) can be delivered exactly as the host delivers them. +await context.addInitScript(() => { + window.__sent = [] + window.__nappletDirectBridge = true + window.__nappletImeProxy = true + window.__nappletBridge = { + postMessage(s) { window.__sent.push(s) }, + set onmessage(fn) { window.__imeIn = fn }, + get onmessage() { return window.__imeIn }, + } +}) +await context.addInitScript({ content: readFileSync(SHIM, 'utf8') }) +await context.route('https://ime.test/**', (route) => route.fulfill({ contentType: 'text/html', body: HTML })) +const page = await context.newPage() +await page.goto('https://ime.test/') + +const drain = async () => { + const raw = await page.evaluate(() => { const s = window.__sent.slice(); window.__sent.length = 0; return s }) + return raw.map((s) => JSON.parse(s)).filter((m) => (m.type || '').startsWith('ime.')) +} + +const failures = [] +const results = [] + +// `expect` is a predicate over the messages one gesture produced, described in words for the report. +const step = async (name, gesture, expectation) => { + await gesture() + await page.waitForTimeout(150) + const msgs = await drain() + const problem = expectation(msgs) + const types = msgs.map((m) => m.type).join(', ') || '(nothing)' + results.push([name, types, problem]) + if (problem) failures.push(`${name}: ${problem}\n got: ${types}`) +} + +const has = (msgs, type) => msgs.some((m) => m.type === type) +const find = (msgs, type) => msgs.find((m) => m.type === type) + +await step( + 'tap an unfocused field announces it and asks for the keyboard', + () => page.click('#inp'), + (m) => { + const focus = find(m, 'ime.focus') + if (!focus) return 'no ime.focus' + if (focus.text !== 'hello world') return `ime.focus carried text "${focus.text}"` + if (focus.readOnly !== false) return 'ime.focus said readOnly on an editable field' + return has(m, 'ime.wantkb') ? null : 'no ime.wantkb doorbell' + }, +) + +// The regression this suite exists for: a tap on a field that never blurred fires no focus event, so before +// `ime.wantkb` existed the host had no signal at all and the keyboard could not be brought back. +await step( + 'tap the SAME already-focused field still rings the doorbell', + () => page.click('#inp'), + (m) => { + if (!has(m, 'ime.wantkb')) return 'no ime.wantkb — the keyboard could never come back' + if (has(m, 'ime.focus')) return 'unexpected ime.focus (page focus never moved)' + return null + }, +) + +// The doorbell must stay payload-free: it fires on every tap in a field, so attaching the editing state +// would put the whole field text on the wire per tap. +await step( + 'the doorbell carries no payload', + () => page.click('#inp'), + (m) => { + const kb = find(m, 'ime.wantkb') + if (!kb) return 'no ime.wantkb' + const extra = Object.keys(kb).filter((k) => k !== 'type' && k !== 'id') + return extra.length ? `ime.wantkb carried ${extra.join(', ')}` : null + }, +) + +await step( + 'a host resync is answered with the focused field state', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => { + const re = find(m, 'ime.refocus') + if (!re) return 'no ime.refocus' + if (re.text !== 'hello world') return `ime.refocus carried text "${re.text}"` + if (re.geom !== undefined) return 'ime.refocus carried geometry (forces a synchronous layout per send)' + return null + }, +) + +await step( + 'tapping off the field blurs it', + () => page.click('#para'), + (m) => (has(m, 'ime.blur') ? null : 'no ime.blur'), +) + +await step( + 'a resync with nothing focused answers nothing', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => (m.length ? 'answered a resync with no focused field' : null), +) + +await step( + 'a readonly field announces itself as readonly', + () => page.click('#ro'), + (m) => { + const focus = find(m, 'ime.focus') + if (!focus) return 'no ime.focus' + return focus.readOnly === true ? null : 'ime.focus did not report readOnly' + }, +) + +await step( + 'readonly survives the resync round trip', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => { + const re = find(m, 'ime.refocus') + if (!re) return 'no ime.refocus' + return re.readOnly === true ? null : 'ime.refocus dropped readOnly — a restore would raise a keyboard' + }, +) + +// contenteditable taps land on a child node, so the doorbell has to test containment, not equality. +await step( + 'a tap inside an already-focused contenteditable rings the doorbell', + async () => { + await page.click('#cespan') + await page.waitForTimeout(150) + await drain() + await page.click('#cespan') + }, + (m) => (has(m, 'ime.wantkb') ? null : 'no ime.wantkb from inside a contenteditable'), +) + +console.log(`\nshim: ${SHIM}\n`) +for (const [name, types, problem] of results) { + console.log(` ${problem ? 'FAIL' : 'ok '} ${name}\n ${types}`) +} +if (failures.length) { + console.log(`\n${failures.length} failure(s):\n`) + failures.forEach((f) => console.log(` - ${f}`)) +} +await browser.close() +process.exit(failures.length ? 1 : 0)