Compare commits

...

129 Commits

Author SHA1 Message Date
Claude
078758888a perf(relay): remove live-path allocations in LiveEventStore + FilterIndex
The remaining SmallReqFloorBenchmark waste, on the per-row replay, the
per-event live fanout, and the per-accepted-event index probe:

- LiveEventStore replay dedupe: a SeenIds holder with an inline lock
  replaces the local-fn-plus-lambda that allocated one closure per
  streamed row (and again per live delivery). Its HashSet is created
  empty so the JVM defers the backing table to the first add — a 0-row
  replay no longer allocates a 1024-slot table (was ~4 MB across the
  benchmark's 1000 idle subs).
- Live fanout serializes the event body once and passes it through
  onEachLive(event, body); RelaySession splices it into the per-sub
  frame prefix. An event matching N live subscriptions paid N identical
  Jackson passes before; now one. queryRaw's onEachLive signature gains
  the body arg (EventSourceBackend default serializes inline, no
  cross-sub memo, no regression). Measured: fanout 1->200 live subs
  0.50 ms (2.5 us/sub).
- FilterIndex holds subscribers in one persistent map per dimension, so
  candidatesFor (once per accepted ingest event) probes with the event's
  own fields and allocates no IdKey/AuthorKey/KindKey/TagKey wrappers;
  BucketKey now lives only in the rare register/unregister bookkeeping.

SmallReqFloorBenchmark grows a fanout stage (200 live subs, one submit)
to anchor the fanout number; it drives `live` directly and guards the
await with withTimeout so a future fanout regression fails fast.

Verified: quartz relay.server + FilterIndex suites (110 tests),
SmallReqFloorBenchmark, geode suite (126 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:38 +00:00
Claude
387bfe99ee perf(relay): drop per-op allocations on the frame + search paths
Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged:

- strippingSearchExtensions: index-loop guard returns the same list with
  zero allocation when no filter carries a search term (every non-search
  REQ/COUNT/snapshot, the overwhelming majority).
- EoseMessage/OkMessage: direct-buildString wire form on the escape-free
  fast path (EOSE per REQ, OK per publish), skipping the generic
  serializer's node tree; exotic subIds/reasons fall back. Shared
  isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage.

Verified: quartz relay.server + message-frame suites (110 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:21 +00:00
Claude
6033957b1c perf(relay): persistent-map FilterIndex snapshots, benchmark the sub population
FilterIndex registration runs on every REQ open/close but built each
new snapshot by copying both full maps — O(S) work and allocation per
REQ with S live subscriptions. Persistent (HAMT) maps keep the
wait-free single-load reads and CAS write loop while making a write
O(keys x log S) with structural sharing.

SmallReqFloorBenchmark grows a B@1k stage (1000 idle parked
subscriptions) to make the population cost visible, and its B stage
now enters queryRaw undispatched like production does: @1000 subs the
per-REQ cost drops 0.225 -> 0.151 ms and the measured population
penalty falls below run noise (was +0.011 ms per REQ).

With this and the undispatched replay, the in-process floor above the
raw store query is ~0.11 ms (was ~0.66 ms as first measured): A 0.120,
B 0.203, C 0.239 ms on a quiet machine.

Verified: FilterIndex tests, quartz relay.server suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:22:13 +00:00
Claude
37923d9101 perf(relay): run the stored REQ replay undispatched
SmallReqFloorBenchmark showed the per-REQ floor on small results is
dominated by pipeline, not the store (raw query 0.125 ms vs 0.785 ms
session REQ->EOSE in-process). Half of the dispatch slice was the
scheduler hop between handleReq's launch and the query coroutine:
starting the job with CoroutineStart.UNDISPATCHED runs the stored
replay and EOSE inline on the receiving coroutine (the reader-pool
acquire doesn't suspend when a connection is free), parking only at
the live tail. Measured: dispatch+frames slice 0.397 -> 0.207 ms.

Commands on a connection are processed sequentially, so nothing can
target the subscription before the job lands in the registry at the
first suspension point.

Verified: quartz relay.server suite, SmallReqFloorBenchmark, geode
full test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:14:12 +00:00
Claude
57ffb3386d feat(geode): enable the tag+kind+pubkey index, refresh measured docs
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.

Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.

Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 15:07:12 +00:00
Claude
a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:

- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
  order with a cost-based pick. Every legal driver (each tagsAll value,
  each tags key's value union, the kind set, the author set) opens a
  lazy directory iterator; all are drained in lockstep and the first to
  exhaust (the smallest listing) drives, so a giant idx/kind tree is
  never read past ~the smallest candidate's size. Fixes the 149 ms vs
  3.4 ms (~44x at 30k events) authors+kinds+limit regression.

- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
  gated indexes are runtime config, not schema. An idempotent
  CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
  an IndexingStrategy flag on an existing DB builds the index without
  a user_version bump.

- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
  ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
  replay authors-only filters that full-scanned without
  (pubkey, created_at); existing DBs pick the index up on next open.

- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
  DM-room shape, 65 client assembler call sites) and "reactions-watch"
  (kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
  head-to-head numbers vs strfry.

- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:54:48 +00:00
Claude
41240eeab9 docs(store): record measured index/planner gaps as TODOs in both stores
Real numbers from the two new benchmarks, so the trade-offs are on the
decision points instead of in a chat log:

- IndexingStrategy.indexTagsWithKindAndPubkey: the KDoc called the
  kinds+authors+tags shape "rarely used", but the client assembler
  survey found 65 call sites. TagAuthorIndexBenchmark @ 200k events:
  DM-room query 9.4 ms -> 0.6 ms (~15x) with the flag on, insert cost
  +14% (41.5 -> 47.3 us/event). TODO: re-evaluate defaults (geode).

- MergeQueryExecutor: tag-path analogue of the follow-feed collect-all
  sort (kinds + #e IN [hundreds] + limit never merges). Measured
  12.8 ms cold / 6.0 ms since-bounded at 200k events; revisit if
  relayBench shows it at relay scale.

- FsQueryPlanner: fixed driver order sends authors+kinds+limit (the
  most common CLI shape) through the kind tree. FsDriverSelection-
  Benchmark @ 30k events: 149 ms -> 3.4 ms (~44x) driving from the
  author tree; TODO: cost-based pick by directory entry counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:39:21 +00:00
Claude
5988db010d test(store): add tag∩author and FS driver-selection benchmarks
The 2026-07 client filter-assembler survey mapped 551 Filter
constructions to ~12 query archetypes. Two hot shapes had no benchmark
coverage in prodbench or relayBench, and the FS store had none at all:

- TagAuthorIndexBenchmark: the DM-room shape (kinds + authors + #p,
  65 assembler call sites) with indexTagsWithKindAndPubkey off vs on,
  including the insert-cost delta of the extra index; plus the
  reactions watcher (kinds=[7], #e IN 300, limit) cold and
  since-bounded, which has no tag-side k-way merge today.

- FsDriverSelectionBenchmark: FsQueryPlanner's fixed driver order
  (tags → kinds → authors) on authors+kinds+limit — the most common
  CLI shape — comparing the current kind-tree driver against an
  author-tree driver with kind post-filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:34:57 +00:00
Vitor Pamplona
72e27a3530 Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
2026-07-21 09:57:06 -04:00
davotoula
556ef9f880 fix(share-qr): stop the mode toggle labels clipping 2026-07-21 14:30:04 +01:00
davotoula
52c863a9c6 feat(share): add QR payload selection for notes
test(share): pin naddr encoding for addressable notes in QR payloads
feat(share): add strings for the QR share screen
feat(share): add fixed-height note card for the QR screen
feat(share): add display-only QR screen for notes
feat(share): register the ShareNoteAsQr destination
feat(share): add Share as QR to the note share sheet
fix(share): gate NSFW thumbnail with ContentWarningGate, prefer article title
fix(share): size the QR from available width, add a11y description
fix(share): add QR a11y strings, document QR row in ShareActionRows KDoc
fix(share): compact permanently-covered NSFW thumbnail, fix inert QR width cap
fix(share-qr): close sensitivity gate leaks and text/thumbnail bugs on the share-as-QR card
fix(share-qr): fix stale payload, unreachable controls, missing back button, and screen-wake handling
fix(share-qr): show image thumbnail for image-only notes, hide raw media URL
2026-07-21 14:29:49 +01:00
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.

It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.

Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
Findings from a second review round (two independent reviewers), with fixes:

Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
  account outbox/bootstrap (general relays); NIP-34 events live on the repo
  announcement's advertised relays (often GRASP/git-specific), which general
  relays don't mirror — so `amy git issues <repo>` with no --relay could return
  empty. Now fetch the announcement once and read from queryTargets ∪ its
  advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
  repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
  → pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
  (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
  ~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
  (was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).

Write path:
- **`git init` silently reported success when the 30618 state publish failed**
  — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
  with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
  non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
  carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
  relays) now warns to stderr instead of reporting silent success.

Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
Findings from a review pass over the git-parity branch, with fixes:

- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
  (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
  boundary commits, not the true root, so the repo would be announced under a
  different cross-fork identity than ngit computes. Now: detect shallow clones
  and omit the euc with a warning to pass `--earliest-commit`; on full clones
  derive the deterministic `--first-parent` mainline root instead of an
  arbitrary `tail -1`.

- **`git issues|patches|prs` silently truncated and mis-derived status** on
  active repos: one single-page `drain` pulled items AND status events under a
  shared cap, so status events (newer, more numerous) could crowd items out of
  the window and the close-status that determines an item's state could fall
  outside it → a closed item read as open. Now paginate the items
  (`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
  Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.

- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
  the OS (a chatty command can no longer fill its stderr pipe and hang the
  stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
  while draining stdout, so a patch larger than the pipe buffer can't deadlock.

- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
  bytes; `GitRepositoryEvent.clones()/webs()` dedupe.

The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.

Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.

This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.

Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude
a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
Bring the notifications-pagination feature up to date with main.

Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).

Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.

Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-21 00:00:28 +00:00
Claude
77908b2be0 refactor(notifications): extract history paging out of CardFeedView
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:

- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
  per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
  per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.

CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:15:06 +00:00
Claude
05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
Address the two audit findings on the infinite-scroll driver.

- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
  in events and notifications collapse heavily into cards, so on a dense
  account a fill could keep pulling until it downloaded the whole history to
  reach the row target. Cap consecutive pages pulled WITHOUT scrolling
  (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
  resumes as the buffer is consumed. Appended older cards don't move
  firstVisibleItemIndex, so the from-top preload still fills the full
  look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
  screen passes page == pagerState.currentPage so an off-screen tab composed
  during a swipe no longer drives the shared account pager, and its buffer
  driver / auto-retry loop / sentinels stay idle. Single screen and side panel
  keep driving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:07:30 +00:00
Claude
c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.

- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
  stops (every relay done-or-stalled) but some are merely stalled, keep
  re-advancing them so recovery no longer depends on the user scrolling to the
  marker or reopening the screen. One non-restarting effect so the backoff
  survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
  passes onRetry and a relay is stalled, the tapped marker's detail popup
  offers an active retry and drops the now-inaccurate "retries on reopen" hint.
  Notifications wire it to advanceAll; DM callers pass nothing (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:36:43 +00:00
Claude
cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.

Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
  into view — the recovery path once the buffer can't keep the frontier ahead
  (relays stalled/exhausted), naturally rate-limited by scrolling.

The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:14:30 +00:00
Claude
ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.

- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
  NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
  one, step every not-done relay one older page (advanceAll). It re-fires as
  each page settles until the buffer refills or all relays run dry — the
  wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
  they are now purely visual (loading is driven by the buffer, not by them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 19:48:27 +00:00
Vitor Pamplona
ef9a891974 Merge pull request #3652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:17:43 -04:00
vitorpamplona
b2cfb5e7ee chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 19:14:54 +00:00
Vitor Pamplona
0601cfafbc Merge pull request #3651 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:12:11 -04:00
vitorpamplona
0214382d0f chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 18:19:06 +00:00
Vitor Pamplona
a0ab3ec66d Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…

Also closes duplicate proposal 4b90b41f, which pointed at the same commits.

Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:13:17 -04:00
Vitor Pamplona
15ce992b61 Merge pull request #3650 from vitorpamplona/fix/napplet-account-isolation-and-consent
fix: v1.13.0 pre-release QA — napplet account isolation, Concord authority, and 30 other fixes
2026-07-20 13:28:41 -04:00
Vitor Pamplona
8913af7a79 fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.

CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.

§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.

ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.

BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.

The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are
un-ignored; two companions (a moderator still bans a plain member, the owner
still bans anyone) passed throughout and pin what the fix had to preserve.
Full :quartz:jvmTest and :commons:jvmTest suites green.

Still open and documented, not addressed here: a banned BAN holder can lift
their own ban (a fixpoint-ordering question that needs a spec ruling), and a
forked ban survives an unban that does not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:53:11 -04:00
Vitor Pamplona
ea6762b137 docs(concord): Banlist rank gap is a conformance bug, not a spec gap
Correcting the previous commit's reasoning. It concluded from the two
implementations that CORD-04 does not rank-gate the Banlist and that enforcing
it would be a unilateral divergence. Reading the actual spec
(github.com/concord-protocol/concord, not in the Armada repo) shows the
opposite: §3 is normative and binds "every action" — "the actor must hold the
required bit and strictly outrank its target — equal cannot act on equal (an
admin cannot ban a peer admin)" — and §5 step 3 restates it. Banning is the
example the rule itself picks.

Only §4, the section defining the Banlist, states the bit half alone. Both
independent implementations read §4 in isolation and made the same mistake,
which is evidence about the section rather than about the readers.

So the fold fix is spec-mandated. It remains consensus-affecting (we would
ignore bans Armada honors until they ship), so it wants coordination rather
than a race, and the fold is still unchanged here.

Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec
citations, both implementations' gates, a delta-based rule that makes the
per-target requirement expressible against a whole-list entity, and two further
reproduced findings — a banned BAN-holder can lift their own ban (so bans do
not stick against any BAN holder), and a forked ban survives an unban that does
not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:05:21 -04:00
Vitor Pamplona
0ae6bc6698 fix(concord): rank-gate the Ban and Remove affordances
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank
— unlike the role picker, which routes through `canActOn`. Both the Members
roster and the message-level path (`Account.concordBanTarget`, the chokepoint
for the quick-action menu, the note dropdown, the note action sections and the
chat action sheet) now require `canActOn(me, target, BAN)`.

This is NOT the no-op it first looked like. The premise that the fold would
drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list
entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and
never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1
admin is therefore ACCEPTED by every client, and the admin then loses every
permission, since `hasPermission` is `!isBanned && ..`. It is privilege
escalation, not a silent no-op.

The fold is deliberately left alone. Armada has the identical gap — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while
its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold
would make us ignore bans every other client honors, splitting the banlist
across clients. Closing it needs a spec change, like CORD-05. Refusing to
AUTHOR such a ban restricts only what we write, never what we accept, so it
cannot diverge consensus.

Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant
and currently fail by design; two companions assert the gate does not
over-correct (a moderator still bans a plain member; the owner still bans
anyone). Un-ignore the first three when the spec closes the gap.

The owner short-circuits the check rather than going through `canActOn`, which
begins at `hasPermission` and is false while banned — since a rogue BAN holder
*can* currently banlist the owner, routing them through it would let them be
locked out of moderating their own community.

Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob
(Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this
member" where Ban and Remove used to be enabled, while the Helper (rank 5)
still offers Roles…, Ban and Remove.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:54:28 -04:00
Claude
ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.

- Add Account.notificationHistory (RelayLoadingCursors) and
  AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
  inbox + NIP-29 group-host relays, registered always-on in
  AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
  and AllNotificationKinds: one combined-kinds filter per relay so the single
  per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
  feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
  marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
  fullness-driven `since` drift), letting the marker-driven pager cleanly own
  everything older — the same live-tail/history split DMs use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 15:53:32 +00:00
Vitor Pamplona
c15774e4e1 docs(qa): record the notifications deadlock and close two open findings
Adds the Notifications and Concord role-grant rows to the coverage table,
retires the "grantConcordRole is unreachable" finding, and records two new
ones: the Members roster offering Ban/Remove on members the viewer doesn't
outrank (silently dropped on fold), and notification cards whose target note
isn't cached rendering as placeholders.

Also adds the pattern worth carrying forward: a paging boundary gated on a
full page deadlocks against a narrow query window, and the empty state is
self-sustaining.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:36:39 -04:00
Vitor Pamplona
4e7242a295 feat(concord): assign CORD-04 roles from the Members roster
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.

Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:

- assignable roles are `roles().filter { myRank < it.position }` — the fold
  drops a grant whose granter does not strictly outrank every assigned role, so
  offering one at or above our own position would publish an edition that every
  client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
  already folds the whole rule (hold the bit, not banned, target isn't the
  owner, strictly outrank) and makes self-promotion fall out for free.

Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.

The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.

`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.

Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:35:48 -04:00
Vitor Pamplona
97b861dd5e fix(notifications): remove the 7-day since floor that emptied the tab
The notifications query pinned `since` to `oneWeekAgo()` whenever it had no
EOSE timestamp. Combined with two other facts that produced a deadlock:

- the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`),
  so EVERY cold start re-pinned the window to 7 days; and
- the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms
  once the feed holds a FULL page (`notes.size >= localFilter.limit()`).

The feed could not fill a page because the query only asked for a week, and the
query could not widen because the feed never filled. Any account whose last
inbound mention was older than 7 days saw a near-empty Notifications tab
forever — including a fresh install of a long-established account.

Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds
1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired
to the paging fallback that the secondary per-key kinds received.

Home is the precedent and does not do this: `filterHomePostsByAllFollows`
passes `since ?: boundary`, i.e. plain null on a cold start, relying on the
relay-side `limit` to bound the response. Notifications was the outlier. These
filters are `#p`-scoped to the user's own key and carry a `limit`
(2000/500/200/20), so an all-time query is one index scan returning at most
`limit` events newest-first — same cost, strictly more useful.

Measured on the test account before the fix: 163 events p-tagging it exist on
relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query
cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the
account's most recent inbound activity was a month old. After the fix the tab
scrolls back through Feb 2025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:34:07 -04:00
Vitor Pamplona
4ee0416a90 docs(qa): record v1.13.0 test coverage, open findings and recipes
The 30 fixes from this session each carry their reasoning in their own
commit message, and the regression tests are in the repo. What none of
that captures is the shape of the testing itself: what was actually
exercised, what was not, what we knowingly left broken, and how to
reproduce the setups. That lived only in one conversation.

Records four things:

- **Coverage**, honestly split three ways: exercised on device; fixed but
  only unit-verified (roughly half the session's work, including the
  entire Concord authority chain); and never opened at all (Nests,
  Marmot, the whole Desktop app, payments, notifications). Plus the
  platform gaps — one tablet, one flavour, one ABI, Android 14 only while
  targetSdk is 37, and no real `release` build.

- **Open findings** we chose not to fix, so they are decisions rather
  than oversights: the unbumped versionCode, the Firebase scheduling
  failure that may cost Crashlytics, Tor settings needing a restart, an
  unreachable relay reported as empty, WebView profiles surviving logout,
  control-plane edits still dropping unknown keys, and the CORD-05
  root-binding gap that Armada shares and therefore needs a spec
  conversation.

- **Setup recipes** that took real time to work out: isolating `amy` by
  `$HOME`, which relays actually accept a NIP-29 create, deep-linking
  past a 1000-entry directory, and proving "no network before consent" by
  running a local relay over `adb reverse` and counting events either
  side of the tap.

- **Patterns** that recurred often enough to be process problems: tests
  that assert the bug, implemented-but-unreachable capabilities, Gradle
  serving a stale up-to-date test run and reporting success for a
  deliberately broken build, and four confident diagnoses that
  measurement overturned.

Written for whoever picks this up next, including the parts that reflect
badly on the testing rather than only the parts that reflect well.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:56:21 -04:00
Vitor Pamplona
a040aa522c fix(perf): keep favicon and settings disk reads off the main thread
Two StrictMode disk violations on startup and on IPC, fixed differently
because they are different problems.

**Favicon storage was writing on the IPC handler thread**, i.e. the main
looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and
`init` scanned the directory with `listFiles`. Both now run on an IO
scope. The directory is still published synchronously so `iconModelFor`
and `record` work immediately — only the scan and the write are deferred.
`keys` updates after the bytes are actually on disk, so a reader is never
told an icon exists before the file backing it does, and until the scan
lands an icon renders its placeholder for a frame and then recomposes,
which is what the StateFlow is for.

**The settings read was left synchronous on purpose, and stays that
way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy
initialiser; the surrounding comments record why it is not hydrated
asynchronously — an async hydrate reopens a window where a late disk read
clobbers a user's toggle. Converting it would have looked like a
StrictMode cleanup while resurrecting a settings-corruption bug. Instead
the prefs file is warmed on an IO thread at startup, so the first
synchronous read hits SharedPreferences' in-memory cache. Best-effort by
design: if a main-thread reader wins the race it pays the disk hit once,
exactly as before, and correctness is unchanged either way.

Verified on device: `notificationServiceEnabled` StrictMode hits went
from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start,
no crashes — and both pinned web-app icons (ditto.pub and Brainstorm)
still render, which is the thing making the write async could plausibly
have broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:52:05 -04:00
Vitor Pamplona
4255ba3345 fix(browser): capture icons from sites that declare only an SVG favicon
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:

    brainstorm:  favicon.svg  AND  favicon.png   <- the PNG is why it works
    ditto:       logo.svg only  (+ an apple-touch-icon nobody read)

Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.

Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.

Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.

No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.

Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.

The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.

Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.

Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:09:24 -04:00
Vitor Pamplona
08bd5d9b4a fix(napplet): tightening an app's trust level drops its live grants
Changing an app to PARANOID did not stop it signing. `sessionAllows` is
consulted BEFORE `signerLedger.decide()`, so a live "allow for this
session" grant short-circuits exactly the check the stricter policy would
have failed. The user picks "I'm a bit paranoid", the UI updates, and the
app carries on signing on the strength of a grant made under the old
policy — until every applet surface closes.

The trust level is a decision about how an app is treated from now on, so
the policy change now drops what that app is currently holding, the same
way revoking and forgetting already do. It fires on any change rather
than only on tightening: loosening is the user's call too, and a stale
grant surviving a deliberate re-decision is surprising in either
direction.

Completes the revocation work — the three paths that change what an app
may do (forget, per-op revoke, trust level) now all clear its live
session grants.

Not automatically tested: this is a Compose click handler and `amethyst`
has no Robolectric. The underlying `revokeSessionGrants` is covered by
`revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to
fail before its namespacing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:28:34 -04:00
Vitor Pamplona
b0668baeec fix(napplet): make revoking an app actually drop its live grants
Three defects, each of which made revocation look like it worked.

**`revokeSessionGrants` had no callers.** It was added with the KDoc "so
revoking an app takes effect immediately instead of lingering until this
broker instance dies" and then never wired, so revoking an app in
Connected Apps left its in-memory session grants active. The user
revokes; the app keeps signing.

**And it was broken as written.** `sessionAllows` keys are the
account-namespaced `napplet:<signer>:<coordinate>|<op>`, but every revoke
call site holds the BARE coordinate, so the prefix match found nothing.
Wiring it naively would have looked correct and silently done nothing. It
now namespaces before matching, and also clears the post-Cancel re-prompt
cooldown so a revoked app prompts on next use instead of being quietly
dropped.

**Worse: there were three ledgers.** `NappletBrokerService`,
`ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed
their own `NappletPermissionLedger`, while ALLOW_SESSION grants are
per-instance in-memory state. So "Forget" cleared the screen's own
always-empty session map while the grants the broker actually consults
lived on. The KDoc described a process-wide singleton; it wasn't one.
Promoted to a real singleton in AppModules alongside the existing
permission store, and shared by all three.

The screens are plain composables with no binder to the broker service,
so rather than invent an IPC path the cached broker moved to the
service's companion under a lock — matching the sibling main-process
registries in that package. Both revoke paths call it: the Forget button
and the per-op revoke.

Also gives `NappletPermissionLedger.endSession()` its first caller, which
promoting the ledger made necessary: it used to die with the service, so
session grants had a natural bound. Now that it outlives the service,
`onDestroy` restores exactly the lifetime ALLOW_SESSION already implied.
The boundary is safe — the service is bind-only and is destroyed only
once every applet and browser surface has unbound, so switching between
two open applets never drops grants mid-use. Deliberately NOT wired to
account switch (already handled by account-keying) or to backgrounding
(would re-prompt mid-use).

Test notes, kept honest: the revoke test was verified to fail before the
namespacing fix. The `endSession` test PASSES without the change —
`endSession` itself was always correct, the bug was that nobody called
it — so it is characterization for the new lifetime contract, not a
regression test. The `onDestroy` wiring and the composable click handlers
have no automated coverage; `amethyst` has no Robolectric and no harness
was invented for them.

Known gap, left alone deliberately: changing an app's trust level to
PARANOID does not drop its live session grants, because `sessionAllows`
is consulted before the signer ledger. That is a revoke-shaped action and
belongs in the same fix, but it is a behaviour change and was out of
scope tonight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:13:37 -04:00
Vitor Pamplona
62440748a7 fix(concord): stop a rejected edition orphaning the honest ones after it
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:

    v0 owner    (grant mods)
    v1 owner    (grant admins)     <- fold stopped here, forever
    v2 MIDTIER  (escalation, correctly rejected)
    v3,v4,v5 owner                    orphaned, unreachable

`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.

Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.

So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.

The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.

Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.

Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.

The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.

Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:06:13 -04:00
davotoula
2d29f7ccda no empty methods
use constants instead of repeating strings
2026-07-20 13:58:06 +01:00
Vitor Pamplona
5f712b3a8a feat(concord): let a member leave a community
`Account.leaveConcordCommunity` has existed since the feature landed and
had ZERO callers anywhere in the repo — so joining a Concord community
was one-way. It stayed in the account's kind-13302 list and in Messages
permanently, with no affordance on the community screen, the Members
screen, or Messages.

Found because a test account joined a probe community whose only relay
then went away: stuck in the list, channels unrecoverable, nothing to
tap. Third capability found this session that is fully implemented and
unreachable, after `ConcordInviteBundle.isExpired` (called only from a
test, so invite expiry was decorative) and
`NappletPermissionLedger.endSession` (never called, so session grants
outlived every revoke). Each made a feature look complete to anyone
reading the model.

Adds "Leave community" to the community screen's top-bar overflow,
mirroring how NIP-29 relay groups already place membership-destroying
actions, behind a confirmation. It renders whether or not the Control
Plane ever folded, which is the case that matters — a dead-relay
community never folds.

The copy is deliberately narrow about what leaving does: it removes the
community from THIS account's list and stops syncing, it does NOT notify
the community or remove anyone from a roster, and returning needs a new
invite.

Owner leaving is allowed, with an extra warning. Blocking it would make
the actual stuck case unfixable, since the motivating community was one
the account created; and it is the user's own private list to edit.
But it is irreversible in a way worth stating: `ownerSalt` lives only in
that entry, so discarding it retires the community rather than
transferring it. Ownership is read from the stored entry rather than the
folded authority, because a dead-relay community has no folded authority.

Works offline by construction: the underlying call rewrites the local
list (falling back to the on-disk backup when nothing folded) and
publishes fire-and-forget to the user's OWN outbox — never the
community's relays — so the UI does not wait on a relay that cannot
answer. Both paths that could resurrect a left entry were checked:
stranded recovery iterates live communities only, and the list import
takes the newest 13302, which is ours.

Tests cover the real logic behind the button — `unfollow` was previously
untested — driven through the offline-backup path with no cached relay
event: drops only the named community, preserves other memberships'
secrets, empties cleanly on the last one, no publish for a community
never joined, and the rewritten list stays self-encrypted.

Not verified on device: building an APK would have replaced the build a
concurrent Concord authority test was running against. The composable
itself has no automated coverage — `amethyst` has no Robolectric.

Two follow-ups noted, not fixed: leaving does not unpin a community from
the bottom bar, so a pinned one leaves a dead tab; and
`grantConcordRole` is another zero-caller capability — the general
CORD-04 role-grant path is unreachable, with only the narrower
make/remove-admin wired up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:57:52 -04:00
Vitor Pamplona
ab2f4d2da8 fix(nip29): split group-state REQs so relays stop rejecting them
A joined NIP-29 group showed its raw hex id instead of its name — in the
header, the Messages list and the browse directory — offered "Join" to an
account the relay already listed as an ADMIN, and hid the entire admin
surface. Members, Edit group, Invite people, the Threads "+" FAB,
pin/unpin, share and the member count all sit behind the same
`isMember()` gate, so none of them were reachable. Posting was blocked
too.

The cause is not membership modelling, warmup skipping, or the local
kind-10009 list. **The relay rejected the subscription outright.** The app
asked for all five group-state kinds in one filter, captured on the wire:

    SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}]
    RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds
                      with others"]

The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache.
Membership is derived from those events, so it fell back to NONE and
every gated control disappeared. Chat messages arrived on a separate `#h`
REQ, which is why the group looked half-alive rather than broken.

relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe:

    39000-39003 + 39005 in one filter   -> CLOSED, 0 events
    the same kinds as two filters       -> 4 events, EOSE
    39000-39003 alone                   -> 4 events

So the fix is to split the kinds into two filters in the same REQ — no
extra subscription, no new assembler. This repairs every joined group on
any relay29-family relay, not just the one that surfaced it: the always-on
joined-state subscription was hitting the identical rejection everywhere.

Two hypotheses were tested and disproved rather than assumed. The
join-tap/warmup theory was wrong — instrumentation showed the filter was
built correctly with the right scope and only failed on the wire. The
`since`-on-replaceable theory was also wrong here: `since` logged null on
every call, since the map is per-subscription and reset on disconnect
rather than a persisted floor. Both left unchanged.

Regression tests fail on the old single-filter behaviour and pass on the
new one, verified by reverting the behaviour while keeping the constants
so the tests genuinely run rather than fail to compile.

Verified on device: header now reads "Amethyst QA 1.13" with an Admin
badge and member count 2, composer enabled, overflow menu showing
Members / Edit group / Invite people / Leave — all consistent with the
relay's roster.

Not fixed here: a stale "Requested" state is client-side only and is
never reconciled against an arriving roster. With this fix a genuine
pending→member transition now resolves, but a rejected 9021 still shows
"Requested" until restart. Separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:18:13 -04:00
Vitor Pamplona
a87049e451 fix(chats): stop crashing when a relay's group directory is sorted
Browsing a relay's groups (Relay Groups → Find groups → pick a relay)
crashed the app to the launcher:

    java.lang.IllegalArgumentException: Comparison method violates its
    general contract!
      at java.util.TimSort.mergeLo
      ...
      at RelayGroupChannelListScreen$allChannels$3$1$1.emit

`sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on
every comparison, and the key comes from mutable shared state — the
channel's display name, which a kind-39000 directory event can change
while the sort is running. TimSort detects the inconsistency and throws.

The failure scales with directory size and needs no user action: a relay
hosting 1237 groups tripped it on the first browse, while a relay with 18
never did — which is why an earlier sweep of this same screen missed it.
v1.12.6 already fixed this class elsewhere ("snapshot live-stream status
order before sorting"); these sites were not covered.

Adds `sortedBySnapshot`, which computes each key ONCE before any
comparison runs, and applies it to all four sites that sorted live
objects by their display name: the relay group directory (both the
initial value and the observer), the parent-group picker, and the
name-ordered search results. The picker's site also had to materialise
its Sequence first, since sorting lazily would have re-introduced the
same window.

Verified on device: the exact tap that crashed now loads the screen with
zero FATAL EXCEPTIONs and the app stays in the foreground.

Found while setting up a NIP-29 group to test the admin surface, which is
also how the directory got large enough to expose it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:43:40 -04:00
Vitor Pamplona
894dc7d99b fix(location): don't self-select a location the user never picked
The map picker opened blank, showed "Tessalit, ML" — the middle of the
Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user
could join a geohash cell they never selected. Three of those are one bug
and the fourth follows from it. Notably there is no tile, network or Tor
problem: tiles were downloading and rendering the whole time.

**The defect.** `onCenterChanged` treated any map movement beyond
`SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a
scroll when the MapView is first laid out, reporting a pixel-quantized
centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the
epsilon. The phantom scroll therefore always cleared the threshold and the
picker selected a location before the user touched anything. No epsilon
can fix that: the quantization error is unbounded at low zoom. Replaced
with a `mapTouched` gate, so only finger-driven movement can become a
selection.

**Why it looked blank.** That phantom selection made the level effect zoom
to 12 over empty desert, and the night tile filter darkened featureless
Sahara tiles to near-black. Tiles were present; there was nothing to see.
Tapping "Region" re-zoomed and a detailed map appeared instantly, which is
what ruled out a loading failure.

**Why Tessalit.** The self-selection encoded the default centre, and
`WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as
the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and
off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the
constants.

**The label mismatch was mostly a misread, with a real bug behind it.**
20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the
geohash between the `e…` and `s…` halves of the world — both cells really
were Tessalit, so the name was right. The genuine bug is narrower: the name
came from a 450 ms-debounced cell, so mid-pan the previous cell's name
rendered beside the new geohash. The name now renders only when the
debounced cell matches the live one, and an unresolvable cell shows no
label rather than echoing its geohash.

Side benefit: the seeded composer path no longer has its exact seed
coordinates overwritten by osmdroid's quantized initial scroll.

Verified on device before and after, on both the Teleport screen and the
composer dialog: tiles on open, no bogus place, no selection until a real
touch, Confirm disabled until then; then pan → cell with no label at sea,
pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed.

Pre-existing, not fixed: in the modal dialog only, the picker header is
overdrawn by the MapView and invisible. Proven to predate this change by
A/B against an unmodified build. Back-dismiss works; cosmetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:22:24 -04:00
Vitor Pamplona
717461f4d0 fix(chats): draw day headers above the message they introduce
Day separators in group chat did not match the messages under them: a
"Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23"
header above a Sep 25 one — while LATER separators in the same
conversation were correct.

The obvious explanation — that a bubble group spanning midnight takes its
header from the group's first message — is wrong. `ChatGroupPosition`
already refuses to group two messages whose formatted dates differ, so a
group cannot cross a day boundary at all.

The real cause is an off-by-one from a false assumption about
`reverseLayout`, stated in the comment that used to sit here: that later
content "draws just above the message". `reverseLayout` reverses the
order of the LIST'S ITEMS, not the content inside one item — each item's
Column still lays out top to bottom. Verified with a throwaway headless
Compose layout probe rather than by argument: with `reverseLayout = true`
and two items each holding a message and a divisor, item 1 (older) sits
above item 0 (newer), but within each item the divisor sits BELOW its own
message.

So the divisor for item i was labelled with date(i) and gated on
date(i+1) != date(i) — the arithmetic was right — but drawn between item
i and item i-1, heading the NEXT, newer message while showing the
previous one's date. That also explains the detail the grouping theory
could not: whenever two consecutive messages fall on the same day the
mislabelled header happens to read correctly, which is why only some
separators looked wrong.

Moving the divisor to the top of the item Column fixes it. Its arguments
were already correct, so nothing else changed — no formatting change, no
grouping change, and one fewer list lookup since it now reuses the
already-computed older note. Subject headers had the same inversion and
are fixed with it.

One call site serves every chat surface — NIP-29 relay groups, NIP-28
public chats, ephemeral chats, live-activity chats, Concord channels,
private DMs, geohash chats and Marmot groups — so all were wrong and all
are fixed.

`markersInGap` immediately below had the identical bug from the identical
assumption: it is handed the bounds for the gap toward the OLDER message
but, composed last, rendered in the gap toward the newer one. Moved for
the same reason.

Not unit-tested, deliberately: the date arithmetic was always correct, so
a pure-function test passes both before and after. Catching this needs a
Compose layout assertion, and `:amethyst` has no Robolectric or JVM
Compose harness — only instrumented tests. The layout probe above is what
established the behaviour; it was deleted afterwards.

Not verified on-device: the visual result is inferred from that probe
plus the reported symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:00 -04:00
Vitor Pamplona
f2e42105f7 fix(podcast): stop rendering an episode description twice
The episode renderer shows two blocks: the `description` tag via
`episodeDescription()`, and the event content as markdown. Most feeds put
the SAME text in both, and the thread view renders both (`makeItShort` is
false there), so the whole description appeared twice inside one card —
one author header, one player, two identical bodies, each with its own
"Show More" pill.

The existing guard only suppressed the markdown block when the content
was blank. It now also suppresses it when the content merely repeats the
description, compared on collapsed whitespace so a copy differing only in
wrapping still counts as a duplicate. Feeds were unaffected because they
pass `makeItShort = true` and never rendered the second block, which is
why this only showed on the episode screen.

Found by a device sweep of the untested feature areas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 -04:00
Vitor Pamplona
c6a6068486 fix(chats): stop showing ciphertext in DM previews; fix stuck npub names
Two bugs found by a device sweep of the Messages list.

**Raw NIP-04 ciphertext rendered as the message preview.** Rows in New
Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`.
Cause: every decryption branch in `Account.cachedDecryptContent` and
`decryptContent` is gated on `isWriteable()`, and the non-writeable path
fell through to `event.content` verbatim — which for a kind:4 IS the
NIP-04 blob. A read-only (npub-only) login therefore hit this on every
legacy DM room.

Both functions now return null instead of the ciphertext, which closes
the leak on every surface reading them — including the open chatroom
body, which had the identical fallthrough. A new pure classifier backs
the preview and never reads `event.content` for an encrypted kind, so a
future raw fallback cannot resurface there.

Pending and undecryptable are now distinguished on facts the UI actually
has, rather than collapsed into one message: no key at all, or a kind:4
between two other people, is "could not decrypt"; encrypted with our key
a party but plaintext not yet back is "Decrypting…", which resolves
itself when the signer answers. The old code showed the not-found string
for the pending case.

**Group DM titles stuck on npubs while the facepile beside them showed
real names** — and this one is not a display bug at all. Both already
observe metadata through the same flow; the fault is in `User`:

    fun metadata() = metadata ?: UserMetadataCache().also { metadata = it }

Non-atomic lazy init on a plain field, called from BOTH the Compose main
thread (every `observeUserInfo` composition) and the relay/IO threads
(`updateUserInfo`). Two threads can each read null, each allocate, and
one instance is orphaned. A composable collecting the ORPHANED cache
never receives the metadata, so it sits on its pubkey fallback forever
while a sibling that got the surviving instance renders the name — which
is exactly "npubs in the title, names in the facepile, same row", and
why it never recovers.

All six per-user lazy caches are now `@Volatile` with double-checked
locking under one process-wide lock, held only for the allocation. The
store holds tens of thousands of users, so a lock per user would be
worse than the bug.

This likely explains a broader class of "some names resolve and others
never do" symptoms, not just the row that surfaced it.

Not fixed: for a read-only account the open chatroom body now renders
nothing for a kind:4 rather than ciphertext — better, but it deserves the
same "could not decrypt" placeholder the preview row got.

Unverified: that the account which showed the ciphertext was in fact
read-only. Every other route to ciphertext was traced and returns null,
so the non-writeable fallthrough is the only reachable source, but the
device state itself was not captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:01:19 -04:00
Vitor Pamplona
73d59a29bf fix(nip46): gate identity reads on pairing; make decrypt consent informed
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.

**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.

Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".

`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.

**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.

Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
  private messages **with Alice**". It never degrades to nothing —
  cached name, else a shortened npub. Knowing *whose* messages is a
  categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
  preview, as documented. It is a local operation and nothing is exposed
  until approval. Failure, blank and hang all collapse to an explanatory
  string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
  `DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
  The dialog's primary button becomes "Always allow for Alice" with the
  broad option demoted. Because the ledger stores an opaque op key, no
  persisted decision migrates and the storage format is untouched.

  Scoping decrypt per counterparty *instead* would have been worse than
  the bug: a DM client would prompt once per conversation, training users
  to approve everything. A narrow option beside the broad one gives
  granularity without the prompt explosion.

Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.

Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.

Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.

Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:28:05 -04:00
Vitor Pamplona
7792c42f1d fix(blossom): bound what a paid server can extract on a 402
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.

**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.

**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.

**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.

**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.

The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.

Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:03:20 -04:00
Vitor Pamplona
6045e28830 fix(cashu): validate a token's mint URL before contacting it
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.

Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.

Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.

The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.

A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.

Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.

DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.

Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:02:59 -04:00
Vitor Pamplona
c84acddc0c fix(concord): renaming a private channel no longer makes it public
`renameConcordChannel` built `ChannelEntity(name = ...)` from scratch
without reading the standing definition. `private`, `voice` and every
other flag default to false, so the published edition declared the
channel PUBLIC and TEXT. Renaming a private channel silently reclassified
it; renaming a voice channel silently converted it.

The messages themselves stay encrypted — a private channel is
independently keyed and a rename does not rotate that key — but the
Control Plane, which is what every client reads to decide how to present
and gate a channel, now says the wrong thing about it. A moderator
fixing a typo in a channel name should not change who a client believes
may read it.

`deleteConcordChannel` had the same shape: its tombstone also reset the
flags, so retiring a channel reclassified it on the way out.

Both now carry the standing definition forward and change only what they
mean to change.

Found while auditing which write paths re-serialize a decoded entity
rather than re-wrapping it — the same audit that produced the community
list fix. This one is worse than the unknown-key loss it was found
beside, because it discards KNOWN fields with a privacy meaning rather
than fields we merely failed to model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:44 -04:00
Vitor Pamplona
5c23f8490d fix(concord): stop destroying other clients' data in the community list
The private community list (kind 13302) is documented as wire-compatible
with Armada's `communityList.ts`, whose entry type ends in
`[k: string]: unknown` — unknown keys are part of the contract, and
`ConcordJson`'s own KDoc says shapes are "deliberately client-extensible
(CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable`
DTOs meant decode dropped every unmodelled key and encode never restored
it, so **every Amethyst write of a user's list silently stripped fields
another client had written**, across every community in it.

Already proven, not hypothetical: `JoinMaterialWire` declared a
`refounder` field that nothing in the repo reads, so it was parsed and
destroyed on the first write. We only avoided destroying Armada's
`invite_ref`/`excluded_at_epoch` because those were modelled hours ago,
for stranded recovery — the anchor recovery depends on would otherwise
have been deleted on every write.

Each wire DTO's compiler-generated serializer is now wrapped in a shared
`JsonTransformingSerializer` that lifts unknown keys into a bag on
decode and merges them back on encode, with declared fields winning on
conflict. The known-key set is read from the descriptor rather than
hand-listed, so it cannot drift from the DTO. Preserved at the document
root, each entry, the `current` join material, each channel, each
held_root, each tombstone, and everything nested inside `seed`.
`refounder`'s typed field is removed so it round-trips generically.

Two further data-loss bugs surfaced while doing it, both fixed here:

- **`seed` was overwritten with `current` on every write**, destroying
  the immutable join anchor. It is now kept and re-emitted verbatim as a
  raw JsonObject — we never hydrate from it while `current` exists, so we
  have no business rewriting it, and keeping it raw preserves everything
  nested inside for free.
- **`tombstones` were re-encoded as an empty list**, which did not just
  lose their unknown keys: it RESURRECTED communities another client had
  deliberately removed. They are now carried verbatim.

Verified by four separate sabotage passes (no-op the transform,
re-mint `seed`, restore the empty-tombstone write, flip the merge order);
each new test fails under at least one, and every mechanism is covered.

Control-plane re-serialization was audited too and is NOT fixed here:
`compactControlPlane` is safe (it re-wraps the original seal verbatim),
but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and
the channel edits — construct fresh typed entities and re-encode, so they
drop extensions the same way. Fixing those means merging into the head
edition's raw JsonObject on each edit path, which is a larger change than
this should carry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:19 -04:00
Vitor Pamplona
f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.

Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.

Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.

Details that decide whether this works at all:

- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
  when it has none. Without it a two-device merge silently discards the
  only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
  rotation; it rebuilt the entry field-by-field and would have dropped
  them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
  member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
  so it is monotonic and cannot be steered by an unrelated bundle.

Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.

Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.

Two things this surfaced, both left for their own change:

- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
  plus closed DTOs — while Armada's format ends in `[k: string]:
  unknown`. So every Amethyst write of the community list silently strips
  fields Armada added that we do not model; it already discards the
  `refounder` field we parse but never re-emit. That is live interop data
  loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
  than adopting the bundle's, matching what `joinConcordViaInvite`
  already does. If a recovered member should pick up new-epoch grants,
  both paths need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:52 -04:00
Vitor Pamplona
fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).

So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.

Adds a per-entity floor: the version AND hash last successfully folded.

- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
  lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
  must contain the exact edition already folded (version and hash; a
  same-version sibling is a fork, not our chain), then walks up. A head
  below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
  head. Refusing by *retaining* matters here: this fold is recomputed
  from scratch each time, so letting an entity vanish would itself be a
  rollback — a dropped banlist is an unban.

The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.

Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.

Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).

Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:25 -04:00
Vitor Pamplona
30f4638954 fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.

Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.

Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.

Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.

That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:35:50 -04:00
Vitor Pamplona
0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.

It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.

**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.

**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.

Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).

Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:10:36 -04:00
Vitor Pamplona
c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains.

**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.

So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.

Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.

**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.

**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.

**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.

The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:00:50 -04:00
Vitor Pamplona
996b800da5 docs(changelog): correct claims the code does not support
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.

Corrected:

- **Privacy Lock.** The notes said it gates Messages "behind a password or
  biometric" on "Android and Desktop", with inactivity auto-lock and
  optional preview redaction. It does not exist on Android at all — no
  code, no settings entry — and there is no biometric implementation on
  any platform (`CredentialPrompter` has no implementations). Preview
  redaction persists a setting nothing reads, and the auto-lock is a fixed
  timer, since the idle-reset modifier is never applied. It is also a
  screen gate rather than encryption at rest: the account stays live and
  messages keep syncing while locked. Now described as what ships — a
  password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
  approval."** Only payments require per-use consent; everything else can
  be granted once and reused, and the default trust level auto-signs notes,
  reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
  and scoring exist only in the `amy` CLI — no app module references it.
  The app consumes NIP-85 cards published by an operator, which is what the
  entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
  relays used by people you follow. What exists is category rules evaluated
  per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
  placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
  cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
  clients only; the desktop, CLI, geode and sandbox blob clients don't
  install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
  evict "on the first strike"; both take repeated failures, and the
  first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
  the optional nickname is one global handle, so setting it links your
  posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
  member keeps the keys until a Refounding.

Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.

Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:52 -04:00
Vitor Pamplona
2ccd837f30 fix(napplet): sign as the account a surface was launched as
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:

- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
  handed it **B's** key.

Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.

`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.

Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.

The same live-account resolution existed on two adjacent paths, fixed
here too:

- Relay subscriptions took the account from a global supplier, so a
  full-screen surface's REQs would target the newly-active account's
  relays while its signatures came from the old one. The account is now
  passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
  to A could be told it had become B while signatures still returned A —
  the same desync inverted. It is now bound to the surface's own account
  and reports only that account going away.

Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:38:52 -04:00
Vitor Pamplona
c8be65a02e fix(concord): require consent for invite links, enforce expiry, fold the head
Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:50 -04:00
Vitor Pamplona
153191e722 fix(nip46): stop auto-signing relay AUTH under the default policy
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.

A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.

Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.

22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:33 -04:00
Vitor Pamplona
5fd8be64fd fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`computeShares` paid each fee recipient `totalMilliSats * split / 100`
with no upper bound on `split`, and `split` comes verbatim from a
kind-30054 episode event that anyone can publish (`ValueTag.parse` is a
bare `fromJson` with no validation). A single `fee:true, split:1000`
recipient was therefore paid TEN TIMES the amount the user chose. The
`remainder` clamp looked like a safety net but only zeroed the honest
recipients; it never touched the fee recipients themselves.

Proven by test before fixing: `split:1000` pays 10x, and two fee
recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap.

This was reachable in the worst possible place. Streaming V4V pays every
minute, automatically, so a modest multiplier stays under a typical NWC
budget and simply runs — and the on-screen running total tracks the
INTENDED amount, so it reads "100 sats" while 1,000 left the wallet,
while the streaming error handler suppresses the toast. The ordinary zap
button reroutes through the same path for any note carrying a value
block, so it was not limited to the streaming toggle.

Fees are a percentage off the top, so each is now clamped to 100% and the
cumulative total to the remaining budget: the payout can never exceed
what the user chose.

The existing test asserted the right invariant (`sum <= total`) but only
ever ran it on well-formed input, which is why this survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:18 -04:00
Vitor Pamplona
7f135a12ab fix(embed): rebuild pinned tabs when the account changes
A WebView's storage profile is fixed at construction, so per-account jars
mean each pinned tab has to be rebuilt on an account switch. Nothing did
that, and the result was a tab that went permanently black: the
logged-in subtree is wrapped in `key(account.pubKey)`, so a switch
disposes and rebuilds it, which closes each sandbox session — but
`EmbeddedTabHost` is process-scoped, so the warm controllers survived.
The rebuilt layer then re-attached fresh views to controllers whose
adapter had already been consumed, and a `SandboxedSdkView` with no
adapter paints only its background, forever.

Three fixes:

- Tabs are torn down and re-armed against the new account's profile when
  it changes, including tabs that were never opened, so none survives
  bound to the previous account's jar.
- `attachView` now recovers instead of silently doing nothing when a
  second view attaches after the adapter was spent, minting a FRESH
  session id — reusing the id let the disposed view's late `close()`
  reap the replacement.
- The re-warm kickoff moved off `LaunchedEffect`. It dispatches through
  the composition's scope, and an account switch floods the main thread:
  measured on a slow device, the sweep started 943-1157 ms after the
  teardown, and the account watcher itself fired 3-4 s late. Running it
  synchronously in the same apply phase drops that to 10-23 ms. The
  suspending work stays in a coroutine; only the kickoff is immediate.

Also gates the load overlay on the tab id rather than a live controller,
so a tab that is re-arming shows the existing spinner instead of a bare
surface — previously the one moment it most needed a cover was the one
moment it had none.

Verified on device across both switch directions, and with profile
detection force-disabled to confirm the recovery path alone prevents the
black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:04 -04:00
Vitor Pamplona
095b156953 fix(napplet): give each account its own WebView storage jar
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.

So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.

Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.

The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.

Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.

Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:58:47 -04:00
Vitor Pamplona
5bce87d76e fix(napplet): make the consent dialog say what a signature really does
The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.

Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.

Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.

Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:57 -04:00
Vitor Pamplona
389b460f7a fix(napplet): disclose what connecting to an app pre-grants
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.

`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:12 -04:00
Vitor Pamplona
dbf15a2146 fix(napplet): scope applet grants per app and per account
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:

- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
  ("sign:1"), and the check ran *before* the per-app ledger lookup. One
  app's "Allow for this session" therefore authorized that op for every
  other applet and browser origin, silently, for the broker's lifetime.

- **Across accounts.** A grant made under one npub authorized the same
  applet under every other npub on the device. For a user keeping a
  pseudonymous account separate from a real one, an app authorized by
  one could sign as the other with no prompt — defeating the point of
  separate accounts.

NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:

- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
  account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
  read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
  is shared with NIP-46, whose sessions run for a specific account rather
  than the active one, so scoping it there would break a background
  bunker. The napplet path namespaces its own coordinate instead.

Also here, found while scoping:

- `identity.watch` consulted only the manifest declaration and never the
  ledger, bypassing a standing DENY — it short-circuits before
  `NappletBroker.handle`, where the "a standing denial always wins" rule
  lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
  keys are written with NUL, so it silently matched nothing and always
  returned an empty list.

Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.

Regression tests cover both leak directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:56:58 -04:00
Vitor Pamplona
ee3b8786d4 Merge pull request #3648 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:15:02 -04:00
Vitor Pamplona
49b93db532 Merge pull request #3647 from vitorpamplona/claude/changelog-new-version-y1y331
docs(changelog): add v1.13.0 release notes
2026-07-19 15:13:29 -04:00
vitorpamplona
710c68d6aa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 19:09:10 +00:00
Vitor Pamplona
824f9898de Merge pull request #3645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:06:36 -04:00
Claude
fb730d7ea8 docs(changelog): add v1.13.0 release notes
Add docs/changelog/v1.13.00.md — the v1.13.0 "Web Apps, Communities & Git"
release notes — and link it at the top of the changelog index.

Covers the full v1.12.6..HEAD range: the in-app Browser and NIP-5D/5A web
clients, Concord communities, NIP-34 git collaboration, Location Channels, the
NIP-46 Remote Signer and Privacy Lock, NIP-42 relay login permissions, NIP-29
Groups, the redesigned Messages inbox, Blossom file sync, Podcasting 2.0,
proof-of-work (NIP-13), negentropy (NIP-77), GrapeRank, accent theming, and the
Desktop, amy CLI, Quartz, and Geode work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrPNt4FchqArpMtAHHfqGi
2026-07-19 19:06:03 +00:00
vitorpamplona
fe761ab7f2 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 14:44:14 +00:00
Vitor Pamplona
768e19ff9b Merge pull request #3646 from vitorpamplona/claude/update-all-dependencies-h7vnw4
chore(deps): update dependencies across all modules
2026-07-19 10:41:30 -04:00
Claude
18c0c4291f chore(deps): update dependencies across all modules
Bump to latest stable (or latest within an existing pre-release track),
skipping stable→alpha/beta jumps for production safety.

- kotlin 2.4.0 → 2.4.10 (+ kotlinTest, compose compiler); ksp 2.3.9 → 2.3.10
- composeBom 2026.06.00 → 2026.06.01; composeRuntimeAnnotation 1.11.3 → 1.11.4
- firebaseBom 34.15.0 → 34.16.0; jacksonModuleKotlin 2.22.0 → 2.22.1
- kotlinxCollectionsImmutable 0.5.0 → 0.5.1; sqlite 2.6.2 → 2.7.0
- composemediaplayer 0.10.0 → 0.11.3; zoomable 2.12.0 → 2.13.0
- jlatexmath 1.4 → 1.5; jna 5.14.0 → 5.19.1 (nestsClient)
- benchmark 1.5.0-alpha06 → alpha07; genaiPrompt 1.0.0-beta2 → beta3
- bump DisableCacheInKotlinVersion guard 2_4_0 → 2_4_10 (iOS test workaround)

appfunctions held at alpha09 (appfunctions-service has no alpha10 published).
AGP 9.3.0, gms 4.5.0, kotlin stable, ktor, coil, media3, navigation, and other
androidx libs already at their newest stable; alpha-only newer releases skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TreeiMiHrhWce6PbnA5qWz
2026-07-19 14:06:37 +00:00
Vitor Pamplona
618664c45a Merge pull request #3644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 09:46:56 -04:00
vitorpamplona
7b5e5f572b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 02:42:47 +00:00
Vitor Pamplona
f875d2c397 Merge pull request #3643 from vitorpamplona/claude/nip29-location-geohash-ux-890wjz
Map-first geohash location UX: picker, teleport, follow, retarget
2026-07-18 22:39:58 -04:00
Vitor Pamplona
b801a6b138 Merge pull request #3639 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 21:13:04 -04:00
vitorpamplona
6ba4ab0a4b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 00:27:16 +00:00
Vitor Pamplona
035c50421c Merge pull request #3642 from vitorpamplona/claude/cli-module-deep-review-le4dsj
amy CLI overhaul: exit-code/JSON contract v1, per-command help, contract tests, docs, and thin-layer extractions
2026-07-18 20:24:28 -04:00
Claude
4efb2cca98 fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:

- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
  10x the idle window, delay()-watchdog: cancellable and virtual-time
  testable). The pure idle window was unbounded when a relay trickled
  events forever — sandboxed napplet queries, set -e fetches, and
  marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
  timeout scope (an expiring window could cancel verifyAndStore
  mid-write and silently drop a received event); the timeout is armed
  only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
  unsynchronized events/seenIds mutation from concurrent socket
  threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
  (synthetic no-response entries no longer render as 'relay rejected
  your message' in app callers); results built by pure associateWith;
  shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
  to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.

CLI:

- publishGuard: all-transport failure exits 124 as timeout; rejected/1
  is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
  --help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
  early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
  shadowing a real npub/NIP-05/hex); stderr color requires a
  positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
  graperank publish/followers/register no longer silently drop
  malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
  conversions, all strict; offer/debit --timeout > 3600 rejected with
  a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
  the marmot reactions harness; printUsage drift (offer pay --with,
  profile --clink-offer, search --kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-19 00:17:43 +00:00
Claude
962d1706dc fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.

timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).

Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.

New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:39:25 +00:00
Claude
620fc465df feat(cli): publish results carry each relay's rejection reason; converge output shapes
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:

- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
  errors, and silent timeouts now survive as PublishResult(accepted,
  message) per relay instead of dying in a debug log. The existing
  boolean APIs delegate unchanged. Silent relays are reported as
  'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
  RawEventSupport.ackFields(ack) is the one canonical projection every
  publisher emits: published_to (urls) + rejected_by as
  [{relay, reason}] — 'why didn't it post' now answers itself, in
  partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
  signed an event (feed/search/dm/message list items), 'pubkey' an
  identity being described; profile show and outbox add the bech32
  npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
  (dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
  size_bytes); the text renderer drops the fragile bare-'size'
  heuristic and colors stderr progress independently of a piped
  stdout.
- Error details are sentences everywhere (not bare gids); dead Result
  class removed from the quartz publish accessory.

Docs updated (DEVELOPMENT output conventions, README rejected example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:30:06 +00:00
Vitor Pamplona
43704772a7 Merge pull request #3641 from vitorpamplona/claude/nip29-group-load-perf-wz4yca
NIP-29 group chat: split state (always-on) from content (paginated)
2026-07-18 19:15:22 -04:00
Claude
8d3a47b8f8 refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:00:15 +00:00
Claude
82f041b1f5 docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.

- README: sections for search, zap (incl. --with auto-pay), podcast20,
  nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
  a 'Which chat system?' comparison table; output section rewritten for
  the new contract (exit-code derivation, rejected, unknown-flag
  errors, -- terminator, per-command --help); layout diagram fixed for
  the SQLite default + operator/ + concord.json; the bunker nak-interop
  claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
  bunker, search; zap partial), rows added for relaygroup/geochat/
  concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
  fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
  documented, no-prompts carve-outs, refreshed architecture tree +
  command template (USAGE/route(help=)/rejectUnknown/publishGuard),
  SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
  mis-spliced marmot row repaired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:59:37 +00:00
Claude
6e664b2db6 refactor(cli): extract the drain loop to quartz accessories; split Context per domain
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:55:56 +00:00
Vitor Pamplona
ad2c232868 build(tor): rebuild libarti_android.so for the SOCKS reply-code fix
Regenerates the shipped native library from the preceding lib.rs change. Kept
as its own commit so the binary diff is isolated and can be audited against the
source change independently.

Built with the canonical reproducible-build path (/tmp/amethyst-arti-build) and
the pinned 1.94.1 toolchain; ARTI_VERSION and Cargo.lock are unchanged, so
lib.rs is the only input that moved. Note that the build path is embedded in
the output, so overriding ARTI_REPRO_DIR produces different bytes — the
artifact committed here comes from the canonical path.

verify-reproducible.sh passes: two clean builds produced identical bytes.
  arm64-v8a  3293f9fd1663f9481972f4b705fe80feb9650271b5a3b4258420501d6375bb3e
  x86_64     a5a5864741a09708cd0b6e44ef3ba28298b4862872084d98e172487d6b12720b

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
dd6e7e2852 fix(tor): map Arti errors to accurate SOCKS reply codes
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.

This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.

socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.

RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.

Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
850c3e58e4 feat(relay): debug-only per-relay cold-start census
Nothing could answer "what did every relay in the pool actually do, and why did
the ones that failed, fail". RelayStats has counters but no dump path and no
failure taxonomy; RelaySpeedLogger counts events per second, not connection
outcomes; RelayLogger prints one line per event, which at the few-hundred-relay
cardinality of the outbox model is thousands of lines to grep rather than a
table to read.

BootRelayDiagnostics buckets connection failures by cause (separating a Tor
SOCKS refusal, which says nothing about the relay, from DNS, TLS, an HTTP
upgrade rejection, a timeout, or a genuine refusal), counts REQ/EOSE/CLOSED per
relay with CLOSED split by NIP-01 machine-readable prefix, and records
time-to-first-open and time-to-first-EOSE. It dumps a rollup plus two tables at
20/45/90s: the relays that cost dials and returned nothing, and the relays
actually carrying the boot — the latter so a suppression change can be checked
for coverage loss instead of only counting CLOSED reduction.

Debug builds only, attached like the other loggers in AppModules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
e66550091d fix(relay): send an unresolvable host straight to the long backoff
A relay whose domain no longer exists (lapsed registration, decommissioned
host) was treated like a busy relay: the backoff doubled from 1s and spent
about ten dials climbing to the ceiling it was always going to reach. An HTTP
upgrade rejection already jumps straight there; a name that does not resolve
deserves the same.

Matching is on the exception type rather than the message because the message
is localized and platform-specific — Android says `Unable to resolve host "x"`,
JVM on macOS says `nodename nor servname provided, or not known` — while the
class name is stable. That is why onCannotConnect appends it in the first
place. Neither message ends with "Host unreachable", so the existing check
never caught DNS failures.

Being this eager is only safe because the verdict is cheap to revisit: a DNS
answer is a property of the network, not of the relay (a captive portal or a
filtering resolver forges NXDOMAIN), and both a network-identity change and a
transport change now clear the backoff outright. The test pins that round trip,
not just the classification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
b1b6190d2f fix(relay): forgive reconnect backoff when the network or transport changes
A relay's reconnect backoff was process-global and network-blind: the delay
earned on one network was still being served out on the next. The only reset
signal was OkHttpClient reference identity, which is rebuilt off the metered
bit, so it fired for wifi<->cellular and nothing else. Wifi A -> wifi B, a VPN
coming up, a captive portal clearing, and metered-wifi -> cellular all left
every relay parked on a penalty earned against a network the device had left —
up to five minutes of silence on a network that might reach the relay instantly.
The transient Off that would have reset things is swallowed by the 200ms
debounce in ConnectivityFlow, so it never rescued those cases.

Key the decision on ConnectivityStatus.Active.networkId instead, which is the
same signal SurgeDns already uses to stale its cache, and treat a genuine
network change as a full pool rebuild: every socket is bound to an interface
that no longer carries traffic, and needsToReconnect() cannot see that because
it only compares the proxy and the timeouts.

Also treat a Tor policy flip as a transport change. Flipping a Tor toggle while
Tor is already up leaves both OkHttpClient references identical, so a relay
whose transport just changed kept waiting out a backoff earned on the other
transport. Only TorRelaySettings is compared, not the relay sets that
TorRelayEvaluation also carries — those churn while an account's relay lists
load (observed firing three times in one cold start), and forgiving the whole
pool every time any list updates is far more damage than it repairs.

Adds IRelayClient.resetBackoff() (default no-op, so the existing fakes and
BleNostrClient are unaffected) rather than reusing ignoreRetryDelays, which
only skips the gate for a single attempt and still doubles the stored delay —
a relay that failed that one dial came back worse off than before.
INostrClient.resetBackoff() is deliberately separate from reconnect(): the
latter debounces, so folding this into a coalescing command would let a later
request silently drop the reset.

The decision table moves into RelayProxyClientConnector.apply() so it can be
exercised directly, without a debounce and a shared StateFlow in the way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
bff7592383 Merge pull request #3640 from vitorpamplona/claude/allsettingsscreen-reorder-9ov2tu
Reorder settings menu entries and update icons
2026-07-18 18:32:57 -04:00
Claude
a4713c89c2 docs(cli): add RECIPES.md — task-shaped walkthroughs for the big subsystems
Seven end-to-end recipes (own relay + NIP-86 admin, NIP-46 bunker both
directions, Marmot group chat, NIP-60/61 Cashu wallet, NIP-5A nsite
publishing, GrapeRank provider pipeline, scripting patterns) so the
docs teach jobs, not just verbs. Reflects the new contract: per-command
--help, alias resolution in user slots, published_to/rejected errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:13:23 +00:00
Claude
d8584a7935 feat: reorder App Settings entries and refine Home/Messages rows
Reorder the App Settings section in AllSettingsScreen to: Privacy Options,
UI Preferences, Home, Messages, Notifications, Compose Settings, Reaction
Row, Bottom Navigation Bar, Profile UI, then the remaining rows.

Rename the "Home Tabs" row to "Home" (reusing route_home) and switch the
Messages row icon to MaterialSymbols.Mail to match the Messages screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYck7FKoLzoaomnjjLFLzi
2026-07-18 21:12:04 +00:00
Claude
7ea6920679 refactor(nip65): move read/write-marker merge semantics from the CLI into quartz
The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:03:56 +00:00
Claude
80be7b909b test(cli): bare 'amy' exits 2; key generate rejects unknown flags
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:59:34 +00:00
Claude
55884f81eb refactor(marmot): extract the incoming-sync policy from the CLI into commons
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).

Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:45 +00:00
Claude
7b5d9a0fe4 feat(cli): contract sweep for notes/nsite/offer/podcast/relay/search/zap verbs
Completes the module-wide contract sweep (N-Z families):
- rejectUnknown() everywhere Args is constructed
- publishGuard on single-event publishes (post, publish, profile edit,
  nsite/napplet publish, podcast + podcast20, relaygroup join/leave/
  message/invite/edit/put-user/remove-user)
- USAGE constants + route(help=...) / --help fast-paths; nsite/napplet
  publish/serve/list, offer discover/pay and zap --with are now
  documented in-binary; subscribe's USAGE pins the NDJSON stream shape
- notes post parses flags before positionals (flags may appear anywhere)
- search note: --kind canonical (--kinds kept as alias); search user
  default limit 20 -> 50
- --identifier accepted as alias of --d in nsite/napplet/podcast20
- relay dispatch gains a full USAGE (--help) documenting every noun

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:19 +00:00
Claude
11901df2c6 feat(cli): contract sweep for event/git/geochat/graperank/group/key/login verbs
Same contract as the A-D sweep, applied to the E-M command families:
- rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args)
- publishGuard on single-event publishes (total rejection -> non-zero)
- USAGE constants + route(help=...) / --help fast-paths; graperank's
  full sub-verb set (including the previously undocumented 'followers')
  and 'key validate' + the --pw alias are now documented in-binary
- geochat --relay is a strict comma-list (bare positional relays kept);
  geochat listen default limit 500 -> 50
- graperank update/probe aliases now print deprecation notes
- git --identifier accepted as alias of --d
- marmot message list defaults to --limit 50 (0 = everything)
- dm-style guards for marmot message send/react/delete and group edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:23 +00:00
Claude
471883a9f2 feat(cli): contract sweep for admin/await/blossom/bunker/concord/dm/cashu verbs
Applies the new CLI contract across the A-D command families:
- rejectUnknown() after flag parsing, so typo'd flags fail with bad_args
  instead of silently no-oping
- publishGuard on single-event publishes: total relay rejection now
  exits non-zero ('rejected') instead of 0
- per-group USAGE constants wired into route(help=...) and --help
  fast-paths on flat commands; previously invisible sub-verbs
  (blossom media/report, the concord moderation set, the whole cashu
  surface, NIP-86 method list) are now documented in-binary
- dm send/send-file parse flags before positionals (flags may appear
  anywhere); dm list/await accept a positional USER as alternative to
  --peer; dm list caps at 50 by default
- concord create: --relay is canonical (--relays kept as alias)
- cashu receive resume: deprecation warning pointing at 'complete'
- offer/debit: --timeout is seconds (was raw milliseconds)
- Identity.fromBunkerUri now delegates to quartz NostrConnectURI
  parseBunker instead of duplicating the URI parsing

BREAKING (--json): unknown flags and malformed numeric/relay/author
flag values now exit 2 where they were previously ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:09 +00:00
Claude
6bcae39b83 fix(cli): repair the exit-code and flag-parsing contract
- Output.error now derives the exit code from the error code
  (bad_args -> 2, timeout -> 124, else 1), so every
  'return Output.error(...)' site honours the documented contract.
  Previously ~225 bad_args sites exited 1 while the docs promised 2,
  and two timeout paths (nostrconnect wait, namecoin lookup) exited 1
  instead of 124.
- Args: literal '--' ends flag parsing (escape hatch for values that
  start with '--'); intFlag/longFlag reject non-numeric values instead
  of silently using the default; requireFlag/positional no longer
  double-print to stderr; new rejectUnknown() turns typo'd flags into
  bad_args failures; new 'help' detection.
- route() understands --help/-h/help (prints group usage, exit 0) and
  names the expected verbs on an unknown sub-verb.
- Unknown or missing top-level subcommand now emits a proper bad_args
  error (JSON-aware under --json) plus a one-screen verb list instead
  of dumping the full 400-line usage.
- RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit
  entries that do not parse are now bad_args errors; previously an
  unresolvable --author was silently DROPPED and the query ran with a
  weaker filter than requested. New shared publishGuard() reports
  'rejected' (exit 1) when every relay refuses an event.
- runCli() seam extracted from main() plus an 'amy.home' system-property
  override of DEFAULT_ROOT so the new JVM test suite can drive the CLI
  in-process; first contract tests: ArgsTest, ExitCodeContractTest,
  JsonContractTest (NIP-19 vector goldens).

BREAKING (--json): error code pow_timeout is now timeout; exit codes
for bad-argument failures move from 1 to 2 as documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:54:14 +00:00
Vitor Pamplona
7e3c01ab08 Merge pull request #3637 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 16:18:38 -04:00
Claude
b852a163c7 feat: paginate the NIP-29 group Threads tab with a backward history pager
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:

- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
  the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
  until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
  ("relayGroup.threads.history"), bound to the open group's threadsHistory
  cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
  window on open, page older content as the list nears its end, and show a
  quiet loading/caught-up footer.

Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.

The screen wiring is device-untested (flagged with the other Tier-D items).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:54:18 +00:00
Claude
3214095b99 fix: route stray NIP-29 group content to the host, not a phantom channel
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed
to its RelayGroupChannel by the relay that served it, because a NIP-29
event doesn't carry its host relay. That's correct for the group's own
host-pinned subscriptions, but a message resolved from a NON-host relay --
e.g. a quoted kind-9 fetched by id during missing-event resolution -- was
filed under GroupId(groupId, strangerRelay), a channel the group's screens
never read, so the message silently vanished (the serving-relay hazard).

LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped
now, when no channel is keyed to the serving relay, redirect the stray to
the group's single confirmed host channel via redirectStrayRelayGroupContent,
keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never
has relay-signed state, so the redirect can only ever land on a real host,
never on another phantom -- the fix is strictly safe and the common
host-pinned arrival stays an untouched O(1) fast path (the scan runs only
on the rare no-channel-for-serving-relay miss).

Also add the cache-prune gap-fill can't-miss test: drive the production
RelayLoadingCursors down a real relay, rewindTo below the window, and
confirm the pruned band re-loads with no gap.

Tests: RelayGroupContentRoutingTest (pure router + the channel signal);
RelayGroupHistoryPagingRelayTest gains the rewind reload case. The
LocalCache wiring is unit-covered at the router level but still device-
untested end-to-end (flagged in the test plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:13:31 +00:00
vitorpamplona
c5f10c7518 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 18:06:39 +00:00
Claude
db5170a82e test: extend NIP-29 group-chat coverage to every assembler + relay integration
Round out the branch's test plan across the headless-runnable tiers.

Tier B (filter shapes) — now every assembler:
- reconnect stability: a since-only bump on the state/tail filters is not a
  resend (no full replay on reconnect), while a history until step is; via
  FiltersChanged.needsToResendRequest.
- directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders
  (kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay
  assembler at it, with a shape test.
- ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id
  backfill and crucially NO message window.
- group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set,
  empty-guards.
- discovery #p roster augmentation (filterRelayGroupsByAuthors): the author,
  #p-roster and #d-backfill filter shapes.

Tier C (serves-the-shape, against the in-process geode relay): state #d,
batched preview tail, threads, a pinned message reachable by id below the tail
window, notification #p+#h scoping, and the relay directory.

Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors
backward over the wire to the bottom, and pin that a short page is not
exhaustion (only an empty page + EOSE ends the walk).

The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are
already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest
and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are
not headless-runnable and stay flagged for a human in the test plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:54:38 +00:00
Claude
399bef6bf7 test: cover NIP-29 group-chat filter shapes and can't-miss history paging
Extract the pure REQ-filter construction out of the NIP-29 group-chat
assemblers into RelayGroupFilterBuilders so the exact filter each screen
puts on the wire can be unit-tested without an Account or relay client,
and point every assembler at the shared builders (dropping the duplicated
per-file kind lists).

Add two test suites from the branch's test plan:

- RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope,
  per-host-relay batching, since/until/limit and all-authors shape of the
  state, joined chat-tail, open chat-tail, history-pager, threads and
  card-warmup joined-skip filters.

- RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages
  property against the in-process geode relay -- a backward #h + kind-9
  walk delivers every group message exactly once and stops on an empty
  page, and an #h-scoped walk isolates one group from another on the same
  relay even when their createdAt ranges fully overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:13:56 +00:00
Claude
4cf86ca030 docs(nip29): add Tier E — relay-behavior resilience + third-party conformance
The friendly-geode tiers test the client against a compliant relay; they neither
harden it against buggy relays nor surface bugs in the third-party NIP-29 relays
users connect to. Tier E adds both:

- E1: a deterministic fault-injecting mock relay (ignore since/until, short pages,
  echo-newest, out-of-order, EOSE-early/none, AUTH-CLOSE, mid-stream drop, caps)
  asserting the tail/pager still converge with no miss / loop / hang. Notes that
  UntilLimitPagingRelayTest + BackwardRelayPagerTest already cover several cases
  for the generic pager, to be extended to the NIP-29 #h shapes.
- E2: a conformance run against a real containerized NIP-29 relay via relayBench's
  RelayUnderTest adapter (AUTH gating, relay-signed 39xxx, previous-tag rejection,
  since/until semantics + caps) — a failure is a relay bug, doubling as a
  conformance report for the operator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 16:04:07 +00:00
Claude
c8362fbc18 docs(nip29): per-screen × per-assembler test plan for the group-chat refactor
A validation plan an AI (or human) on this branch runs before trusting the
state-vs-content refactor. Organized by screen and covering every NIP-29
assembler (JoinedState, JoinedChatTail, OpenChatTail, OpenChatHistory,
OpenThreads, CardWarmup, RelayGroupsOnRelay, RelayGroupsDiscovery, the
ChannelPublic relay-group metadata branch, and the notifications path).

Tiers: (A) build + JVM suite; (B) new amethyst unit tests for each assembler's
filter shape + reconnect stability; (C) amy + geode/`amy serve` integration for
the framework, ingest and the can't-miss-messages paging property (C3); (D)
Android per-screen scenarios with logcat pass criteria, including the original
join-mid-session bug (D1) and reconnect no-replay (D8). Flags what is verifiable
headless vs. needs a device, and the known-failing serving-relay hazard row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 15:16:36 +00:00
Claude
ee5dc25414 refactor(nip29): finish the datasource rename — Open threads + consistent filter-builder names
Continues the scope+role naming pass over the group-chat datasource layer:

- RelayGroupThreadFeed -> RelayGroupOpenThreads: it's the open group's threads
  tab, so it now sits in the "Open" family beside RelayGroupOpenChatTail/History.
- filterMetadataToRelayGroup -> filterRelayGroupState: matches the "state"
  concept (39000-39005 + pinned-id back-fill) the always-on state sub is named for.
- makeRelayGroupsDiscoveryFilter -> filterRelayGroupsDiscovery: consistent
  `filter…` verb with the rest of the relay-group filter builders.

The RelayGroupsOnRelay / RelayGroupsDiscovery assemblers keep their PLURAL prefix
on purpose: it marks the "browse/discover many groups" surfaces apart from the
singular in-a-specific-group ones (RelayGroupJoined*/Open*/Card*). The already-
consistent filterRelayGroupsBy* discovery builders are left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 14:33:56 +00:00
Claude
54b2b42d87 refactor(nip29): scope Card warmup to non-joined, rename the group-chat assemblers by scope+role
Two clarity cleanups on the group-chat subscription split:

1. RelayGroupCardWarmup (ex-Warmup) now skips a group the user has already joined.
   Joined groups are kept fully warm app-wide by the always-on state + chat-tail
   subs; warmup only needs to cover groups shown as cards that those don't — above
   all NON-joined groups (discovery, a relay's channel list, member/metadata/parent
   screens). Removes the last joined-group double-fetch. (A joined group appearing
   in the discovery "My Groups" tab now draws its card activity from the recent-tail
   cache instead of a fixed newest-50 fetch.)

2. Rename the assemblers so their scope and role read at a glance, side by side:
     RelayGroupState      -> RelayGroupJoinedState     (joined · state, always-on)
     RelayGroupPreviewTail -> RelayGroupJoinedChatTail (joined · recent chat, always-on)
     RelayGroupChatTail   -> RelayGroupOpenChatTail    (open group · recent chat)
     RelayGroupChatHistory -> RelayGroupOpenChatHistory (open group · older chat)
     RelayGroupWarmup     -> RelayGroupCardWarmup      (on-screen card, non-joined)
   The family now reads Joined{State,ChatTail} · Open{ChatTail,ChatHistory} · CardWarmup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 13:57:44 +00:00
Claude
050ecaaf9f feat(nip29): split group chat into always-on state + live tail + history pager
Consolidates the six overlapping NIP-29 group-chat REQ assemblers into the
state-vs-content shape the DM and Concord chat stacks already use, reusing the
BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker framework. See
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.

State (always-on, account-keyed), mounted at LoggedInPage:
- RelayGroupStateFilterAssembler — one #d filter per host relay carrying every
  joined group's 39000/39001/39002/39003/39005. Keeps name/roster/roles/pins
  current app-wide, so no screen re-queries metadata. Promotes (and replaces) the
  old RelayGroupMyJoinedGroups roster path from "while a groups screen is up" to
  genuinely always-on.
- RelayGroupPreviewTailFilterAssembler — one #h filter per host relay across all
  joined group ids, since=recentBoundary(), NO per-group limit (a time floor
  batches it and makes it reconnect-safe). Drives the Messages-list previews.
  Replaces the old fixed-window content path whose shared per-relay `since` gated
  a newly-joined group's backfill (the reported slow-first-load bug).

Content (per open group), mounted on the chat screen:
- RelayGroupChatTailFilterAssembler — the open group's recent chat live (covers a
  non-joined group opened by link, which the joined-only preview tail misses).
- RelayGroupChatHistoryFilterAssembler — on-demand backward pager (until+limit on
  the host relay, all authors), cursors on RelayGroupChannel.history, driven by
  the feed's viewport markers with an eager backfill-to-window on open. Gap-proof
  via RelayLoadingCursors.rewindTo, so deep scroll never misses older messages —
  and being all-authors it re-materializes my own history too.

Retires the duplicated paths:
- RelayGroupMyJoinedGroups{FilterAssembler,Subscription} deleted (state + preview
  subs cover it); unmounted from Messages panes, discovery, bottom-bar preloader.
- ChannelPublicFilterSubAssembler RelayGroupChannel branch drops the content
  window, keeps only metadata + pinned-id backfill (for a non-joined open group).
- ChannelFromUserFilterSubAssembler RelayGroupChannel branch removed (redundant).
- FilterMessagesToRelayGroup / FilterMyMessagesToRelayGroup deleted (orphaned).

Warmup (non-joined cards), OnRelay (directory), Discovery, ThreadFeed and the
always-on notifications path are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 04:19:50 +00:00
Claude
af9d5a7278 docs(nip29): plan to split group chat into always-on state + paginated content
Design doc for consolidating the six overlapping NIP-29 group-chat REQ assemblers
into the same state-vs-content shape the DM and Concord chat stacks already use:
an always-on account subscription for the small replaceable state (metadata /
roster / roles / pins), and a live tail + on-demand backward history pager
(reusing BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker) for the
high-volume chat content. Includes the full message-delivery coverage proof
(can't-miss-messages), the file-by-file change list, and the additive-first
rollout order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 04:19:25 +00:00
Claude
7dcf2afa0f perf(chats): make ChannelNewMessageViewModel.init idempotent across recompositions
Every channel composer screen (NIP-28 public chat, NIP-29 relay group, NIP-53
live activity, ephemeral and geohash chats) calls `init(accountViewModel)`
directly from its composable body, so it ran on the main thread on every
recomposition of that body — re-allocating UserSuggestionState,
EmojiSuggestionState and a fresh ChatFileUploadState each time. Besides the
wasted main-thread allocations, blindly re-initializing `uploadState` would
reset an in-progress upload.

Guard the setup so it only (re)runs when the account actually changes, matching
the pattern the sibling ConcordNewMessageViewModel already uses. Repeated calls
with the same account are now cheap no-ops, so leaving the call in the composable
body stays correct while dropping the per-recomposition work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 01:25:52 +00:00
Claude
9d8e07fbee perf(nip29): prefetch a joined group's history with a per-group since
The Messages-list warmup (RelayGroupMyJoinedGroupsSubAssembler) is keyed by
account, so its `since` is one per-relay EOSE map shared across every joined
group on that relay — not per-group. Feeding that shared `since` into the
bounded per-group content backfill gated any group joined (or first surfaced)
after that relay's `since` advanced — or after a live message on a sibling group
pushed it forward: the group only ever fetched events newer than that timestamp,
so its history never landed in the Channel's (strong-ref) notes cache. The
Messages row showed the newest message while opening the group waited on a full
`limit=200` relay round-trip — "the last message is there but the group loads
slowly even though it should be cached."

Dropping `since` entirely would be just as wrong: the pool re-sends every REQ on
each reconnect (FiltersChanged treats a brand-new connection's empty prior state
as a change), and reconnects happen constantly, so a since-less filter would
replay the whole newest-50 page for every group on every reconnect.

Instead, gate the shared `since` per group on whether we already hold a full page
(>= LIMIT) of that group's preview content in the notes cache:
  - < LIMIT cached  -> cold / newly joined / thinly scattered: fetch the full
                       page with no `since`; once it lands it flips to incremental
                       on its own.
  - >= LIMIT cached -> already backfilled: apply the shared `since` so reconnects
                       fetch only the tail (the group already has its history, so
                       sharing the relay `since` only bounds incremental top-ups).
A group with genuinely fewer than LIMIT total events re-pulls its (sub-page,
cheap) content on reconnect — an acceptable cost for guaranteeing the backfill.

Roster stays on the shared per-relay `since`: those are a few small replaceable
events per group, so a reconnect just re-confirms them rather than replaying chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 01:25:28 +00:00
m
a1b7cad85c feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
When the author types a NIP-05 mention (full `m@testls.bit` form or bare
`testls.bit` domain), the existing user-suggestion popover already resolves it
asynchronously on Dispatchers.IO via Nip05Client (Namecoin for .bit, regular
.well-known/nostr.json for everything else). On pick, however, the inserted
token was `@npub1…`, losing the relay hints already harvested during
resolution.

Insert `nostr:${user.toNProfile()} ` for NIP-05-shaped picks so:

  * the send-time NewMessageTagger parses the bech32 inline through its
    existing `nprofile1` branch — no extra main-thread I/O, no per-word
    network round-trips that could compound into 30s+ stalls on dead servers;
  * relay hints harvested by nip05ResolutionFlow ride along in the nprofile;
  * the email-vs-mention call is the author's: typing `vitor@vitorpamplona.com`
    surfaces the suggestion but only commits as a mention when the author taps
    the row. Just typing the address sends as plain text.

Non-NIP-05 picks (search by name, typed npub/nprofile, hex pubkey) keep the
existing `@npub1…` insertion form.

Also collapses the open-coded `Nip05Id("_", prefix.lowercase())`
bare-domain synthesis onto Nip05Id.parseLenient so there's one place doing
that conversion.

Addresses review feedback on #3165: avoids the extra parser pass and the
inline send-time resolve loop in NewMessageTagger by routing all NIP-05
mentions through the popover the author already uses.
2026-07-11 20:35:16 +10:00
m
6ac8e475df feat(nip05): add Nip05Id.parseLenient for mention/text rendering
Add a small companion helper that lets callers parse either:
  - a full NIP-05 identifier (`name@domain.tld`), same as Nip05Id.parse; or
  - a bare domain (`domain.tld`), synthesized as the wildcard form
    `_@domain.tld` per NIP-05.

This is the building block used by upcoming mention parsing in note
compose (NewMessageTagger) and note rendering (RichTextParser) so that
typing `@m@testls.bit` or `@testls.bit` in a note tags the user
correctly, and a received note that contains `m@testls.bit` becomes a
clickable user link instead of falling into the email/mailto bucket.

The helper is intentionally split out from the strict `parse`
(unchanged) so its lenient behaviour (bare-domain synthesis) cannot
sneak into NIP-05 verification flows that need the strict form.
2026-07-11 20:35:16 +10:00
402 changed files with 25425 additions and 4067 deletions

View File

@@ -567,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,201 @@
# NIP-29 group-chat subscriptions: split *state* (always-on) from *content* (paginated)
**Status:** proposed · **Date:** 2026-07-18 · **Module:** `amethyst` (+ `commons` model, reuses `commons`/`quartz` paging)
## Problem
NIP-29 relay-group ("RelayGroup") chat is served today by **six** overlapping
REQ assemblers, each keyed differently and each re-deriving the same two queries:
| Query shape | Emitted by (today) |
|---|---|
| Metadata `#d` (3900039005 + pins) | Warmup, ChannelPublic (open), MyJoinedGroups (roster subset), OnRelay (directory) |
| Content `#h` (kind 9 + poll) | MyJoinedGroups (limit 50), Warmup (limit 50), ChannelPublic-open (limit 200) |
| My-own `#h` (`authors=[me]`) | ChannelFromUser — **redundant for groups** (the all-authors `#h` window already returns my messages; a group is pinned to one host relay) |
| Threads `#h` (11 + 1111) | Warmup, ThreadFeed |
Two concrete defects fall out of this shape:
1. **Slow / partial first load** (the reported bug). Content is fetched in **fixed
windows** (limit 50 / 200) gated by a *shared per-relay* `since`
(`RelayGroupMyJoinedGroupsSubAssembler` is keyed by `Account`, so its `since`
collapses to one map per relay, not per group). A group joined or surfaced
after that relay's `since` advanced never backfills; opening it waits a full
relay round-trip.
2. **We can miss messages.** A fixed `limit=200` window has no way to reach older
history, and no demand-driven paging: scroll up past 200 and there is nothing
behind it. There is also a **serving-relay keying hazard** (see below) where a
referenced group message lands in a channel the UI never reads.
Every *other* chat surface in the app already solved this with a **two-subscription
model** — an always-on live tail + an on-demand backward history pager — and there
is a reusable framework for it. Group chat is the outlier that never adopted it.
## Goal
Split group chat into the same shape every other chat uses, and delete the
duplication:
- **State** (metadata / roster / roles / pins) — small replaceable events →
**one always-on account subscription**, gated on the NIP-29 settings toggle.
The cache is always current; no per-screen metadata re-fetch.
- **Content** (kind 9 chat + polls) — high volume → **live tail + backward history
pager**, exactly like NIP-04 DMs and Concord channels. Gap-proof (`RelayLoadingCursors`
handles cache-prune rewind), demand-driven by the visible feed, reconnect-safe.
No message path that delivers a group message today may be dropped.
## Reused framework (do not reimplement)
Mapped end-to-end from the NIP-04 DM stack and the **Concord channel** stack, which
is the closest existing template (a public group channel already paged this way):
| Piece | Location | Role |
|---|---|---|
| `BackwardRelayPager(name, pageLimit, liveTailSeconds)` | `commons/.../relayClient/paging/` | single-active per-relay backward orchestrator |
| `RelayLoadingCursors` | `quartz/.../relay/client/paging/` | per-relay `until`/`reached`/`done` cursors + `rewindTo` (prune realign) — **one instance per group scope** |
| `WindowLoadTracker` + `trackingListener` | `commons/.../relayClient/paging/` | live-tail "all relays settled" indicator |
| `PagingStatus`, `RelayPagingProgress` | paging pkg / quartz | atomic display snapshot |
| `RelayReachCursor` / `RelayReachSentinels` / `RelayReachMarkers` | `commons/.../ui/feeds/RelayReachMarker.kt` | viewport-driven "load older" markers |
| `DmHistoryLoadingCard`, `RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` | `amethyst/.../chats/feed/ChatFeedView.kt` | shared feed hooks |
| `DmHistoryTuning.recentBoundary()` | `commons/.../model/privateChats/` | shared live-tail floor (7 days) |
**Direct templates to copy:**
`ConcordChannelHistorySubAssembler` + `ConcordChannelHistoryFilterAssembler` +
`ConcordChannelHistorySubscription` + `ConcordChannelScreen`'s
`ConcordBackfillHistoryToWindow`; and `ChatroomNip04SubAssembler` (live tail) /
`ConcordChannelFilterAssembler` (batched always-on live).
## Target architecture
Four concerns, mirroring the DM stack (rooms-list tail + per-conversation tail +
per-conversation history) plus a groups-only always-on state sub.
1. **`RelayGroupStateSubAssembler`** — *always-on*, account-keyed.
Roster `#d` (39000/39001/39002/39003/39005) batched one filter per host relay
across the joined set. Keeps `since` (tiny replaceable events; reconnect just
re-confirms). Mounted at `LoggedInPage` (like `AccountFilterAssemblerSubscription`),
gated on `ChatFeedType.NIP29`. **This is today's `RelayGroupMyJoinedGroups` roster
path, promoted to always-on and stripped of content.**
2. **`RelayGroupPreviewTailSubAssembler`** — *always-on*, account-keyed, batched.
Content `#h` (kind 9 + poll) across **all** `liveRelayGroupList` group ids,
`since = recentBoundary()`, **no per-group limit** (a time floor bounds it, so it
batches into one filter per relay). `WindowLoadTracker`. Drives Messages-list
previews and keeps joined groups' recent chat live app-wide. **Replaces
`RelayGroupMyJoinedGroups` content path (A).** Batching + time-floor `since`
eliminates both the per-group-`since` bug and the reconnect re-download.
3. **`RelayGroupChatTailSubAssembler`** — per-open-`GroupId`, live tail for the
*currently open* group: content `#h` (9 + poll), `since = recentBoundary()`, host
relay. Covers recent + live updates for **any** open group, **including non-joined**
groups opened by link (which the batched preview tail — joined-only — doesn't cover).
Mirrors the DM per-conversation live tail.
4. **`RelayGroupChatHistorySubAssembler`** — per-open-`GroupId`, `BackwardRelayPager`
(`liveTailSeconds` = 7d floor; the tails cover above it), cursors on
`RelayGroupChannel.history`, content `#h` (9 + poll, **all authors**) `until`+`limit`
on the host relay. Demand-driven by the feed markers; eager `advanceAll()` backfill
to a window target on open. **Replaces ChannelPublic-open content (C) and
ChannelFromUser (D).** All-authors, so it re-materializes my own history too.
`ChannelFeedFilter` is unchanged — it reads `channel.notes`, so every path that fills
the cache surfaces. (It has **no `limit()`**, so it already renders whatever is cached.)
## Message-coverage proof (can't-miss-messages checklist)
Every current content-delivery path and what covers it after:
| Path (today) | Kinds / scope | After |
|---|---|---|
| **A** MyJoined content (50) | 9,poll `#h` joined | **Preview tail (batched `#h`, since=window)** for previews + **chat tail** when open |
| **B** Warmup content (50) | 9,poll,11,1111 `#h` card | **KEEP** — non-joined cards/discovery aren't in the joined tail (screen-dependent, per design) |
| **C** ChannelPublic-open content (200) | 9,poll `#h` open | **Chat tail (recent) + history pager (older, gap-proof)** |
| **D** ChannelFromUser (`authors=me`) | 9,poll `#h` me | **History pager (all-authors) + tail + optimistic-send attach + host echo** → redundant |
| **E** ThreadFeed | 11,1111 `#h` | **KEEP** (Threads screen; separate `threadNotes` feed). Pager adoption is a follow-up. |
| **F** Notifications | 7,9,1111,1068,… `#h`+`#p=me` | **KEEP** — always-on, p-tags-me; unchanged bonus |
| §3 by-id (`filterMissingEvents`) | ids | **KEEP** — quotes/replies/mentions; **+ serving-relay fix below** |
| §3 pinned by-id backfill | ids `filterMetadataToRelayGroup` | **KEEP** (host relay; older-than-window pins) |
| §3 replies/reactions `#e/#q` | 1111 etc. | **KEEP** — comments never attach to timeline (by design) |
**Serving-relay keying hazard (real, pre-existing — fix as part of "can't miss").**
`attachToRelayGroupIfScoped` keys the channel by `GroupId(groupId, servingRelay)`.
All subscriptions here are host-pinned, so they're safe. But `filterMissingEvents`
can deliver a referenced group message from a **non-host** relay, filing it under a
different channel object than the host-keyed one the UI reads → cached but invisible.
Fix: when attaching a group-scoped content event, if exactly one existing
`RelayGroupChannel` carries that `groupId` (the joined/host one), attach there
instead of minting a `(groupId, servingRelay)` channel — reusing the existing
`singleOrNull`-by-groupId resolution already used for the `relay == null` optimistic
branch. Ambiguous ids (the relay-wide `_` group joined on several relays) keep
serving-relay keying.
## File-by-file changes
**New (`commons` model):**
- `RelayGroupChannel`: add `val history = RelayLoadingCursors()` (mirror `ConcordChannel.history`).
**New (`amethyst` datasource, per templates):**
- `RelayGroupStateFilterAssembler` (+ SubAssembler) — always-on roster.
- `RelayGroupPreviewTailFilterAssembler` (+ SubAssembler) — batched preview tail.
- `RelayGroupChatTailFilterAssembler` (+ SubAssembler) — per-open live tail.
- `RelayGroupChatHistoryFilterAssembler` (+ SubAssembler) — per-open history pager.
- Subscription composables for each (`*Subscription`), copying the Concord ones.
**Modified:**
- `RelaySubscriptionsCoordinator`: register the four new assemblers; drop the retired ones (see below).
- `LoggedInPage`: mount `RelayGroupStateSubscription` + `RelayGroupPreviewTailSubscription` (always-on, gated).
- `RelayGroupChannelView`: mount chat-tail + history subscriptions; wire
`RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` + a
`BackfillHistoryToWindow` (copy `ConcordBackfillHistoryToWindow`).
- `MessagesSinglePane`/`MessagesTwoPane`: drop `RelayGroupMyJoinedGroupsSubscription`
(its roster role moves to the always-on state sub; previews come from the tail).
- `LocalCache.attachToRelayGroupIfScoped`: host-relay normalization (serving-relay fix).
- `AccountViewModel.dataSources()`: expose the four new assemblers; remove retired handles.
**Retired:**
- `RelayGroupMyJoinedGroupsFilterAssembler` **content path** → deleted; the file's
roster role becomes `RelayGroupStateFilterAssembler` (rename/replace).
- `ChannelPublicFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMessagesToRelayGroup`
+ `filterMetadataToRelayGroup`) → removed; metadata now always-on, content now tail+pager.
(Keep `filterMetadataToRelayGroup`'s **pinned-id backfill** — re-home it on the chat-tail or a
small pin sub so older-than-window pins still resolve.)
- `ChannelFromUserFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMyMessagesToRelayGroup`) → removed.
- Keep `RelayGroupWarmup*` (non-joined cards), `RelayGroupsOnRelay*` (directory),
`RelayGroupsDiscovery*` (discover feed), `RelayGroupThreadFeed*` (threads), and the
notifications path unchanged.
## Rollout order (additive first, retire last — never a window where messages drop)
1. **Additive, no removals:** add `RelayGroupChannel.history`; add the four new
assemblers + subscriptions + coordinator/dataSources handles + `LoggedInPage`
and `RelayGroupChannelView` wiring. New content now flows through tail+pager
**alongside** the old A/C/D (harmless dedup by id). Compile + smoke.
2. **Serving-relay normalization** in `LocalCache` (independent correctness fix).
3. **Retire** A-content, C-relay-group-branch, D-relay-group-branch; move roster to
the always-on state sub; drop the Messages-pane `MyJoinedGroups` mount. Compile.
4. Re-home the pinned-id backfill; delete now-dead code; `spotlessApply`; full suite.
## Edge cases
- **Quiet group** (newest message older than the 7-day tail): won't appear in the
preview tail; its Messages row falls back to cached / placeholder (same as NIP-04).
Opening it → the history pager's eager backfill loads it. Optional: a one-shot
newest-1 per quiet joined group in the state sub's initial snapshot.
- **Non-joined open group:** covered by the per-open chat tail + history pager
(both per-`GroupId`, no joined-list dependency).
- **Reconnect:** tails carry `since=recentBoundary()` (time floor, shared-safe,
incremental); history is `until`-based (position, not reconnect-sensitive);
`FiltersChanged` already ignores `since`-only changes → no full replay.
- **Threads:** unchanged this pass; a follow-up can point `RelayGroupThreadFeed` at
a second `BackwardRelayPager` on `RelayGroupChannel` for kind 11/1111.
## Testing
- Unit: preview-tail filter batches one `#h` filter per relay with
`since=recentBoundary()` and no per-group limit; history filter emits only for
armed relays at their `requestedUntil`; state filter emits roster `#d` per relay.
- Cursor behavior is already covered by `RelayLoadingCursors` tests (reused).
- Manual (amy / device): join a group after session start → open → history backfills;
scroll up past the window → older pages load; reconnect → no full re-download;
quote a group message from a non-host relay → it appears in the group.

View File

@@ -0,0 +1,177 @@
# NIP-29 group-chat loading — test plan (per screen × per assembler)
**For:** an AI validating branch `claude/nip29-group-load-perf-wz4yca` before trusting the
state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`).
**Question this answers:** *does the correct data load on every screen, and can we ever miss a message?*
**Implemented on this branch (all headless-runnable tiers):**
- **Tier B — filter shapes, every assembler.**
- Assemblers 16 + card-warmup joined-skip + reconnect stability (`needsToResendRequest`) + directory: `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt` (tests the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to).
- #9 ChannelPublic relay-group branch (state+pins, no message window): `.../publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt`.
- #10 group notifications (`#p`+`#h`): `.../service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt`.
- #8 discovery `#p` roster augmentation: `.../relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt`.
- **Tier C — serves-the-shape, against the in-process `geode` relay** (`quartz/src/jvmAndroidTest/.../nip29RelayGroups/RelayGroupFilterServingRelayTest.kt`): C1 state `#d`, C2 batched preview tail, C5 threads, C6 pinned-body-below-window by id, C7 notification `#p`+`#h`, C8 directory.
- **Tier C3 / E1 — can't-miss + resilience** (`quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`): backward `#h` walk covers every message once + stops on empty page; same-relay group isolation with overlapping `createdAt`; the **production `RelayLoadingCursors`** driven to the bottom over the wire; short-page-≠-exhaustion.
**Deliberately not duplicated (already covered generically at the unit level):** echo-newest→done and rewind/prune are in `RelayLoadingCursorsTest`; no-EOSE watchdog in `WindowLoadTrackerIdleTest`; auth-CLOSED/stall/cannot-connect in `BackwardRelayPagerTest`. E1's remaining hostile-relay faults (ignore-`since`, out-of-order, AUTH-CLOSE, silence) are those same state-machine paths — re-asserting them under NIP-29 naming adds no coverage since the cursor/pager never sees the `#h` filter, only `onEvent(createdAt)`/`onEose`.
**Not headless-runnable in this environment (flagged for a human):**
- **Tier D** (Android emulator/device, per screen) — needs a device; each row must be run and any unrun row flagged.
- **Tier E2** (conformance against a *real* third-party NIP-29 relay in a container) — non-deterministic + needs a container image; geode/strfry are generic and can't surface a real NIP-29 relay's bugs.
## What is / isn't verifiable headless
| Layer | Harness | Covers |
|---|---|---|
| Filter **shapes** each assembler builds | amethyst JVM unit tests (new) | the REQ is correct for the screen's job |
| Relay **serves** those filters; **ingest** into `LocalCache``RelayGroupChannel.notes`; paging framework | **`amy` + `geode`/`amy serve`** (drives the same quartz+commons client) | the reused machinery + filter shapes work against a real relay |
| **Screen loading** (mount → subscribe → feed renders); UI marker/sentinel→`advance`; always-on mounting; backfill-to-window loop | **Android emulator/device** | the amethyst wiring end-to-end |
The amethyst *assemblers* are Android-module, so `amy` cannot invoke them directly — it validates the
**framework + filter shapes + ingest** they depend on. The **screen** rows below therefore have a
headless part (unit + amy) and a device part; do both, and mark any device row you couldn't run.
## The assemblers under test (all of them)
| # | Assembler | Mounts on | Must load |
|---|---|---|---|
| 1 | `RelayGroupJoinedState` (always-on) | LoggedInPage → every screen | joined groups' 39000/1/2/3/5 → name, roster, roles, pins, my membership |
| 2 | `RelayGroupJoinedChatTail` (always-on) | LoggedInPage → Messages | joined groups' recent chat (`#h` since=window) → true newest-message previews |
| 3 | `RelayGroupOpenChatTail` | open group chat screen | the open group's recent chat + live (incl. **non-joined**) |
| 4 | `RelayGroupOpenChatHistory` | open group chat screen | older chat on demand (`#h` until+limit), gap-proof |
| 5 | `RelayGroupOpenThreads` | Threads tab | kind-11/1111 threads |
| 6 | `RelayGroupCardWarmup` | discovery cards, relay channel-list, members/metadata/parent screens | a **non-joined** card's metadata + preview; **skips joined** groups |
| 7 | `RelayGroupsOnRelay` | relay channel-list, subgroups bar, parent picker | a host relay's whole group directory |
| 8 | `RelayGroupsDiscovery` | Discovery screen | cross-relay discovery feed (by follows / global) |
| 9 | `ChannelPublicFilter` (relay-group branch) | open group chat screen | open group metadata + **pinned-id backfill** (incl. non-joined; pins older than window) |
| 10 | `filterGroupNotificationsToPubkey` (always-on notifications) | account-level | group content that **p-tags me**, even if I never opened the group |
## Harness setup (headless)
```bash
./gradlew :cli:installDist # build amy
RELAY=ws://127.0.0.1:7447
amy serve --port 7447 & # embedded relay (geode); or run :geode directly
# Identities: one "relay/operator" key (signs 39xxx), a few member keys, and "me".
# Seed a group G on the relay:
amy relaygroup create --relay $RELAY --gid G --name "Test" ... # 39000/39001/39002
# Seed chat spanning the live-tail boundary (7d): messages older AND newer than now-7d.
for t in <timestamps old→new>; do amy publish --relay $RELAY --kind 9 --tag h=G --created-at $t "msg $t"; done
# Seed: kind-11 thread + kind-1111 reply (h=G); a pinned kind-9 older than 7d + 39005 pin list;
# one kind-9 that p-tags "me"; a SECOND group G2 on the same relay (batching); a group on a
# second relay R2 (multi-relay); a group with <LIMIT total messages (small-group path).
```
Discover exact flags with `amy <verb> --help` (`fetch`/`subscribe`/`publish`/`relaygroup`).
`amy fetch --json` gives machine-checkable output for assertions.
---
## Tier A — baseline (must stay green)
```bash
./gradlew :amethyst:compilePlayDebugKotlin
./gradlew :quartz:jvmTest :commons:jvmTest :amethyst:testPlayDebugUnitTest :cli:test
./gradlew spotlessCheck
```
## Tier B — new amethyst unit tests (filter shapes per assembler)
Construct a minimal `Account` with `relayGroupList.liveRelayGroupList` = {G@R, G2@R} (+ a mock
`INostrClient`). If wiring a full `Account` is too heavy, **first refactor the filter construction out
of each `updateFilter` into a pure function** (`buildJoinedChatTailFilters(joinedTags, since)`,
`buildOpenChatHistoryFilters(groupId, armed, until, limit)`, …) and test those — this is itself a
worthwhile testability change. Assert, per assembler:
- **1 State:** one `#d` filter per host relay; kinds = 39000/39001/39002/39003/39005; `d` = all joined ids on that relay; `since` = shared per-relay EOSE. Disabled when NIP-29 toggle off / joined empty.
- **2 JoinedChatTail:** one `#h` filter per host relay; kinds = [9,poll]; `h` = all joined ids on that relay; `since = recentBoundary()`; **no per-group `limit`**. Two groups on one relay ⇒ **one** filter.
- **3 OpenChatTail:** one `#h` filter, host relay, kinds [9,poll], `since = recentBoundary()`, the single open group id.
- **4 OpenChatHistory:** with no relay armed ⇒ empty; after `advance(relay)` ⇒ one `#h` filter at `requestedUntilFor(relay)`, `limit = pageLimit`, **all authors** (no `authors`).
- **5 OpenThreads:** `#h`, kinds [11,1111], host relay.
- **6 CardWarmup:** a **joined** group ⇒ `emptyList()`; a **non-joined** group ⇒ metadata (unless contentOnly) + `#h` content (9,poll,11,1111) `limit`.
- **7 OnRelay:** directory `#`-less filter, kinds 39000-39003, `limit 500`, that relay.
- **8 Discovery:** by-follows + host-relay `#p` roster augmentation (see `RelayGroupsDiscoverySubAssembler`); global variant.
- **9 ChannelPublic relay-group branch:** returns **only** `filterRelayGroupState` (metadata + pin ids) — **no** message-window filter.
- **Reconnect stability:** re-run each `updateFilter` after a simulated EOSE; assert `FiltersChanged.needsToResendRequest(old,new)` is **false** for the tails/state (a `since`-only bump) — i.e. no full replay.
## Tier C — `amy` + relay integration (framework, ingest, can't-miss)
Issue the **exact filter shapes** from Tier B against the seeded relay and assert results:
- **C1 State load:** `amy fetch --kind 39000,39001,39002,39003,39005 --tag d=G --json` returns the seeded state. (screen-1)
- **C2 Preview/tail:** `amy fetch --kind 9 --tag h=G --since <now-7d> --json` returns only in-window messages; the newest equals the true newest. Batched: `--tag h=G --tag h=G2` returns both groups' recent in one query. (screens 2,3)
- **C3 History paging (CAN'T-MISS — the crown jewel):** seed **N=120** messages (older than 7d, spread over months). Starting `until=now`, repeatedly `amy fetch --kind 9 --tag h=G --until <cursor> --limit 50 --json`, setting the next `until = oldest.created_at - 1`, until an empty page. Assert the **union of all pages = all 120 ids, no gaps, no infinite loop** (mirror `RelayLoadingCursors.advance/onEose`). Then confirm a relay that returns the same newest events on a repeat page terminates (the `onEose` "not strictly older ⇒ done" guard). (screen-4)
- **C4 Ingest:** drive `amy subscribe`/`fetch` so events flow through the real client, then assert they land in a `RelayGroupChannel` keyed by `GroupId(G, R)` and surface via the `ChannelFeedFilter` predicate (kind 9/poll in, 1111 out). (screens 2,3)
- **C5 Threads:** `--kind 11,1111 --tag h=G` returns thread + reply; confirm 1111 attaches to threads, not the chat timeline. (screen-5)
- **C6 Pins:** a pinned kind-9 older than the window is **not** returned by C2 but **is** by `amy fetch --ids <pinnedId>` — proving the pinned-id backfill path still reaches it. (screen-9)
- **C7 Notifications:** `--kind 9 --tag h=G --tag p=<me>` returns the me-tagged message. (screen-10)
- **C8 Directory / discovery:** `--kind 39000 --limit 500` on R lists G+G2; a `#p=<follow>` roster query on the host relay surfaces a follow's group (the discovery augmentation). (screens 7,8)
- **C9 Multi-relay + reconnect:** repeat C2 against R and R2; drop and re-issue the subscription and confirm (via `--json` counts / relay logs) that a `since`-carrying re-REQ returns only the tail, not a full replay.
## Tier D — Android app, per screen (emulator/device; flag if unrunnable)
Boot `:amethyst:installDebug` against the seeded relay (point the account's relay list at `$RELAY`).
Watch logcat: `adb logcat | grep -E "DMPagination|relayGroup"`.
For **each screen**, the pass criteria:
- **D1 Messages list (1,2):** cold start with app already having joined G → the G row shows its **true newest** message (not a stale/scattered one), and its name/avatar (state). Join **G2 mid-session** (don't restart) → within seconds G2 appears with a real preview — *this is the original bug; it must now pass.*
- **D2 Open joined group (3,4,9 + backfill):** tap G → lands on a populated first screen (~50, the backfill-to-window), name/pins present. **Scroll up** past the window → older pages load, the reach marker advances, the "loading older" card shows then flips to "all caught up" at the bottom. No duplicate rows.
- **D3 Open non-joined group by link (3,4,9):** open a `naddr`/link to a group you have **not** joined → recent chat + live updates load (OpenChatTail) and scroll-up pages (OpenChatHistory), even though it's absent from the joined tail.
- **D4 Threads tab (5):** open Threads → kind-11 threads list; open one → its 1111 replies.
- **D5 Discovery (8,6):** open Discovery → groups list; a card fills name+activity (CardWarmup for non-joined). A **joined** group shown in "My Groups" still renders (from cache) though CardWarmup emits nothing for it.
- **D6 Relay channel-list / browse (7,6):** browse a relay → its directory lists groups; tapping one warms + opens.
- **D7 Members / Metadata screens (6/1):** roster + roles render.
- **D8 Reconnect (2,3,4):** toggle airplane mode on the open group and Messages → on reconnect, logcat shows incremental `since`/`until` REQs, **not** a full page replay; no missing or duplicated messages.
- **D9 Notifications (10):** with G *not* open, have another key post a message p-tagging me → it appears in notifications / unread.
- **D10 Quiet group:** a group whose newest message is older than 7d → Messages row falls back to cached/placeholder (documented limitation), and opening it backfills via the pager.
## Tier E — relay-behavior resilience & third-party conformance
Tiers C/D run against geode/`amy serve` — a **compliant relay we control**. Real NIP-29 groups live on
relays managed by other people, which have bugs and quirks. Two distinct concerns:
### E1 — client resilience to a MISBEHAVING relay (deterministic, mock)
Build a scriptable WebSocket relay (reuse the quartz relay-server + `RelayClientTestFakes`) that injects
one fault per run; assert the tail/pager still **converge** — all messages ingested, no infinite
`advance`, no hang, correct terminal state (`done` vs `stalled`), no duplicate rows:
- ignores `since` (returns everything) → tail must **dedup**, not duplicate.
- ignores / partial `until` → pager makes progress or marks done, **never loops**.
- **short page** (returns < `limit` though more exist) NOT exhaustion (only an *empty* page ends a relay).
- **echoes the same newest events every page** `RelayLoadingCursors.onEose` "not strictly older done" fires.
- out-of-order / duplicate events cursor takes `min(createdAt)`; dedup by id.
- EOSE before any event, or **no EOSE at all** `WindowLoadTracker` idle/abs-cap; pager silence watchdog `stalled`.
- **AUTH-required CLOSED("auth-required")** relay `stalled`-but-kept; re-`advance` retries; **not silently dropped**.
- mid-stream socket drop resubscribe with `since`/`until`, **no full replay, no gap** (`rewindTo` on prune).
- relay result cap below `limit` treated like a short page.
`UntilLimitPagingRelayTest` + `BackwardRelayPagerTest` already cover the empty-page / until-limit-walk /
echo-newest cases for the **generic** pager. E1 is to (a) re-run them against the NIP-29 `#h` filter
shapes and (b) add the not-yet-covered faults (ignore-since, out-of-order, AUTH-CLOSE, silence, reorder).
### E2 — conformance against REAL NIP-29 relay implementations (surfaces THEIR bugs)
geode / strfry / nostr-rs-relay are **generic** (store+serve by tag; no NIP-29 semantics), so they can't
surface a real NIP-29 relay's bugs. Point the harness (reuse relayBench's `RelayUnderTest` adapter) at an
actual NIP-29 relay (e.g. relay29, chorus, a self-hosted groups relay) in a **container**, seed a group
via `amy relaygroup`, and run C1C9 + E1's corpus. A failure is a **relay** bug or a client/relay
mismatch a NIP-29 conformance report the operator can act on. Cover the behaviors only a real NIP-29
relay has:
- **AUTH gating** on closed/private groups (39002 membership): does the client AUTH and then receive the `#h` timeline?
- **relay-signed 39xxx** (the relay's own key) the `isRelaySignedGroupEvent` gate.
- **`previous`-tag fork rejection** on send (a relay rejecting an event whose `previous` refs it doesn't recognise).
- the relay's actual **`since`/`until` inclusivity** and **result caps / rate limits** on the `#h` timeline.
Public relays are non-deterministic (live data): use a containerized instance for CI, a public one only
for exploratory runs. **E1 hardens our client against buggy relays; E2 tells us which third-party relay
is buggy** both are needed before trusting "loads correctly" in the wild.
## Cross-cutting invariants (assert throughout)
- **No missed messages:** the union of tail + history + pins + notifications = the full timeline; C3 is the decisive test. Also exercise `RelayLoadingCursors.rewindTo` trim the cache below the window, page again, confirm the pruned band re-loads.
- **No double-download on reconnect** (C9/D8).
- **Retirement left no gap:** with `RelayGroupMyJoinedGroups` deleted and `ChannelPublic`/`ChannelFromUser` relay-group content removed, D1/D2/D3 still load proving the tail+pager replaced them.
- **CardWarmup joined-skip** (Tier B #6 / D5): a joined card issues no warmup REQ.
- **Serving-relay hazard (FIXED):** a group message fetched from a **non-host** relay (e.g. `filterMissingEvents` quote resolution) used to be filed under `GroupId(G, otherR)` and lost. `LocalCache.attachToRelayGroupIfScoped`/`attachThreadToRelayGroupIfScoped` now redirect a stray (no channel for the serving relay) to the group's single confirmed **host** channel via `redirectStrayRelayGroupContent`, keyed off `RelayGroupChannel.hasRelaySignedState()` (a phantom never has relay-signed state, so the redirect only ever lands on a real host strictly safe, the common host-pinned path is an untouched O(1) fast path). Covered by `RelayGroupContentRoutingTest`. **Device-untested:** the pure router + channel signal are unit-tested; the LocalCache wiring is a guarded fast-path/slow-path swap that still needs a device pass (Tier D) to confirm end-to-end.
## Exit criteria
- Tier A green; Tier B all assertions pass; Tier C1C9 pass (esp. **C3**).
- Tier D1D9 pass on device, or each unrun row is explicitly flagged for a human.
- Known-failing by design until follow-ups: the serving-relay hazard row and (if unadopted) a threads pager.

View File

@@ -0,0 +1,190 @@
# v1.13.0 release QA — coverage, open findings, and recipes
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
This document records what that session **could not** capture in commit messages: what was actually
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
Everything below is that one configuration unless stated.
---
## 1. Coverage
### Exercised on device
| Area | Notes |
|---|---|
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
### Fixed but **only unit-verified** — never run on a device
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
podcast duplicate description · Concord leave affordance · the three revocation wirings.
### Never opened at all
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
pinned messages were never exercised.
### Platform gaps
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
An emulator makes this cheap and it is the highest-value remaining gap.
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
`release` (full R8) has never been built or run. **arm64 only.**
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
---
## 2. Open findings (known, deliberately not fixed)
**Release mechanics**
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
ships blind.
**Correctness / UX**
- Tor settings do not take effect until app restart, with no indication.
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
- `amy login bunker://…` hangs and never delivers a `connect`.
**Security / protocol**
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
revokes nothing.
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
keys; only the community list was fixed.
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
spec conversation, not a unilateral fix.
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
the rank-aware `canActOnPosition`.
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must
hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin
cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
**FIXED and shipping**`AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
**Known consequence: Armada has not shipped this, so banlists can differ between clients**
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
that does not chain onto it.
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
---
## 3. Setup recipes
**`amy` with an isolated identity** (never touch the maintainer's real one):
```
AMY=$(pwd)/cli/build/install/amy/bin/amy
H=/tmp/qa-home; mkdir -p $H
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
```
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
`--secret-backend plaintext` for throwaway identities.
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
`put-user … --role admin`.
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
and after the user action — that turns "I didn't see traffic" into an actual measurement.
---
## 4. Patterns worth acting on
These recurred often enough to be process problems rather than individual bugs.
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
while being unreachable to users, and the first one actually invoked turned out to be **broken as
written**. A lint for "public capability with no caller outside its declaring file" would catch the
whole class cheaply.
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
already bounds these queries, which is what makes dropping the time floor safe.
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
with others`), not membership modelling. Instrument first.
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
Removing it would have been a confident, review-passing regression.
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
committed another worker's temporary revert. Stage explicit paths, always.

View File

@@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -95,6 +98,10 @@ class Amethyst : Application() {
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
// exits) starts on the open web without first flashing a failed Tor load.
WebAppNetworkRegistry.init(this)

View File

@@ -27,6 +27,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
@@ -84,6 +85,7 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
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.event.EventFinderQueryState
@@ -693,8 +695,32 @@ class AppModules(
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
/**
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
* call (never captured) so an account switch immediately moves embedded apps to the new account's
* namespace: an app authorized by one npub is never authorized under another.
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
* caller shares this instance. The broker service and the Connected Apps screens used to build
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
* session map while the grants the broker was actually consulting lived on untouched.
*
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
*/
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
// account rather than the active one. The napplet path namespaces its own coordinate the same way
// (see NappletBroker.signerCoordinateFor) instead.
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.
@@ -755,6 +781,10 @@ class AppModules(
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
// Per-relay cold-start census: connection outcome by cause, REQ/EOSE/CLOSED accounting,
// and which relays actually carried the boot (tag: BootRelayDiag).
val bootDiagnostics = if (isDebug) BootRelayDiagnostics(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(

View File

@@ -236,6 +236,19 @@ object LocalPreferences {
// the source of truth, so there is no async hydrate that could clobber a user toggle.
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
/**
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
* thread, so the first synchronous read below hits memory rather than disk.
*
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
* simply pays the disk hit once, exactly as before.
*/
fun warmGlobalSettings() {
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
}
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}

View File

@@ -193,6 +193,16 @@ private fun SignerConsentDialog(
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
// For a decrypt request, WHOSE conversation is being read is the decision. Show
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
}
}
Spacer(Modifier.height(12.dp))
@@ -204,12 +214,31 @@ private fun SignerConsentDialog(
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: always allow this op
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
// Alice" — one broad decrypt grant would otherwise hand over every conversation
// forever, and scoping the op itself would mean a prompt per conversation.
val narrowOp = info.narrowOp
if (narrowOp != null && info.narrowOpLabel != null) {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(info.narrowOpLabel)
}
// The broad grant stays available, but demoted below the scoped one.
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
}
// Secondary: allow just once

View File

@@ -65,6 +65,23 @@ data class SignerConsentInfo(
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
/**
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
* decision from "X wants to read your private messages", so this must reach the dialog.
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
*/
val counterpartyName: String? = null,
val counterpartyPicture: String? = null,
val counterpartyPubKey: String? = null,
/**
* A NARROWER op the dialog may offer to remember instead of [op] — today only
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
*/
val narrowOp: NostrSignerOp? = null,
val narrowOpLabel: String? = null,
)
/** One pending per-operation consent request, as the batched sheet renders it. */

View File

@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
@Volatile private var iconDir: File? = null
/** Binds the app context and indexes already-stored icons. Idempotent. */
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
try {
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}

View File

@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
@@ -92,9 +93,20 @@ object FavoriteAppLauncher {
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
NappletBrowserActivity
.intent(
context,
url,
proxyPort,
useTor,
theme = theme,
isFavorite = isFavorite,
// Opaque per-account storage partition, so a web app can't carry one npub's session
// into another. Derived here (the sandbox never sees the pubkey).
webViewProfile = NappletWebViewProfiles.current(),
).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}

View File

@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
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
@@ -208,6 +209,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
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
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -362,6 +364,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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
@@ -371,6 +374,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
/** 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(
@@ -650,6 +661,11 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
// Account-level notification history paging cursors (one scope per account): how far back each
// notification relay has been paged by until+limit. Held here so they share the account's lifetime;
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
@@ -2090,6 +2106,16 @@ class Account(
* 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
@@ -2112,6 +2138,7 @@ class Account(
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
@@ -2135,6 +2162,10 @@ class Account(
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)
@@ -2343,7 +2374,7 @@ class Account(
): 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())
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
}
@@ -2405,12 +2436,12 @@ class Account(
val roleIdHex =
existing?.key ?: run {
val roleId = RandomInstance.bytes(32)
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
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())
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
}
@@ -2422,17 +2453,26 @@ class Account(
): 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())
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 is the owner or holds the BAN permission, and the target is
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI shows the Ban action only when it would actually take
* effect on fold.
* 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<String, HexKey>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
@@ -2446,7 +2486,11 @@ class Account(
?.value
?.authority ?: return null
if (authority.isOwner(author)) return null
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
// 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
}
@@ -2457,7 +2501,7 @@ class Account(
): 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())
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2469,7 +2513,7 @@ class Account(
): 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())
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2483,7 +2527,7 @@ class Account(
/**
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
* roll the `community_root`, re-key every retained member (Guestbook membership
* the privileged roster self) via kind-3303 blobs, and republish the compacted
* 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.
@@ -2508,14 +2552,23 @@ class Account(
// 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())
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 — Guestbook joins roster self, minus the
// removed and the already-banned.
// 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.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
(session.allMembers() + signer.pubKey)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removedLower)
@@ -2583,6 +2636,13 @@ class Account(
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)
@@ -2591,10 +2651,23 @@ class Account(
/**
* 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
* 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.
* 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
@@ -2612,12 +2685,76 @@ class Account(
) ?: continue
if (received.newEpoch <= entry.rootEpoch) continue
val authority = session.state.value?.authority ?: continue
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
// 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<String, Long>()
/**
* 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
@@ -2634,7 +2771,7 @@ class Account(
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())
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2652,7 +2789,7 @@ class Account(
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())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2665,8 +2802,16 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// 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
}
@@ -2679,8 +2824,15 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim(), deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// 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
}
@@ -4916,7 +5068,10 @@ class Account(
else -> event.content
}
} else {
event.content
// A read-only (npub-only) account holds no key, so nothing above can run. Returning
// `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the
// UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable".
if (event.hasEncryptedContent()) null else event.content
}
}
@@ -4943,6 +5098,11 @@ class Account(
draftsDecryptionCache.cachedDraft(event)?.content
}
// Encrypted kinds that reached here did so because this account is not writeable
// (every branch above is gated on isWriteable). Their `content` is ciphertext —
// hand back null rather than let the blob render. See cachedDecryptContent.
event != null && event.hasEncryptedContent() -> null
else -> {
event?.content
}
@@ -5373,6 +5533,9 @@ class Account(
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) }
// 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) }
}
}

View File

@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : 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

View File

@@ -2006,9 +2006,20 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event == null) return
if (relay != null) {
// Normal arrival: the group only exists on its host relay and the
// filters are host-pinned, so the serving relay is the group's key.
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
// Normal arrival: the group's host-pinned filters served it, so the serving relay IS the
// group's key and its channel already exists. Fast O(1) path — no scan.
existing.addNote(note, relay)
} else {
// No channel keyed to the serving relay: this may be a stray from a NON-host relay (e.g. a
// quoted kind-9 resolved by id). Redirect it to the group's single confirmed host rather
// than mint a phantom channel the group's screens never read (the serving-relay hazard);
// fall back to the serving-relay key when there is no single host (new/ambiguous group).
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addNote(note, relay)
}
} else {
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
@@ -2022,6 +2033,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
/** Candidate group channels for the [redirectStrayRelayGroupContent] slow path — one scan by group id. */
private fun relayGroupCandidatesFor(groupId: String): List<RelayGroupTargetCandidate> =
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.map { RelayGroupTargetCandidate(it.groupId, it.hasRelaySignedState()) }
/**
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
* are kept in a separate collection from the chat timeline so the two content
@@ -2036,7 +2053,15 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event == null) return
if (relay != null) {
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note)
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
existing.addThread(note)
} else {
// Same serving-relay hazard as the chat path: prefer the single confirmed host over a phantom.
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addThread(note)
}
} else {
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
relayGroupChannels

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* A candidate group channel when routing a stray group-scoped content event: its [key] and whether it is a
* confirmed host (has received relay-signed state). See [redirectStrayRelayGroupContent].
*/
data class RelayGroupTargetCandidate(
val key: GroupId,
val hasRelaySignedState: Boolean,
)
/**
* Resolves the **serving-relay hazard**. A group-scoped content event (kind-9 chat, poll, kind-11 thread…)
* is keyed to its group channel by the relay that served it, because a NIP-29 event doesn't carry its host
* relay. That is correct for the group's own host-pinned subscriptions, but a message resolved from a
* **non-host** relay — e.g. a quoted kind-9 fetched by id during missing-event resolution — would be filed
* under a channel keyed to that stranger relay, one the group's own screens never read, so the message
* silently vanishes.
*
* Called only when there is **no** channel keyed to the serving relay for this group id (the fast, common
* path attaches directly and never gets here). It picks the group's single confirmed **host** channel — one
* that has received relay-signed state — to attach the stray to instead. Returns that host key, or null when
* there is no single confirmed host (a genuinely new group on the serving relay, or an id ambiguous across
* several hosts), in which case the caller keeps the serving-relay key as today's best effort.
*
* A phantom channel (one minted from an earlier stray) never has relay-signed state, so it can never be
* chosen here — the redirect only ever lands on a real host, never on another phantom. This makes the fix
* strictly safe: it can redirect a stray to a known host, but never divert a message away from one.
*/
fun redirectStrayRelayGroupContent(candidates: List<RelayGroupTargetCandidate>): GroupId? = candidates.filter { it.hasRelaySignedState }.singleOrNull()?.key

View File

@@ -29,14 +29,14 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.label
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -119,39 +119,43 @@ object Nip46ConsentBridge {
} ?: AppConnectResult.Cancelled
}
/** Per-operation consent: describe the request (op + event preview) and await the user's grant. */
/**
* Per-operation consent: describe the request and await the user's grant.
*
* For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the
* counterparty the conversation is with. That is what makes the decision reviewable: without it
* the dialog said only "wants to read your private messages" with no way to tell one request from
* another. Decryption is local — [signer] runs on this device and nothing leaves it unless the
* user approves — and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer
* degrades to an explanatory message instead of hanging or blanking the prompt.
*/
suspend fun requestOp(
coordinate: String,
clientPubKey: HexKey,
op: NostrSignerOp,
request: BunkerRequest,
signer: NostrSigner,
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val preview =
if (request is BunkerRequestSign) {
request.event.content
.take(160)
.trim()
} else {
""
}
val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else ""
val face = accountFace(coordinate)
val consentInfo =
SignerConsentInfo(
appletTitle = title,
Nip46ConsentInfoBuilder.build(
coordinate = coordinate,
op = op,
operationSummary = op.label(context),
contentPreview = preview,
rawData = rawData,
title = title,
iconUrl = info?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
op = op,
request = request,
account = accountFace(coordinate),
faceOf = ::userFace,
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
@@ -159,16 +163,30 @@ object Nip46ConsentBridge {
} ?: SignerOpGrant.DenyOnce
}
/**
* Performs the local decryption behind the decrypt preview with the account's own signer. Errors
* and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call.
*/
private suspend fun decryptWithAccountSigner(
signer: NostrSigner,
request: BunkerRequest,
): String? =
when (request) {
is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey)
is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey)
else -> null
}
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
private fun accountFace(coordinate: String): AccountFace {
private fun accountFace(coordinate: String): SignerFace {
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
private data class AccountFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */
private fun userFace(pubKey: HexKey): SignerFace {
val user = LocalCache.getUserIfExists(pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
}

View File

@@ -0,0 +1,176 @@
/*
* 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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
/** Avatar + display name for one pubkey, as the consent dialogs render it. */
data class SignerFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/**
* The user-visible strings the builder needs, injected rather than read from `R.string` so the
* builder itself carries no Android dependency and can be unit-tested.
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
/**
* Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt.
*
* Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the
* decisions that matter for safety are testable without an emulator:
* - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the
* contract the dialog documented but never implemented;
* - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one;
* - the counterparty label is never empty — it degrades to a shortened npub, never to nothing.
*/
object Nip46ConsentInfoBuilder {
/** Characters of plaintext/content shown inline before the "show more" toggle takes over. */
const val PREVIEW_MAX_CHARS = 160
/**
* Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the
* prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding)
* must not delay the dialog.
*/
const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L
suspend fun build(
coordinate: String,
title: String,
iconUrl: String?,
op: NostrSignerOp,
request: BunkerRequest,
account: SignerFace,
/** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */
faceOf: (HexKey) -> SignerFace,
strings: Nip46ConsentStrings,
/** Performs the local decryption. May fail, return null, or hang — all are handled. */
decrypt: suspend (BunkerRequest) -> String?,
): SignerConsentInfo {
val counterparty = request.decryptCounterparty()
val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null
val preview =
when {
request is BunkerRequestSign ->
request.event.content
.take(PREVIEW_MAX_CHARS)
.trim()
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
else -> ""
}
val rawData =
when {
request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event)
// Only worth a "show more" toggle when the preview actually truncated it.
plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext
else -> ""
}
// A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to
// the broad "always allow", instead of only the all-conversations-forever choice.
val narrowOp = request.toNarrowSignerOp()
val counterpartyFace = counterparty?.let { face(it, faceOf) }
return SignerConsentInfo(
appletTitle = title,
coordinate = coordinate,
op = op,
// For decrypt this names the counterparty ("read your private messages with Alice").
operationSummary = strings.opLabel(narrowOp ?: op),
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
accountName = account.name,
accountPicture = account.picture,
accountPubKey = account.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
counterpartyName = counterpartyFace?.name,
counterpartyPicture = counterpartyFace?.picture,
counterpartyPubKey = counterparty,
narrowOp = narrowOp,
narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) },
)
}
/**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
* ciphertext we cannot even read is itself worth showing — a blank dialog is not.
*/
private suspend fun decryptPreview(
request: BunkerRequest,
decrypt: suspend (BunkerRequest) -> String?,
failureText: String,
): String =
withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) {
try {
decrypt(request)?.ifBlank { null }
} catch (e: CancellationException) {
// Includes this block's own timeout — must propagate so withTimeoutOrNull sees it.
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" }
null
}
} ?: failureText
/** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */
private fun face(
pubKey: HexKey,
faceOf: (HexKey) -> SignerFace,
): SignerFace {
val resolved = runCatching { faceOf(pubKey) }.getOrNull()
return SignerFace(
name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey),
picture = resolved?.picture,
pubKey = pubKey,
)
}
/** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */
fun shortIdentifier(pubKey: HexKey): String {
val npub = runCatching { NPub.create(pubKey) }.getOrNull()
return if (!npub.isNullOrBlank()) npub.take(12) + "" else pubKey.take(12) + ""
}
}

View File

@@ -170,7 +170,12 @@ class Nip46SignerState(
// connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds,
// decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing.
connectConsent = Nip46ConsentBridge::requestConnect,
opConsent = Nip46ConsentBridge::requestOp,
// The account's own signer goes to the bridge so a decrypt request can be decrypted
// BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque
// "wants to read your private messages". Local only; nothing is disclosed until approval.
opConsent = { coordinate, clientPubKey, op, request ->
Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer)
},
)
init {

View File

@@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
*/
class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletPermissionStore {
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
/**
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
* every read and write to that account's namespace with no rebuild — a grant made by one account
* can never authorize another.
*/
private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate"
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
val prefs = dataStore.data.first()
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
val result = mutableMapOf<NappletCapability, GrantState>()
for ((key, value) in prefs.asMap()) {
val name = key.name
@@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore(
}
override suspend fun clear(coordinate: String) {
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
dataStore.edit { prefs ->
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
toRemove.forEach { prefs.remove(it) }
@@ -78,11 +87,15 @@ class DataStoreNappletPermissionStore(
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
val prefs = dataStore.data.first()
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
val accountPrefix = "${accountPubKey()}$SEP"
for ((key, value) in prefs.asMap()) {
val name = key.name
// Key is "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
val capName = name.substringAfterLast(SEP, "")
val coordinate = name.substringBeforeLast(SEP, "")
// Key is "<account><SEP><coordinate><SEP><CAPABILITY>". Only the active account's grants
// are listed, so the Connected Apps screen never surfaces another account's permissions.
if (!name.startsWith(accountPrefix)) continue
val scoped = name.removePrefix(accountPrefix)
val capName = scoped.substringAfterLast(SEP, "")
val coordinate = scoped.substringBeforeLast(SEP, "")
if (capName.isEmpty() || coordinate.isEmpty()) continue
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
@@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore(
private fun keyOf(
coordinate: String,
capability: NappletCapability,
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
companion object {
private const val SEP = "\u0000"

View File

@@ -32,14 +32,20 @@ import kotlinx.coroutines.flow.first
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
/**
* DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one
* napplet's keys can never collide with another's, and this store is entirely separate from the
* app's own preferences.
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
* never visible to another, and this store is entirely separate from the app's own preferences.
*
* [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and
* writes to the new namespace with no rebuild — an embedded applet always sees the current account's
* data and never the previous one's.
*/
class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
@@ -62,7 +68,9 @@ class DataStoreNappletStorage(
}
override suspend fun keys(coordinate: String): List<String> {
val prefix = "$coordinate "
// Must match keyOf's separator exactly. This filtered on a space while keys are written
// with NUL, so no key could ever match and keys() always returned an empty list.
val prefix = prefixOf(coordinate)
return dataStore.data
.first()
.asMap()
@@ -72,8 +80,11 @@ class DataStoreNappletStorage(
.map { it.removePrefix(prefix) }
}
/** Account first, then applet: isolates accounts from each other, and applets within an account. */
private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000"
private fun keyOf(
coordinate: String,
key: String,
) = stringPreferencesKey("$coordinate\u0000$key")
) = stringPreferencesKey(prefixOf(coordinate) + key)
}

View File

@@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
@@ -50,7 +49,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -78,32 +77,37 @@ import kotlinx.coroutines.launch
class NappletBrokerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
// Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the
// Connected Apps screens revoke the very grants this broker consults; session grants are dropped
// in onDestroy, which is the "all applet surfaces closed" boundary.
private val ledger get() = Amethyst.instance.nappletPermissionLedger
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
// instantiated in the main process where the signer lives; never touched from :napplet.
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
// The broker for the current account, rebuilt only on account switch (see broker()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
// Live relay subscriptions, keyed by the applet's subId; reads the current account live.
private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() }
// 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()
// 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) }
// Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet.
// Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the
// app's active one: a surface acts as the account that opened it for its whole life, so switching
// accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here
// would tell a page it had become someone else while its signatures still came back as the
// original — the same desync the launch binding exists to prevent. What this does still report is
// that account going away (logout/removal), which emits "".
private val identityWatch =
NappletIdentityWatch(scope) {
Amethyst.instance.sessionManager.accountContent
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
NappletIdentityWatch(scope) { boundPubKey ->
Amethyst.instance.accountsCache.accounts
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
}
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
@@ -114,6 +118,13 @@ class NappletBrokerService : Service() {
override fun onDestroy() {
liveSubscriptions.closeAll()
identityWatch.stop()
// 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
// the consent dialog promises ("allow for this session") instead of letting it become
// "allow until the app process dies".
dropCachedBroker()
Amethyst.instance.nappletPermissionLedger.endSession()
// Drop any foreground holds this broker still owns so they don't leak past the service.
synchronized(foregroundLeases) {
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
@@ -241,7 +252,10 @@ class NappletBrokerService : Service() {
val replyTo = msg.replyTo ?: return true
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
@@ -278,17 +292,20 @@ class NappletBrokerService : Service() {
// 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.
val broker = broker()
// 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("No account is signed in.")))
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) { 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.WatchIdentity -> identityWatch.start { push(replyTo, it) }
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)
@@ -327,28 +344,44 @@ class NappletBrokerService : Service() {
}
}
/** The launched-as account, or null once it is no longer loaded. */
private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey]
/**
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
* changes (reference identity). The gateways capture the account and read its flows live, so a
* cached broker stays correct across requests without per-request allocation.
* The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey],
* never whichever account is active right now.
*
* Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a
* separate activity that an account switch does not tear down, so its WebView kept account A's
* cookies while requests were signed by B. The page displayed one identity while another signed,
* and B's session was written into A's storage jar — after which even the embedded tab, which is
* rebuilt correctly, showed the wrong account.
*
* Binding to the launch account satisfies both halves of the rule with no extra machinery:
* embedded surfaces are torn down and re-minted on a switch, so they follow the active account,
* while a full-screen surface stays on the account it was opened with.
*
* Returns null when that account is no longer loaded (logged out), so requests fail closed
* rather than silently falling back to someone else's key.
*/
@Synchronized
private fun broker(): NappletBroker? {
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
val broker =
AccountNappletGateways(
account = account,
context = applicationContext,
ledger = ledger,
storage = storage,
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
signerLedger = signerLedger,
).broker()
cachedBroker = account to broker
return broker
private fun brokerFor(accountPubKey: HexKey): NappletBroker? {
val account = accountFor(accountPubKey) ?: return null
synchronized(brokerLock) {
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
val broker =
AccountNappletGateways(
account = account,
context = applicationContext,
ledger = ledger,
storage = storage,
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
signerLedger = signerLedger,
).broker()
cachedBroker = account to broker
return broker
}
}
/**
@@ -403,6 +436,38 @@ class NappletBrokerService : Service() {
}
companion object {
/**
* Guards [cachedBroker]. Both live on the companion rather than the service instance so the
* Connected Apps UI can reach the running broker to revoke its live session grants — the
* screens are plain composables with no binder to this service, and the broker is the only
* holder of the in-memory "allow for this session" signer grants.
*
* Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets
* its own (unused, empty) copy of these statics and must never touch them.
*/
private val brokerLock = Any()
// The broker for the current account, rebuilt only on account switch (see brokerFor()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
/**
* Drops the live "allow for this session" signer grants the running broker holds for
* [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in
* Connected Apps: without it the persisted grants are cleared but the in-memory session ones
* keep authorizing signatures until the broker dies, so a revoked app goes on signing.
*
* No-op when no broker has been built yet (no applet has run this process).
*/
suspend fun revokeSessionGrants(coordinate: String) {
val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return
broker.revokeSessionGrants(coordinate)
}
/** Forgets the cached broker, dropping every session grant it holds. */
private fun dropCachedBroker() {
synchronized(brokerLock) { cachedBroker = null }
}
/**
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
* carried in the identity's identifier (which the consent dialog shows); this constant only fills

View File

@@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.horizontalScroll
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -41,11 +45,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -54,6 +65,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
)
}
// Operation detail box (may include content preview)
if (info.operationSummary.isNotBlank()) {
// Operation detail box (may include content preview), plus the full event behind a
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
SelectionContainer {
Text(
info.operationSummary,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
Column(modifier = Modifier.padding(12.dp)) {
if (info.operationSummary.isNotBlank()) {
SelectionContainer {
Text(
info.operationSummary,
style = MaterialTheme.typography.bodySmall,
)
}
}
// A single-account follow/mute change: show who, so the user recognizes
// the face rather than parsing a name they may not read carefully.
info.subject?.let { subject ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashFallbackAsyncImage(
robot = subject.pubKey,
model = subject.pictureUrl,
contentDescription = subject.name,
modifier = Modifier.size(36.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Spacer(Modifier.width(8.dp))
Text(
subject.name,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.rawData.isNotBlank()) {
var showRawData by remember { mutableStateOf(false) }
if (showRawData) {
Spacer(Modifier.height(8.dp))
Surface(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(onClick = { showRawData = !showRawData }) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}

View File

@@ -36,6 +36,26 @@ data class NappletConsentInfo(
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
val allowAlways: Boolean,
val iconUrl: String? = null,
/**
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
* rendering — it truncates content and cannot spell out every tag — so this is the only place
* the user can see exactly what a signature would cover.
*/
val rawData: String = "",
/**
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
* Null for multi-account edits and every other request.
*/
val subject: ConsentSubject? = null,
)
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
data class ConsentSubject(
val pubKey: String,
val name: String,
val pictureUrl: String?,
)
/**

View File

@@ -21,22 +21,35 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
* or a sat amount). Localized via app resources.
*
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
* already cached there, so the dialog can say what a signature would actually change. It never signs,
* mutates, or exposes account state — the values it reads are the user's own public lists.
*/
class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
identity: NappletIdentity,
@@ -51,16 +64,196 @@ class NappletConsentSummary(
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val consequence = consequenceFor(request)
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
rawData = rawEventFor(request),
subject = consequence?.subject,
)
}
/**
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
* signing requests carry one; everything else has nothing to disclose.
*/
private fun rawEventFor(request: NappletRequest): String =
when (request) {
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
else -> ""
}
private fun rawEvent(
kind: Int,
tags: Array<Array<String>>,
content: String,
createdAt: Long?,
): String =
buildString {
append("kind: ").append(kind).append('\n')
createdAt?.let { append("created_at: ").append(it).append('\n') }
append("tags:")
if (tags.isEmpty()) {
append(" []\n")
} else {
append('\n')
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
}
append("content: ").append(content.ifEmpty { "(empty)" })
}
/** A consequence line, plus the single account it is about when the change names exactly one. */
private data class Consequence(
val text: String,
val subject: ConsentSubject? = null,
)
/**
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
when (kind) {
ContactListEvent.KIND ->
diffOf(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
MuteListEvent.KIND ->
diffOf(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
}
/**
* Describes what a proposed replaceable list changes relative to the copy already on the account.
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
private fun subjectOf(pubKey: String): ConsentSubject? {
val user = account.cache.getUserIfExists(pubKey) ?: return null
return ConsentSubject(
pubKey = pubKey,
name = user.toBestDisplayName(),
pictureUrl = user.profilePicture(),
)
}
/** The distinct values of every `[tagName, value, …]` tag. */
private fun valuesOf(
tags: Array<Array<String>>,
tagName: String,
): Set<String> {
val out = mutableSetOf<String>()
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
return out
}
private fun pluralFor(
resId: Int,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
@@ -91,11 +284,14 @@ class NappletConsentSummary(
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null) {
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay)
context.getString(R.string.napplet_consent_pay_no_amount)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)

View File

@@ -39,15 +39,18 @@ import kotlinx.coroutines.launch
*/
class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: () -> Flow<String>,
private val pubKey: (boundPubKey: String) -> Flow<String>,
) {
private var job: Job? = null
fun start(push: (String) -> Unit) {
fun start(
boundPubKey: String,
push: (String) -> Unit,
) {
stop()
job =
scope.launch {
pubKey()
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }

View File

@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.SecureRandom
@@ -45,6 +46,18 @@ object NappletLaunchRegistry {
data class Session(
val identity: NappletIdentity,
val declared: Set<NappletCapability>,
/**
* The account this surface was launched as. Requests resolve their signer through THIS, not
* through whichever account happens to be active when they arrive.
*
* A full-screen host is a separate activity that an account switch does not tear down, so
* resolving live meant its WebView kept account A's cookies while the broker signed as B —
* a page showing one identity while another signed, and B's session written into A's
* storage jar. Binding here gives both halves of the rule for free: embedded surfaces are
* rebuilt on a switch, so they re-mint and follow the active account, while a full-screen
* surface keeps the account it was opened with.
*/
val accountPubKey: HexKey,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
@@ -60,9 +73,10 @@ object NappletLaunchRegistry {
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
accountPubKey: HexKey,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared)
sessions[token] = Session(identity, declared, accountPubKey)
return token
}

View File

@@ -120,7 +120,16 @@ object NappletLauncher {
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val declared = profile.declaredCapabilities(requires)
val launchToken = NappletLaunchRegistry.register(identity, declared)
// 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).
// An empty key can never match a loaded account, so a launch with nobody signed in fails
// closed at the broker rather than falling back to whoever signs in later.
val launchAccountPubKey =
Amethyst.instance.sessionManager
.loggedInAccount()
?.pubKey
.orEmpty()
val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
@@ -156,6 +165,9 @@ object NappletLauncher {
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
// Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies
// and localStorage into another. Derived here (the sandbox never sees the pubkey).
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
}

View File

@@ -38,12 +38,13 @@ import java.util.concurrent.atomic.AtomicInteger
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
* caller-supplied sink — it never touches the transport itself.
*
* [account] is read live (so it always targets the currently signed-in account); [open] is reached
* only after the broker authorized the subscription (RELAY consent).
* The account is supplied per [open] by the caller, which resolves it from the requesting surface's
* LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an
* account switch, and reading live would have pointed its REQs at the new account's relays while its
* signatures still came from the old one. [open] is reached only after the broker authorized the
* subscription (RELAY consent).
*/
class NappletLiveSubscriptions(
private val account: () -> Account?,
) {
class NappletLiveSubscriptions {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
@@ -62,9 +63,9 @@ class NappletLiveSubscriptions(
fun open(
nappletSubId: String,
filters: List<Filter>,
account: Account?,
push: (String) -> Unit,
) {
val account = account()
val relays = account?.homeRelays?.flow?.value ?: emptySet()
if (account == null || filters.isEmpty() || relays.isEmpty()) {
push(NappletProtocolJson.encodeRelayEose(nappletSubId))

View File

@@ -0,0 +1,63 @@
/*
* 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.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.MessageDigest
/**
* Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies,
* localStorage, IndexedDB and service workers by.
*
* Every embedded app follows the currently-selected account, and each account gets its OWN storage
* jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact
* (this is a partition, not a wipe).
*
* The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which
* account it is running for — it only gets a stable, meaningless token. Stability is what makes
* sessions survive a switch, so the derivation must never change once shipped.
*
* The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape
* before handing it to `ProfileStore`.
*/
object NappletWebViewProfiles {
/** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */
private const val DOMAIN = "amethyst-webview-profile-v1:"
/** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */
private const val NAME_LENGTH = 32
/** The profile name for the account embedded apps currently run as, or null when logged out. */
fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope())
/** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */
fun forPubKey(pubKey: HexKey): String? {
if (pubKey.isBlank()) return null
return MessageDigest
.getInstance("SHA-256")
.digest((DOMAIN + pubKey).toByteArray())
.toHexKey()
.take(NAME_LENGTH)
}
}

View File

@@ -24,15 +24,19 @@ import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
@@ -41,8 +45,24 @@ fun NostrSignerOp.label(context: Context): String =
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
/**
* A person's display name for a consent prompt: their profile name when we have it cached, otherwise
* a shortened npub. Never empty — "read your private messages with <nothing>" would be worse than the
* broad wording it replaces.
*/
fun counterpartyLabel(pubKeyHex: HexKey): String {
LocalCache
.getUserIfExists(pubKeyHex)
?.toBestDisplayName()
?.ifBlank { null }
?.let { return it }
val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull()
return if (npub != null) npub.take(12) + "" else pubKeyHex.take(12) + ""
}
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
context: Context,
@@ -105,10 +125,16 @@ fun buildSignerConsentInfo(
)
}
/** Creates a [SignerConnectInfo] for the first-connect dialog. */
/**
* Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the
* connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
* approve a set it never showed them.
*/
fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
declared: Set<NappletCapability> = emptySet(),
): SignerConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
@@ -124,5 +150,18 @@ fun buildConnectInfo(
} else {
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
}
return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
// Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and
// VALUE is per-use, so listing them would overstate what accepting hands over.
val preGranted =
declared
.filter { it.requiresConsent && !it.requiresPerUseConsent }
.map { context.getString(it.labelRes()) }
.sorted()
return SignerConnectInfo(
appletTitle = title,
coordinate = identity.coordinate,
domain = domain,
iconUrl = iconUrl,
requestedPermissions = preGranted,
)
}

View File

@@ -81,7 +81,9 @@ class AccountNappletGateways(
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
private val signerLedger: NostrSignerPermissionLedger? = null,
) {
private val consentSummary = NappletConsentSummary(context)
// Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays,
// mutes) against the copy already cached here, and say what actually changes.
private val consentSummary = NappletConsentSummary(context, account)
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
@@ -138,10 +140,10 @@ class AccountNappletGateways(
}
val connectPrompt =
NostrConnectPrompt { identity ->
NostrConnectPrompt { identity, declared ->
SignerConnectCoordinator.requestConnect(
context = context,
info = buildConnectInfo(context, identity),
info = buildConnectInfo(context, identity, declared),
)
}

View File

@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import okhttp3.OkHttpClient
import kotlin.coroutines.cancellation.CancellationException
@@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException
* it also picks up NUT-02 per-input fee handling for free.
*/
class MeltProcessor {
/**
* @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token
* pointing at one of those was, by definition, issued by a mint the user
* deliberately added, so it is exempt from the private-address block that
* [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies
* to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate).
*/
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
knownWalletMints: Set<String> = emptySet(),
): MeltResult {
try {
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) }
val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient))
val proofs = token.proofs
// A Lightning address must commit to an amount before we know the
@@ -106,6 +116,14 @@ class MeltProcessor {
} catch (e: Exception) {
if (e is CancellationException) throw e
if (e is LightningAddressResolver.LightningAddressError) throw e
// The mint URL was refused before any request went out: this is OUR
// message, not the mint's, so don't dress it up as "the mint said".
if (e is MintUrlException) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_unsafe_mint_url),
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
)
}
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),

View File

@@ -91,13 +91,17 @@ abstract class FlowProgressForegroundService<T> : Service() {
protected abstract fun cancelAll()
/** Called for every emission before [render]; use to update derived subclass state. */
protected open fun onEmission(value: T) {}
protected open fun onEmission(value: T) {
// No-op by default: only subclasses that keep derived state need this hook.
}
/** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
protected open fun needsClockRefresh(value: T): Boolean = true
/** One-time setup once the watch loop starts (e.g. a benchmark). */
protected open fun onStarted() {}
protected open fun onStarted() {
// No-op by default: only subclasses with one-time setup (e.g. a benchmark) override this.
}
/** How to draw the progress bar of the card. */
sealed interface Bar {

View File

@@ -20,6 +20,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.ui.tor.TorServiceStatus
@@ -63,6 +64,27 @@ class RelayProxyClientConnector(
private var lastTorConnection: OkHttpClient? = null
private var lastClearConnection: OkHttpClient? = null
// The network we were last on. The OkHttp clients above are rebuilt off the metered
// bit, so they only tell us about wifi<->cellular; they say nothing about wifi A ->
// wifi B, a VPN coming up, or a captive portal clearing. Those all mint a new
// networkHandle, and after them every existing socket is bound to an interface that
// is gone and every accumulated backoff was earned against a network we have left.
private var lastNetworkId: Long? = null
// The user's Tor preferences. TorRelayEvaluation has no equals() and a fresh instance is
// emitted on unrelated churn, so we track the settings by value. Without this, flipping a
// Tor toggle while Tor is already up leaves both OkHttpClient references identical ->
// transportChanged=false -> a relay parked on a 5-minute backoff keeps waiting it out on
// a transport it is no longer using.
//
// Deliberately only [TorRelaySettings], not the trusted/DM/money relay sets that
// TorRelayEvaluation also carries: those churn constantly while an account's relay lists
// load from the network (observed firing three times during a single cold start). A relay
// moving between classifications can flip its transport too, but forgiving the whole
// pool's backoff every time any relay list updates is far more damage than making that
// one relay serve out its delay.
private var lastTorSettings: TorRelaySettings? = null
@OptIn(FlowPreview::class)
val relayServices =
combine(
@@ -74,51 +96,8 @@ class RelayProxyClientConnector(
) { torSettings, torConnection, clearConnection, connectivity, torStatus ->
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity, torStatus)
}.debounce(100)
.onEach {
when {
it.connectivity is ConnectivityStatus.StartingService -> {
// ignore
}
it.connectivity is ConnectivityStatus.Off -> {
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${it.connectivity}" }
if (client.isActive()) {
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
}
it.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
client.connect()
}
else -> {
// Only skip the per-relay exponential backoff when the actual HTTP
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
// port not yet listening) honor each relay's backoff so we don't
// reconnect-fail-reconnect on every unrelated infrastructure event.
val transportChanged =
it.torConnection !== lastTorConnection || it.clearConnection !== lastClearConnection
lastTorConnection = it.torConnection
lastClearConnection = it.clearConnection
Log.d("ManageRelayServices") { "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)" }
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = transportChanged,
)
}
}
}.onStart {
.onEach { apply(it) }
.onStart {
Log.d("ManageRelayServices", "Resuming Relay Services")
client.connect()
}.onCompletion {
@@ -130,4 +109,101 @@ class RelayProxyClientConnector(
SharingStarted.WhileSubscribed(30000),
null,
)
/**
* Decides what a change in the relay infrastructure means for the pool. Split out of the
* flow so the decision table can be exercised directly, without a debounce and a shared
* StateFlow in the way.
*/
fun apply(infra: RelayServiceInfra) {
val networkId = (infra.connectivity as? ConnectivityStatus.Active)?.networkId
val torSettings = infra.evaluator.torSettings
when {
infra.connectivity is ConnectivityStatus.StartingService -> {
// ignore
}
infra.connectivity is ConnectivityStatus.Off -> {
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
if (client.isActive()) {
client.disconnect()
}
if (infra.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
// disconnect() already cleared every relay's backoff. Forget the network
// so the next Active is treated as a fresh start rather than a change.
lastNetworkId = null
}
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (infra.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
client.connect()
lastNetworkId = networkId
lastTorSettings = torSettings
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
}
else -> {
// Only skip the per-relay exponential backoff when the actual HTTP
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
// port not yet listening) honor each relay's backoff so we don't
// reconnect-fail-reconnect on every unrelated infrastructure event.
val transportChanged =
infra.torConnection !== lastTorConnection || infra.clearConnection !== lastClearConnection
// A different network entirely. Every socket is bound to an interface that
// no longer carries traffic, and needsToReconnect() cannot see that (it only
// compares the proxy and the timeouts), so those sockets would otherwise sit
// there until OkHttp's 120s ping finally fails.
val networkChanged = networkId != null && lastNetworkId != null && networkId != lastNetworkId
// Same network and same OkHttp clients, but the user re-classified which
// relays go through Tor. The relays whose transport flipped must re-dial now.
val torPolicyChanged = lastTorSettings != null && torSettings != lastTorSettings
val previousNetworkId = lastNetworkId
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
lastNetworkId = networkId ?: lastNetworkId
lastTorSettings = torSettings
if (networkChanged) {
Log.d("ManageRelayServices") {
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
}
// 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 {
val freshStart = transportChanged || torPolicyChanged
if (freshStart) {
// The failures behind the current backoffs were measured against a
// transport we are no longer using. ignoreRetryDelays alone only
// skips the gate once and still doubles the stored delay, so a relay
// that fails this one dial would come back worse off than before.
client.resetBackoff()
}
Log.d("ManageRelayServices") {
"Relay Services have changed, reconnecting relays that need to " +
"(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged)"
}
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = freshStart,
)
}
}
}
}
}

View File

@@ -0,0 +1,261 @@
/*
* 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.diagnostics
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
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.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.AuthCmd
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
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
/**
* Debug-only cold-start census: what every relay in the pool actually did, and why the ones
* that failed, failed.
*
* The existing instrumentation cannot answer this. [com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats]
* has counters but no dump path and no failure taxonomy; `RelaySpeedLogger` counts events per
* second but not connection outcomes; `RelayLogger` prints one line per event, which at the
* few-hundred-relay cardinality of the outbox model is thousands of lines to grep rather than
* a table to read.
*
* What this adds: connection outcome bucketed **by cause**, REQ/EOSE/CLOSED accounting per
* relay, and time-to-first-EOSE — so a boot can be read as "N relays served us, M were
* refused for reason R, K were never reachable".
*
* Attach only in debug builds; it holds one small record per relay for the process lifetime.
*/
class BootRelayDiagnostics(
val client: INostrClient,
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
) {
companion object {
const val TAG = "BootRelayDiag"
/**
* Buckets a connection failure by what actually went wrong. The distinction that
* matters most here is *ours vs theirs*: a SOCKS refusal is our Tor proxy declining
* to open a stream and says nothing about the relay, but it reaches the relay client
* through the same path as a genuine relay failure and is charged to the relay.
*/
fun classify(error: String): String =
when {
error.contains("SOCKS", ignoreCase = true) -> "tor-socks"
error.contains("127.0.0.1") -> "tor-proxy-down"
error.contains("UnknownHostException") -> "dns"
error.contains("SSLHandshakeException") || error.contains("SSLPeerUnverified") -> "tls"
error.contains("SocketTimeoutException") -> "timeout"
error.contains("Server Misconfigured") -> "http-" + (Regex("Response: (\\d+)").find(error)?.groupValues?.get(1) ?: "?")
error.contains("ConnectException") -> "refused"
error.contains("Connection reset") -> "reset"
else -> "other"
}
}
class RelayRecord {
val tentatives = AtomicInteger()
val opens = AtomicInteger()
val disconnects = AtomicInteger()
val reqsSent = AtomicInteger()
val authsSent = AtomicInteger()
val events = AtomicInteger()
val eoses = AtomicInteger()
val notices = AtomicInteger()
/** failure cause -> count, see [classify]. */
val failures = ConcurrentHashMap<String, AtomicInteger>()
/** CLOSED machine-readable prefix (or "unprefixed") -> count. */
val closed = ConcurrentHashMap<String, AtomicInteger>()
val firstOpenAtMs = AtomicLong(0)
val firstEoseAtMs = AtomicLong(0)
fun bump(
map: ConcurrentHashMap<String, AtomicInteger>,
key: String,
) = map.computeIfAbsent(key) { AtomicInteger() }.incrementAndGet()
}
private val records = ConcurrentHashMap<NormalizedRelayUrl, RelayRecord>()
private val startedAtMs = System.currentTimeMillis()
private fun record(url: NormalizedRelayUrl) = records.computeIfAbsent(url) { RelayRecord() }
private fun elapsed() = System.currentTimeMillis() - startedAtMs
private val listener =
object : RelayConnectionListener {
override fun onConnecting(relay: IRelayClient) {
record(relay.url).tentatives.incrementAndGet()
}
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
val r = record(relay.url)
r.opens.incrementAndGet()
r.firstOpenAtMs.compareAndSet(0, elapsed())
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
val r = record(relay.url)
r.bump(r.failures, classify(errorMessage))
}
override fun onDisconnected(relay: IRelayClient) {
record(relay.url).disconnects.incrementAndGet()
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
val r = record(relay.url)
when (cmd) {
is ReqCmd -> r.reqsSent.incrementAndGet()
is AuthCmd -> r.authsSent.incrementAndGet()
else -> Unit
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
val r = record(relay.url)
when (msg) {
is EventMessage -> r.events.incrementAndGet()
is EoseMessage -> {
r.eoses.incrementAndGet()
r.firstEoseAtMs.compareAndSet(0, elapsed())
}
is NoticeMessage -> r.notices.incrementAndGet()
is ClosedMessage -> r.bump(r.closed, prefixOf(msg.message))
else -> Unit
}
}
}
/** First token of a NIP-01 machine-readable CLOSED/OK message, or "unprefixed". */
private fun prefixOf(message: String?): String {
val text = message?.trim().orEmpty()
if (text.isEmpty()) return "empty"
val head = text.substringBefore(':', "")
return if (head.isNotEmpty() && head.length < 20 && !head.contains(' ')) head else "unprefixed"
}
init {
client.addConnectionListener(listener)
thread(isDaemon = true, name = TAG) {
var last = 0L
dumpAtSeconds.forEach { at ->
Thread.sleep((at - last) * 1000)
last = at
dump(at)
}
}
}
fun detach() = client.removeConnectionListener(listener)
/**
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
* survives logcat's per-tag rate limiting on a busy boot.
*/
fun dump(atSeconds: Long) {
val snapshot = records.toMap()
val served = snapshot.filter { it.value.events.get() > 0 }
val opened = snapshot.filter { it.value.opens.get() > 0 }
val neverOpened = snapshot.filter { it.value.opens.get() == 0 }
val causeTotals = mutableMapOf<String, Int>()
val closedTotals = mutableMapOf<String, Int>()
snapshot.values.forEach { r ->
r.failures.forEach { (k, v) -> causeTotals[k] = (causeTotals[k] ?: 0) + v.get() }
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
}
Log.w(TAG, "===== boot census @${atSeconds}s =====")
Log.w(
TAG,
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
"events=${snapshot.values.sumOf { it.events.get() }} " +
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
)
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
Log.w(TAG, "--- top wasted dials (no events received) ---")
snapshot
.filter { it.value.events.get() == 0 }
.entries
.sortedByDescending { it.value.tentatives.get() }
.take(25)
.forEach { (url, r) ->
Log.w(
TAG,
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
"closed=[${r.closed.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}",
)
}
// The relays actually carrying the boot, so a suppression change can be checked for
// coverage loss rather than just CLOSED reduction.
Log.w(TAG, "--- top event providers ---")
served.entries
.sortedByDescending { it.value.events.get() }
.take(20)
.forEach { (url, r) ->
Log.w(
TAG,
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
)
}
Log.w(TAG, "===== end census @${atSeconds}s =====")
}
}

View File

@@ -39,9 +39,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStateFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -126,11 +130,18 @@ class RelaySubscriptionsCoordinator(
// assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their
// own here.
val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list
val relayGroupMyJoinedGroups = RelayGroupMyJoinedGroupsFilterAssembler(client) // metadata+rosters of groups I've joined
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupOpenThreads = RelayGroupOpenThreadsFilterAssembler(client) // a group's forum-threads tab (recent tail)
val relayGroupOpenThreadsHistory = RelayGroupOpenThreadsHistoryFilterAssembler(client) // the Threads tab's backward history pager
val relayGroupCardWarmup = RelayGroupCardWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
// NIP-29 chat, split state-vs-content like the DM / Concord stacks (see
// amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md).
val relayGroupJoinedState = RelayGroupJoinedStateFilterAssembler(client) // always-on: joined groups' metadata/roster/roles/pins
val relayGroupJoinedChatTail = RelayGroupJoinedChatTailFilterAssembler(client) // always-on: batched #h recent-tail for Messages previews
val relayGroupOpenChatTail = RelayGroupOpenChatTailFilterAssembler(client) // the open group's recent chat (covers non-joined)
val relayGroupOpenChatHistory = RelayGroupOpenChatHistoryFilterAssembler(client) // the open group's on-demand backward history pager
// Concord Channels (encrypted communities). One assembler keeps every joined community's
// control + channel planes live (kind-1059 by derived stream address).
val concordChannels = ConcordChannelFilterAssembler(client)
@@ -201,10 +212,14 @@ class RelaySubscriptionsCoordinator(
val all =
listOf(
relayGroupsOnRelay,
relayGroupMyJoinedGroups,
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupOpenThreads,
relayGroupOpenThreadsHistory,
relayGroupCardWarmup,
relayGroupsDiscovery,
relayGroupJoinedState,
relayGroupJoinedChatTail,
relayGroupOpenChatTail,
relayGroupOpenChatHistory,
concordChannels,
concordChannelHistory,
account,

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
@@ -56,13 +57,20 @@ class AccountFilterAssembler(
// History: older gift wraps, loaded on demand in bounded one-shot slices.
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
// Live tail: the recent week of notifications from the inbox + group host relays.
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
giftWraps,
giftWrapsHistory,
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
notifications,
notificationsHistory,
MarmotGroupEventsEoseManager(client, ::allKeys),
)

View File

@@ -28,7 +28,6 @@ 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.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,17 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// 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 = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
) +
filterNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
)
}
@@ -76,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager(
relay = relay,
pubkey = user(key).pubkeyHex,
groupIds = groupIds.distinct(),
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
// already bound this, so a week floor only hides older group activity.
since = since?.get(relay)?.time ?: pagingBoundary,
)
}

View File

@@ -27,7 +27,6 @@ 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.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// only loads this after the feed is built
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo()
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
// behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20
// 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 since = since?.get(it)?.time ?: defaultSince
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)

View File

@@ -0,0 +1,222 @@
/*
* 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.nip01Notifications
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.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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
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.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Loads the account's notification **history** — everything older than the one-week live tail
* ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on
* demand**, so the notifications feed can be scrolled back in time instead of being pinned to the
* recent week.
*
* There is no proactive walk: each relay advances exactly one page when the feed's on-screen
* window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers
* are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it
* stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to
* scroll through its notifications to pull more, and nothing is fetched while its marker is off screen.
*
* Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification
* kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`).
* The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager],
* tiny latest-N limits) is deliberately live-tail only and is NOT paged here.
*
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
* the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification REQ
* filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page;
* one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted]
* flips once every relay is either done or stalled.
*/
class AccountNotificationsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
// A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render
// and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once).
private val pager = BackwardRelayPager("notifications.history", pageLimit = 500)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
// group filter and (its keys) to add group host relays to the paged relay set.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
account.relayGroupList.liveRelayGroupList.value
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
.toMap()
// The full relay set this account pages notifications back through: inbox relays + group host relays.
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) return emptyList()
val pubkey = user(key).pubkeyHex
val inbox = key.account.notificationRelays.flow.value
val groups = groupsByRelay(key.account)
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
// re-REQ it — it stays parked until the marker advances it again.
val armed = pager.armedRelays(inbox + groups.keys)
if (armed.isEmpty()) return emptyList()
return armed.flatMap { relay ->
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
buildList {
if (relay in inbox) {
addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit))
}
groups[relay]?.let { groupIds ->
addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit))
}
}
}
}
/** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */
fun advanceAll() {
if (pager.advanceAll()) {
Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" }
invalidateFilters()
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
// Repoint the single-active orchestrator at this account's notification cursors and the relay set
// it fans out to, refreshing the display flows from the restored progress.
pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) }
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
// A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ
// so a newly-added relay can be armed and a removed one drops out.
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow
.sample(1000)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupList
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// 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(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[notifications.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until its marker asks for the next page.
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
private const val TAG = "NotificationPagination"
}
}

View File

@@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 =
AttestorRecommendationEvent.KIND,
)
/**
* Every kind the notifications feed cares about on the user's inbox relays, flattened into one list.
* The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these
* across several filters with different per-kind limits; backward history paging instead asks ONE filter
* per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older").
*/
val AllNotificationKinds =
(SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct()
/**
* One backward-paging page of notifications on an inbox relay: the N newest events tagging me
* ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's
* split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]
* cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped.
*/
fun filterNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = AllNotificationKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = limit,
until = until,
),
),
)
}
/**
* One backward-paging page of NIP-29 group-activity notifications on a group's host relay:
* [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until].
*/
fun filterGroupNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
groupIds: List<String>,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = limit,
until = until,
),
),
)
}
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,

View File

@@ -40,28 +40,129 @@ import kotlinx.coroutines.withTimeoutOrNull
* caller should surface that a lightning wallet is required.
*/
object BlossomPaymentHandler {
/**
* Hard ceiling on a single BUD-07 charge, in sats.
*
* BUD-07 charges are per-blob storage fees: real paid Blossom servers ask
* single-digit to low-hundreds of sats for a media upload. 10,000 sats is
* roughly USD 10 at a 100k BTC — one to two orders of magnitude above any
* legitimate per-blob fee, so it never gets in a real user's way, while
* capping what a hostile or compromised server can drain in one prompt.
* Anything above this is refused outright rather than shown to the user,
* because the value is server-chosen and a user tapping through a dialog is
* not a meaningful defence against a four-digit-sat surprise.
*/
const val MAX_PAYMENT_SATS = 10_000L
/** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */
sealed interface PayResult {
data class Paid(
val proof: BlossomPaymentProof,
) : PayResult
/** The amount failed [checkAmount]; [reason] is user-facing. */
data class Refused(
val reason: String,
) : PayResult
/** No invoice, no wallet, or the wallet request could not be sent. */
data object Unavailable : PayResult
/** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */
data object TimedOut : PayResult
}
/** Verdict on the invoice amount, before any money moves. */
sealed interface AmountCheck {
data class Ok(
val sats: Long,
) : AmountCheck
/** BOLT-11 with no amount: the payee picks it. Never payable unattended. */
data object Amountless : AmountCheck
data class OverCap(
val sats: Long,
) : AmountCheck
/** The invoice asks for something other than what the dialog told the user. */
data class Mismatch(
val shownSats: Long?,
val actualSats: Long,
) : AmountCheck
}
/**
* Re-derives the amount from the invoice itself and checks it against both the
* cap and [shownSats] — the number the confirmation dialog put in front of the
* user. The amount shown must be the amount paid, so a server that swapped the
* invoice (or leaned on a misleading `X-Reason`) cannot get a different sum
* approved than the one the user agreed to.
*/
fun checkAmount(
payment: BlossomPaymentRequired,
shownSats: Long?,
): AmountCheck {
val actual = amountSats(payment) ?: return AmountCheck.Amountless
if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual)
if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual)
return AmountCheck.Ok(actual)
}
/** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */
fun refusalReason(check: AmountCheck): String =
when (check) {
is AmountCheck.Ok -> ""
is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it."
is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid."
is AmountCheck.Mismatch ->
"The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid."
}
/** True when this account has a wallet we can pay the lightning invoice with. */
fun canPay(
account: Account,
payment: BlossomPaymentRequired,
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
/** The invoice amount in sats, for display in a confirmation prompt. */
/**
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
* unspecified and chosen by the payee.
*/
fun amountSats(payment: BlossomPaymentRequired): Long? =
payment.lightning?.let {
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
}
/**
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if
* there is no payable invoice, no wallet, or the wallet didn't confirm in time.
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof.
*
* [shownSats] is what the confirmation dialog displayed; the invoice is
* re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise
* nothing is sent to the wallet at all.
*/
suspend fun pay(
account: Account,
payment: BlossomPaymentRequired,
): BlossomPaymentProof? {
val invoice = payment.lightning ?: return null
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
shownSats: Long?,
): PayResult {
val invoice = payment.lightning ?: return PayResult.Unavailable
if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable
val check = checkAmount(payment, shownSats)
if (check !is AmountCheck.Ok) {
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
return PayResult.Refused(refusalReason(check))
}
// Never send the same invoice twice: an earlier attempt may still settle.
if (!InFlightInvoices.tryClaim(invoice)) {
return PayResult.Refused(
"A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.",
)
}
val preimageResult = CompletableDeferred<String?>()
try {
@@ -71,10 +172,26 @@ object BlossomPaymentHandler {
}
} catch (e: Exception) {
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
return null
// The request never left, so the invoice is definitively not in flight.
InFlightInvoices.release(invoice)
return PayResult.Unavailable
}
val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null
return BlossomPaymentProof(lightningPreimage = preimage)
// NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout
// cannot stop the payment — it can only stop us from sending it again.
// Deliberately do NOT release the claim on the timeout path.
val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() }
if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut
InFlightInvoices.release(invoice)
val preimage = answered ?: return PayResult.Unavailable
return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage))
}
/**
* How long we wait for the wallet. Matches the previous behaviour; note the
* NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach
* us anyway — which is exactly why the invoice stays blocked afterwards.
*/
private const val PAYMENT_TIMEOUT_MS = 90_000L
}

View File

@@ -0,0 +1,60 @@
/*
* 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.uploads.blossom
/**
* BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned.
*
* [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but
* NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so
* giving up on the wait does **not** stop the payment. An invoice that settles a
* second after we time out still moved the user's money — and if we then let the
* user (or an automatic retry) send the very same invoice again, they pay twice.
*
* So: claim the invoice before sending it, and release the claim only when the
* wallet gives a definitive answer. A timed-out invoice stays claimed for the
* life of the process and can never be paid a second time from this app.
*
* This is the weaker half of the fix — a genuine cancel would be better, but the
* NIP-47 client offers none, so we settle for "never silently pay it twice".
*/
object InFlightInvoices {
private val claimed = mutableSetOf<String>()
/**
* Claims [invoice] for one payment attempt. Returns false when it was already
* claimed and never resolved — the caller must not send it again.
*/
fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) }
/** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */
fun release(invoice: String) {
synchronized(claimed) { claimed.remove(invoice) }
}
/** True when [invoice] was sent to the wallet and never resolved. */
fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed }
/** Test-only reset. */
internal fun clear() {
synchronized(claimed) { claimed.clear() }
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
/**
* Bounds how often one user action may raise a BUD-07 payment prompt.
*
* The mirror flow retries a target after paying it, and that retry catches every
* exception — including a second `BlossomPaymentException`. Left unbounded, a
* server that pockets the preimage and answers 402 again drives an indefinite
* pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real
* payment, so "the user can always tap cancel" is not an adequate answer.
*
* Rule: a given (blob, server) pair may prompt at most once per user-initiated
* action. [beginUserAction] resets the ledger; everything downstream of that tap
* — including the post-payment retry — goes through [shouldPrompt].
*/
class PaymentPromptLedger {
private val prompted = mutableSetOf<String>()
/** The user tapped mirror/sync: a fresh budget of one prompt per target. */
fun beginUserAction() {
synchronized(prompted) { prompted.clear() }
}
/**
* True the first time this (blob, server) pair asks for payment in the current
* user action; false on every subsequent 402 from the same pair.
*/
fun shouldPrompt(
hash: String,
server: String,
): Boolean = synchronized(prompted) { prompted.add("$hash|$server") }
}

View File

@@ -68,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -105,7 +106,7 @@ fun BlossomBlobManagerScreen(
BlossomPaymentDialog(
host = pending.targetHost,
amountSats = pending.amountSats,
reason = pending.payment.reason,
reason = pending.payment.sanitizedReason(),
onConfirm = { vm.confirmPendingPayment() },
onDismiss = { vm.cancelPendingPayment() },
)
@@ -419,13 +420,21 @@ private fun BlossomPaymentDialog(
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
title = { Text(stringRes(R.string.blossom_payment_title)) },
text = {
Text(
text =
listOfNotNull(
stringRes(R.string.blossom_payment_message, host),
reason,
).joinToString("\n\n"),
)
Column {
Text(text = stringRes(R.string.blossom_payment_message, host))
// X-Reason is server-controlled: it is sanitized upstream and rendered
// here attributed to the server, in a dimmer italic, so it can never be
// mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat").
reason?.let {
Spacer(Modifier.size(12.dp))
Text(
text = stringRes(R.string.blossom_payment_server_says, host, it),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) {

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -129,6 +130,13 @@ class BlossomBlobManagerViewModel : ViewModel() {
private var resultCollectorStarted = false
/**
* Caps BUD-07 payment prompts at one per (blob, server) per user-initiated
* mirror, so a server that keeps replying 402 after being paid cannot drive an
* unbounded pay-prompt cycle. See [PaymentPromptLedger].
*/
private val promptLedger = PaymentPromptLedger()
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
// Reflect the app-level sync sweep's per-server results onto the pills, so an
@@ -299,8 +307,17 @@ class BlossomBlobManagerViewModel : ViewModel() {
}
}
/** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */
/**
* BUD-04: mirror a blob to every server that doesn't have it; each pill spins
* then turns green. This is the user-initiated entry point, so it resets the
* "already asked for payment" ledger — see [promptedForPayment].
*/
fun mirrorToMissing(row: BlobRow) {
promptLedger.beginUserAction()
mirrorMissingTargets(row)
}
private fun mirrorMissingTargets(row: BlobRow) {
val source = row.url ?: return
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
if (targets.isEmpty()) return
@@ -313,6 +330,14 @@ class BlossomBlobManagerViewModel : ViewModel() {
} catch (e: BlossomPaymentException) {
setServerState(row.hash, target, PresenceState.MISSING)
if (BlossomPaymentHandler.canPay(account, e.payment)) {
// Bounded: a server that pockets the preimage and replies 402
// again must not be able to spin up an endless pay-prompt
// cycle. One prompt per target per user-initiated mirror.
if (!promptLedger.shouldPrompt(row.hash, target)) {
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
continue
}
_pendingPayment.value =
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
return@launch
@@ -369,12 +394,31 @@ class BlossomBlobManagerViewModel : ViewModel() {
_pendingPayment.value = null
viewModelScope.launch(Dispatchers.IO) {
setServerState(pending.hash, pending.target, PresenceState.PENDING)
val proof = BlossomPaymentHandler.pay(account, pending.payment)
if (proof == null) {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
// pending.amountSats is exactly what the dialog showed; pay() re-derives
// the amount from the invoice and refuses if the two disagree or the
// amount is above the cap.
val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats)
val proof =
when (result) {
is BlossomPaymentHandler.PayResult.Paid -> result.proof
is BlossomPaymentHandler.PayResult.Refused -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = result.reason
return@launch
}
BlossomPaymentHandler.PayResult.TimedOut -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value =
"Your wallet did not confirm in time. If the payment does go through, retry the mirror — " +
"Amethyst will not send this invoice again."
return@launch
}
BlossomPaymentHandler.PayResult.Unavailable -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
}
try {
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
@@ -382,8 +426,9 @@ class BlossomBlobManagerViewModel : ViewModel() {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
}
// Continue with any remaining missing servers (which may prompt again).
currentRow(pending.hash)?.let { mirrorToMissing(it) }
// Continue with any remaining missing servers. Targets already prompted
// in this action (including this one) will NOT prompt again.
currentRow(pending.hash)?.let { mirrorMissingTargets(it) }
}
}

View File

@@ -88,40 +88,13 @@ fun ConcordInviteCard(
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
ConcordInvitePreviewRow(
robotSeed = robotSeed,
title = title,
subtitle = stringRes(R.string.concord_invite_card_subtitle),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.concord_invite_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SymbolIcon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.concord_invite_card_join),
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
}
}
}
/**
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
* the deep-link consent screen, so both render an invite identically. Purely
* presentational — it performs no I/O, which is what lets the deep-link screen show a
* preview without contacting the link's (attacker-supplied) relays before the user
* consents.
*/
@Composable
fun ConcordInvitePreviewRow(
robotSeed: String,
title: String,
subtitle: String,
accountViewModel: AccountViewModel,
autoPlayGif: Boolean,
trailing: @Composable () -> Unit = {},
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
trailing()
}
}

View File

@@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
@@ -93,7 +93,7 @@ private fun RelayGroupCardContent(
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
// Recompose in place when the relay-signed metadata / roster changes.
val channelState by baseChannel

View File

@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -153,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
@@ -334,6 +336,10 @@ fun AppNavigation(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
// Move every embedded app to the new account on a switch. Mounted before the layer and
// the preloader so the previous account's sessions are dropped ahead of the first sweep
// (an embed WebView's storage profile is fixed at construction, so it must be rebuilt).
EmbeddedTabAccountWatcher()
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
@@ -578,6 +584,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }

View File

@@ -507,6 +507,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ShareNoteAsQr(
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()

View File

@@ -100,7 +100,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.abs
/** Zoom the map animates to after a search hit or a "use my location" tap. */
private const val RECENTER_ZOOM = 14.0
@@ -125,15 +124,16 @@ private fun zoomForGeohashLength(length: Int): Double =
else -> 17.5
}
/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */
private const val WORLD_CENTER_LAT = 20.0
private const val WORLD_CENTER_LON = 0.0
/**
* Minimum center shift (degrees) from the opening center that counts as a real pan,
* so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick.
* Neutral starting center when the picker opens with no seed: a genuinely unnamed
* point in the mid-Atlantic, framing the Americas, Europe and Africa at [WORLD_ZOOM].
*
* Deliberately NOT 20N/0E — that is inland Mali (it reverse-geocodes to Tessalit) and
* sits exactly on the prime meridian, where a sub-kilometre pan flips the geohash
* between the `e…` and `s…` halves of the world and looks like a broken readout.
*/
private const val SELECT_MOVE_EPS = 0.0005
private const val WORLD_CENTER_LAT = 20.0
private const val WORLD_CENTER_LON = -30.0
/** Give up waiting for a GPS fix after this long so the button never spins forever. */
private const val GPS_FIX_TIMEOUT_MS = 20_000L
@@ -201,15 +201,21 @@ fun GeohashLocationPickerContent(
val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
val seedLen = initialGeohash?.trim()?.length ?: 0
// The map opens centered here. Without a seed there is no real selection yet — and
// osmdroid can emit an initial scroll at this exact center, which must NOT be treated
// as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm).
// The map opens centered here. Without a seed there is no real selection yet.
val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT
val initialLon = seed?.centerLon ?: WORLD_CENTER_LON
var pickedLat by remember { mutableStateOf(seed?.centerLat) }
var pickedLon by remember { mutableStateOf(seed?.centerLon) }
var hasSelection by remember { mutableStateOf(seed != null) }
// osmdroid emits a scroll event when the MapView is first laid out, reporting a
// pixel-quantized version of the opening center — at world zoom a single pixel is
// ~0.4 degrees, so that phantom "pan" can be hundreds of km away from where we asked
// it to open. Treating it as a pick auto-selected whatever the default center was and
// enabled Confirm with nothing chosen. Only map movement that follows a real finger
// down on the map counts, so an automatic scroll can never become a selection.
var mapTouched by remember { mutableStateOf(false) }
var level by remember {
mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY)
}
@@ -337,8 +343,8 @@ fun GeohashLocationPickerContent(
Column(modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().weight(1f)) {
LocationPickerMap(
latitude = seed?.centerLat ?: 20.0,
longitude = seed?.centerLon ?: 0.0,
latitude = initialLat,
longitude = initialLon,
pickedLatitude = null,
pickedLongitude = null,
zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM,
@@ -347,11 +353,12 @@ fun GeohashLocationPickerContent(
zoomTo = zoomTo,
highlight = highlight,
highlightColor = highlightColor,
onUserInteraction = { mapTouched = true },
onCenterChanged = { lat, lon ->
pickedLat = lat
pickedLon = lon
// A pan/zoom away from the opening center is the user's first real pick.
if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) {
// Only a movement the user drove counts as their first real pick.
if (mapTouched) {
pickedLat = lat
pickedLon = lon
hasSelection = true
}
},
@@ -687,13 +694,24 @@ private fun PickerBottomBar(
}
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
LoadCityName(geohashStr = settledCell ?: cell) { cityName ->
Text(
cityName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
// The place name must never contradict the geohash under it. Resolve
// it only for the settled cell, and only while that IS the current
// cell — mid-pan the debounced [settledCell] still names the previous
// cell, and drawing it beside a fresh geohash is worse than no name.
// LoadCityName echoes the geohash back when it cannot resolve a name
// (no geocoder backend, or a point at sea); drop that too rather than
// repeat the geohash as if it were a place.
if (settledCell == cell) {
LoadCityName(geohashStr = cell) { cityName ->
if (cityName != cell) {
Text(
cityName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
Text(
"#$cell",

View File

@@ -65,6 +65,10 @@ import org.osmdroid.views.overlay.Polygon
* - [recenter] animates the map to a new point when its value changes (e.g. after
* a place search or a "use my location" tap). Passing the same value twice is a
* no-op, so it is safe to hoist in state.
* - [onUserInteraction] fires when a finger first lands on the map. [onCenterChanged]
* alone cannot tell a user pan from osmdroid's own layout-time scroll (which the
* MapView emits at the opening center with pixel-quantized coordinates), so callers
* that must not treat an automatic scroll as a choice gate on this instead.
*/
@Composable
fun LocationPickerMap(
@@ -80,12 +84,14 @@ fun LocationPickerMap(
highlight: BoundingBox? = null,
highlightColor: Int = 0,
onCenterChanged: ((Double, Double) -> Unit)? = null,
onUserInteraction: (() -> Unit)? = null,
onPick: (Double, Double) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPick by rememberUpdatedState(onPick)
val currentOnCenterChanged by rememberUpdatedState(onCenterChanged)
val currentOnUserInteraction by rememberUpdatedState(onUserInteraction)
val darkTheme = !MaterialTheme.colorScheme.isLight
// Tracks the last point we animated to, so a recomposition that re-supplies the
@@ -117,7 +123,10 @@ fun LocationPickerMap(
// LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap.
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_DOWN -> {
view.parent?.requestDisallowInterceptTouchEvent(true)
currentOnUserInteraction?.invoke()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false)
}
false

View File

@@ -110,12 +110,16 @@ class UserSuggestionState(
.map(::userSearchTermOrNull)
.map { prefix ->
if (prefix != null) {
// NIP-05 resolution: user@domain or bare .bit domain
// NIP-05 resolution: full `name@domain` form, or bare
// `.bit` domain synthesised as the wildcard `_@domain`.
// Bare DNS domains aren't accepted here on purpose: a
// `.com`/`.io`/etc. that happens to host nostr.json is
// ambiguous with a regular URL the user might be typing.
val nip05 =
if (prefix.contains('@')) {
if (prefix.endsWith(".bit", ignoreCase = true) && !prefix.contains('@')) {
Nip05Id.parseLenient(prefix)
} else if (prefix.contains('@')) {
Nip05Id.parse(prefix)
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
Nip05Id("_", prefix.lowercase())
} else {
null
}
@@ -231,7 +235,7 @@ class UserSuggestionState(
item: User,
): TextFieldValue {
val lastWordStart = message.selection.end - word.length
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
return TextFieldValue(
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
@@ -244,11 +248,43 @@ class UserSuggestionState(
word: String,
item: User,
) {
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
}
}
/**
* The token to insert into the message text when the author picks [item]
* from the suggestion popover. When the author was typing a NIP-05
* mention (full `m@testls.bit` or bare-domain `.bit` form), we insert
* `nostr:nprofile1…` directly so the send-time tagger doesn't need to
* re-resolve anything — it parses the bech32 inline via its existing
* `nprofile1` branch, with no main-thread I/O. For every other path
* (search by name, typed npub/nprofile, hex) we keep the existing
* `@npub1…` form to preserve current behaviour.
*
* Pre-resolved NIP-05 hits already have their relay hints pushed into
* the account cache by [nip05ResolutionFlow] before this runs, so
* [User.toNProfile] picks them up automatically.
*/
private fun mentionInsertion(
word: String,
item: User,
): String {
val typed = userSearchTermOrNull(word)
val wasNip05Mention =
typed != null &&
(
(typed.endsWith(".bit", ignoreCase = true) && !typed.contains('@')) ||
(typed.contains('@') && Nip05Id.parse(typed) != null)
)
return if (wasNip05Mention) {
"nostr:${item.toNProfile()} "
} else {
"@${item.pubkeyNpub()} "
}
}
}

View File

@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
* both from the reaction-row Share button and from the note's 3-dot menu).
*
* Only the true "send it somewhere" options live here — browser link, image
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
* they are intentionally NOT part of this shared element.
* file, image URL, and the display-only QR code. The copy-to-clipboard
* options stay in the 3-dot menu, so they are intentionally NOT part of this
* shared element.
*
* Callers only render these for non-private notes: every option exposes the
* note publicly (a shareable web link, or an image of it), which must never
@@ -76,4 +77,8 @@ fun ShareActionRows(
nav.nav(Route.ShareNoteAsImage(shareId))
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
nav.nav(Route.ShareNoteAsQr(shareId))
onDismiss()
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.share
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
/** Which of the two payloads the QR code currently encodes. */
enum class QrPayloadMode {
/**
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
* open an http(s) URL but may treat a bare custom scheme as inert text.
*/
Web,
/**
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
* in-app scanners expect.
*/
Nostr,
}
/**
* The string encoded into the QR code for [note] in [mode].
*
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
*/
fun qrPayloadFor(
note: Note,
mode: QrPayloadMode,
): String =
when (mode) {
QrPayloadMode.Web -> externalLinkForNote(note)
QrPayloadMode.Nostr -> note.toNostrUri()
}

View File

@@ -0,0 +1,258 @@
/*
* 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.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.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
// 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
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
private val QrMaxSize = 320.dp
/**
* Display-only screen presenting [id]'s note as a scannable QR code.
*
* There is no export or save action by design — the screen exists to be held up and
* photographed by another device.
*
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
* null check, so an id that never resolves to a note still leaves the user a way back — only
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
LoadNote(id, accountViewModel) { note ->
if (note != null) {
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareNoteAsQrScreenContent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
pad: PaddingValues,
) {
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
// does not change while the event and the author's relay list are still loading). A payload
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
// SharedNoteCard uses for its own sensitivity gate (F1).
val noteState by observeNote(note, accountViewModel)
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
KeepScreenBrightAndAwake()
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
// short viewport (landscape, split screen, large font scale) where the square QR plus the
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
Column(
modifier =
Modifier
.padding(pad)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SharedNoteCard(note, accountViewModel, nav)
val qrContentDescription =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
}
QrCodeDrawer(
contents = payload,
modifier =
Modifier
.widthIn(max = QrMaxSize)
.fillMaxWidth()
.semantics { contentDescription = qrContentDescription },
)
// Fill the width so each button gets an equal, generous half (a bare
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
// empty `icon` so the selected-state checkmark never steals horizontal room from the
// label — selection is already signalled by the fill colour. Both matter for translated
// labels, which are often longer than the English "Web link" / "Nostr link".
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
modes.forEachIndexed { index, candidate ->
SegmentedButton(
selected = mode == candidate,
onClick = { mode = candidate },
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
icon = {},
) {
Text(
text =
when (candidate) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Text(
text =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
* restoring both on exit.
*
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
* on auto-brightness in a dim room is exactly the case that fails.
*
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}

View File

@@ -0,0 +1,320 @@
/*
* 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.share
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.alt
import com.vitorpamplona.quartz.nip71Video.blurhash
import com.vitorpamplona.quartz.nip71Video.mimeType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
private val CardHeight = 72.dp
private val ThumbSize = 56.dp
private val ThumbShape = RoundedCornerShape(9.dp)
/**
* A fixed-height preview of the note being shared, shown above the QR code.
*
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
* natural aspect ratio with no height ceiling.
*/
@Composable
fun SharedNoteCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
// is always null and that inner gate is permanently dead on this path. A note whose only
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
// with an empty reason string (`["content-warning"]` with no second element) still counts
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
// is still called for the human-readable *reason text*, shown in the covered box's
// accessibility label when one exists — it never drives the show/hide decision.
//
// `note.event` is a plain field read that never recomposes if this composable is
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
// straight off a deep link). observeNote() subscribes both the relay finder and the
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
// (GalleryThumb.kt:78).
//
// This deliberately does NOT use the shared ContentWarningGate: its overlay
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
// composable was rejected — six other screens depend on its current layout — so this
// call site renders its own compact, permanently-covered state instead: no reveal
// affordance, because this screen is held up in public and pointed at someone else's
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
//
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
// site. This card is not tappable.
val noteState by observeNote(note, accountViewModel)
val event = noteState.note.event
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
val hasImetaContentWarning =
remember(event) {
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
}
val isSensitive =
remember(event, hasImetaContentWarning) {
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
}
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
val isGated = isSensitive && showSensitiveContent != true
// Thumbnail source, in priority order:
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
// image post — content is typically just the media URL) -> render that image;
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
// note's own picture (F7).
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
if (isGated) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription =
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (hasStructuredMedia) {
GalleryThumbnail(note, accountViewModel, nav)
} else if (contentImage != null) {
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
// isGated branch above already covered the sensitive case.
UrlImageView(contentImage, accountViewModel)
} else {
NoteAuthorPicture(note, ThumbSize, accountViewModel)
}
}
Column(Modifier.weight(1f)) {
Text(
text = note.author?.toBestDisplayName() ?: "",
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = secondaryLineFor(event, isGated, contentImage != null),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
*/
private fun hasStructuredMedia(event: Event): Boolean =
when (event) {
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
is PictureEvent -> event.imetaTags().isNotEmpty()
is VideoEvent -> event.imetaTags().isNotEmpty()
is LiveActivitiesClipEvent -> event.videoUrl() != null
else -> false
}
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
internal fun IMetaTag.isImage(): Boolean {
val mime = mimeType()?.firstOrNull()
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
}
/**
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
* rather than feeding a video URL to the image loader.
*/
internal fun firstContentImage(event: Event): MediaUrlImage? {
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
return MediaUrlImage(
url = imeta.url,
description = imeta.alt()?.firstOrNull(),
blurhash = imeta.blurhash()?.firstOrNull(),
mimeType = imeta.mimeType()?.firstOrNull(),
)
}
/**
* The one-or-two-line description under the author name: an article's title, else the note's own
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
*
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
* either way; only this line is affected.
*
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
* "Picture" label rather than a blank line.
*/
@Composable
private fun secondaryLineFor(
event: Event?,
isGated: Boolean,
hasContentImage: Boolean,
): String {
if (event == null) return ""
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
// recomposition — only when the underlying event or the gate decision actually changes.
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
if (bodyText != null) return bodyText
return when {
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
event is VideoEvent -> stringRes(R.string.kind_video)
event is LongTextNoteEvent -> stringRes(R.string.article)
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
else -> ""
}
}
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
internal fun secondaryBodyTextFor(
event: Event,
isGated: Boolean,
): String? {
// Gated: never surface the note's own title, content or alt text, only the kind label above.
if (isGated) return null
if (event is LongTextNoteEvent) {
val title = event.title()
if (!title.isNullOrBlank()) return title
}
// An image-only post's content is typically just the media URL(s). Strip any that appear
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
// fall through to the alt text, then the kind label.
val mediaUrls = event.imetas().map { it.url }
val stripped =
if (mediaUrls.isEmpty()) {
event.content
} else {
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
}
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
if (content.isNotEmpty()) return content
return event
.imetas()
.firstOrNull { it.isImage() }
?.alt()
?.firstOrNull()
?.takeIf { it.isNotBlank() }
}
private const val MAX_SECONDARY_LINE_CHARS = 280

View File

@@ -95,8 +95,17 @@ fun RenderPodcastEpisode(
val value = remember(noteEvent) { episode.episodeValue() }
var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) }
// Suppress the markdown block if blank — title + description already describe a short
// episode. Otherwise hand off to RichText below.
val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } }
// episode — and ALSO when it merely repeats the description. Most feeds put the same text in
// both the `description` tag and the event content, and the thread view renders both blocks
// (`makeItShort` is false there), so the whole description appeared twice inside one card,
// each copy with its own "Show More". Compared on collapsed whitespace so a copy differing
// only in wrapping still counts as a duplicate.
val markdown =
remember(noteEvent, description) {
noteEvent.content.ifBlank { null }?.takeUnless { body ->
description?.let { normalizeForCompare(body) == normalizeForCompare(it) } == true
}
}
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
@@ -230,3 +239,8 @@ fun RenderPodcastEpisode(
}
}
}
/** Collapses whitespace so two copies of the same text that differ only in wrapping compare equal. */
private fun normalizeForCompare(text: String): String = text.trim().replace(WHITESPACE_RUN, " ")
private val WHITESPACE_RUN = Regex("\\s+")

View File

@@ -379,6 +379,14 @@ class AccountSessionManager(
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
return@launch
}
// TODO: also drop this account's WebView storage profile — the cookies/localStorage of every
// site it visited survive here, keyed by NappletWebViewProfiles.forPubKey(hex). It CANNOT be
// done from this process: WebView profiles live in the WebView data directory, which belongs
// to the `:napplet` process (nothing calls setDataDirectorySuffix, so booting WebView here
// too would collide on the same directory). Deleting it needs a broker message that has
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
// the sandbox on account deletion.
if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't

View File

@@ -632,6 +632,24 @@ class AccountViewModel(
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
}
/**
* Set [member]'s CORD-04 roles in [communityId] to exactly [roleIds] (empty revokes
* everything). The Control Plane grant REPLACES the member's role set rather than
* merging into it, so [roleIds] must be the *complete* list the member should end up
* holding — the caller (the Members roster dialog) preselects their current roles for
* that reason. Authority is re-checked at fold time by every client, so the caller must
* also have offered only roles it strictly outranks on a member it strictly outranks.
*/
fun setConcordRoles(
communityId: String,
member: HexKey,
roleIds: List<String>,
) = launchSigner {
if (!account.grantConcordRole(communityId, member, roleIds)) {
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
}
}
/** Ban/unban [member] from [communityId] (from the Members roster). */
fun setConcordBan(
communityId: String,
@@ -1580,6 +1598,15 @@ class AccountViewModel(
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
/**
* Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the
* signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the
* new list event is best-effort published to our outbox. Nothing here waits on a relay, which is
* 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 createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
@@ -2380,7 +2407,18 @@ class AccountViewModel(
if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) {
try {
val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context)
val meltResult =
MeltProcessor().melt(
token,
lud16,
httpClientBuilder::okHttpClientForMoney,
context,
// Mints the user deliberately added are exempt from the
// private-address block (self-hosted LAN mints are legit).
knownWalletMints =
account.cashuWalletState.mints.value
.toSet(),
)
onDone(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(

View File

@@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.Article
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
@@ -133,7 +132,10 @@ private fun PreloadFor(
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Joined groups' state + recent-chat previews are kept live app-wide by the always-on
// RelayGroupJoinedStatePreload + RelayGroupJoinedChatTailPreload (mounted at LoggedInPage), so pinning
// this tab needs no extra preload.
NavBarItem.RELAY_GROUPS -> Unit
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)

View File

@@ -52,6 +52,8 @@ import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailPreload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStatePreload
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -95,6 +97,12 @@ fun LoggedInPage(
// their channels/metadata/icon appear, without waiting for a Concord screen to be opened.
ConcordChannelPreload(accountViewModel)
// Keeps joined NIP-29 groups' relay-signed state (metadata/roster/roles/pins) always current, and
// their recent chat live for Messages-list previews — app-wide, so opening a group lands on cached
// content. See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
RelayGroupJoinedStatePreload(accountViewModel)
RelayGroupJoinedChatTailPreload(accountViewModel)
// Foreground-only loaders: follows-outbox finder + random-relay notifications.
// Pauses on ON_STOP, resumes on ON_START.
AccountForegroundFilterAssemblerSubscription(accountViewModel)

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
@@ -73,6 +74,13 @@ class EmbeddedWebAppController(
private var sandboxedSdkView: SandboxedSdkView? = null
private var pendingAdapter: SandboxedUiAdapter? = null
/**
* True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can
* only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the
* sandbox destroys its WebView, so the adapter is dead. See [attachView] for why this matters.
*/
private var adapterDelivered = false
private var startUrl: String = "about:blank"
private var hasLoadedReal = false
@@ -92,7 +100,9 @@ class EmbeddedWebAppController(
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
/** Invoked on the main thread when the page navigates: (url, canGoBack). */
var onUrlChanged: ((String, Boolean) -> Unit)? = null
@@ -132,6 +142,7 @@ class EmbeddedWebAppController(
serviceMessenger = null
sandboxedSdkView = null
pendingAdapter = null
adapterDelivered = false
onUrlChanged = null
onImeEvent = null
onMagnifierFrame = null
@@ -141,15 +152,48 @@ class EmbeddedWebAppController(
override fun teardown() = unbind()
/** Hands the surface view to the controller; applies the adapter if it already arrived. */
/**
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
* remote session when this controller is being re-used by a *second* view.
*
* A warm controller outlives the composition (it lives in the process-scoped [EmbeddedTabHost]), but its
* [SandboxedSdkView] does not: an account switch rebuilds the whole logged-in subtree, disposing every
* surface. That disposal makes privacysandbox close the remote session, which destroys the sandbox's
* WebView — so the adapter this controller already handed out is dead and cannot be given to the fresh
* view. A [SandboxedSdkView] with no adapter never builds a ContentView/SurfaceView and paints nothing
* but its background colour, forever (the load overlay's retry can't help — it re-navigates a WebView
* that no longer exists).
*
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
* session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms this view. The sandbox stamps the
* CURRENT account's storage profile on that new session (see [sendCreateSession]), so re-arming can
* never resurrect the previous account's cookie jar.
*/
override fun attachView(view: SandboxedSdkView) {
sandboxedSdkView = view
// Paint the surface placeholder in the app's theme background so there's no white flash before
// the remote WebView delivers its first frame.
view.setBackgroundColor(backgroundColor)
pendingAdapter?.let {
view.setAdapter(it)
pendingAdapter = null
val adapter = pendingAdapter
when {
adapter != null -> {
pendingAdapter = null
adapterDelivered = true
view.setAdapter(adapter)
}
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
// behind it is gone, so this view would stay blank forever. Re-create it.
adapterDelivered -> {
// Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox
// asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s
// AFTER this create: reusing the id let that late close reap the session we had just asked
// for — a new WebView was built, destroyed, and the surface stayed black. A new id makes
// the stale close target only the corpse it belongs to.
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
adapterDelivered = false
sendCreateSession()
}
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
}
}
@@ -165,6 +209,9 @@ class EmbeddedWebAppController(
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
putString(NappletBrowserContract.KEY_THEME, themeType)
// Opaque per-account storage partition, so an embedded site can't carry one
// npub's session into another. Derived here (the sandbox never sees the pubkey).
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
runCatching { serviceMessenger?.send(msg) }
@@ -176,7 +223,12 @@ class EmbeddedWebAppController(
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
if (view != null) {
adapterDelivered = true
view.setAdapter(adapter)
} else {
pendingAdapter = adapter
}
}
NappletBrowserContract.MSG_URL_CHANGED -> {
val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()

View File

@@ -116,7 +116,7 @@ private fun EmbeddedWebAppTab(
// Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this
// re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction).
val controller =
remember(id, EmbeddedTabHost.themeEpoch) {
remember(id, EmbeddedTabHost.rebuildEpoch) {
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
}

View File

@@ -233,6 +233,24 @@ fun ChatFeedLoaded(
}
Column(modifier = itemModifier) {
// A day/subject header belongs ABOVE the message it introduces. `reverseLayout`
// flips the order of the lazy list's items, but NOT the content inside one item:
// this Column still lays out top-to-bottom, so the divisor must be composed
// before the bubble. Composing it after put the header below its own message —
// i.e. visually heading the NEXT (newer) message while showing this one's date,
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
NewDateOrSubjectDivisor(older, item)
// Per-relay paging markers for the gap toward the next-older message. Older items sit
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
// which means these belong before it, for the same reason the divisor does. Composed
// after the bubble they rendered in the gap toward the NEWER message, contradicting the
// bounds they are handed.
markersInGap?.invoke(
item.event?.createdAt,
older?.event?.createdAt,
)
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
@@ -246,19 +264,6 @@ fun ChatFeedLoaded(
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
// Per-relay paging markers belonging in the gap toward the next-older message. With the
// reverse layout this draws just above the message (the older side), so a relay's marker
// appears right below the oldest message it has reached and slides down as it pages.
markersInGap?.invoke(
item.event?.createdAt,
items.list
.getOrNull(index + 1)
?.event
?.createdAt,
)
}
}
}

View File

@@ -126,6 +126,41 @@ fun ConcordChannelListScreen(
var inviteLink by remember { mutableStateOf<String?>(null) }
var minting by remember { mutableStateOf(false) }
// Prefer the folded metadata name, then the stored community name from the list entry (always
// present from the join/create — this is what shows everywhere else). Fall back to the app name
// only if neither exists (should be unreachable), never as the normal "metadata hasn't folded
// yet" placeholder — that showed "Amy Debug".
val communityName =
state?.metadata?.name
?: session?.entry?.name?.ifBlank { null }
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
// Owner from the list entry, not the folded authority: a community whose relays are dead never
// folds a Control Plane, and that is exactly the case where leaving matters most.
val isOwner = session?.entry?.owner == account.signer.pubKey
// Read once here (it is @Composable) so the post-leave navigation can use it from a callback.
val canPop = nav.canPop()
var showLeave by remember { mutableStateOf(false) }
if (showLeave) {
ConcordLeaveDialog(
communityName = communityName,
isOwner = isOwner,
onDismiss = { showLeave = false },
onConfirm = {
showLeave = false
// Fire-and-forget: the list edit is local + a best-effort publish to our own outbox,
// so we never hold the user behind a spinner waiting on a relay that may be dead.
accountViewModel.leaveConcordCommunity(communityId)
// Don't strand the user on the server view of a community they just left. Popping is
// right when we were pushed here; when this community is a bottom-nav root there is
// nothing to pop, so restart the stack on the Concord hub.
if (canPop) nav.popBack() else nav.newStack(Route.Concords)
},
)
}
// 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.
val canManageChannels =
@@ -185,20 +220,10 @@ fun ConcordChannelListScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
// Prefer the folded metadata name, then the stored community name from the list
// entry (always present from the join/create — this is what shows everywhere else).
// Fall back to the app name only if neither exists (should be unreachable), never as
// the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug".
val title =
state?.metadata?.name
?: session?.entry?.name?.ifBlank { null }
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
Text(title, maxLines = 1)
},
title = { Text(communityName, maxLines = 1) },
navigationIcon = {
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
if (nav.canPop()) {
if (canPop) {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
@@ -236,6 +261,27 @@ fun ConcordChannelListScreen(
) {
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
// actions live behind the menu, never as a one-tap icon.
var menuOpen by remember { mutableStateOf(false) }
IconButton(onClick = { menuOpen = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_community),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
showLeave = true
},
)
}
},
)
},
@@ -472,6 +518,50 @@ private fun rememberConcordDisplayName(
return name
}
/**
* Confirms leaving a community. Deliberately explicit about the blast radius: leaving is a private
* edit of *this account's* kind-13302 list — nobody is told, no roster changes — but it also drops
* the entry that carries the community's keys, so history this account can no longer derive may be
* gone for good. The owner gets an extra line: the entry is the only place their owner salt lives,
* so leaving is what actually retires the community for them.
*/
@Composable
private fun ConcordLeaveDialog(
communityName: String,
isOwner: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_message, communityName))
if (isOwner) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_owner_warning),
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.leave),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,

View File

@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,13 +40,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.model.ConcordInviteResult
import com.vitorpamplona.amethyst.ui.components.ConcordInvitePreviewRow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
private sealed interface RedeemState {
/** Showing the local preview, waiting for the user to tap Join. Nothing has been sent. */
data object AwaitingConsent : RedeemState
data object Working : RedeemState
data class Done(
@@ -63,10 +73,25 @@ private sealed interface RedeemState {
}
/**
* Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
* On open it fetches + unlocks the bundle, joins the community, and forwards to its
* channel list. On failure it offers a retry, so a transient relay miss doesn't
* strand the user.
* Redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
*
* **This screen must never act before the user consents.** It is reachable from any
* `https://amethyst.social/invite/…` link on any web page, in any QR code, or in a
* push — i.e. from a URL the user may never have meant to open. Redeeming is a
* side-effecting act: it connects to up to three relay URLs *chosen by whoever minted
* the link* (disclosing the user's IP to them), publishes a Guestbook JOIN signed by
* the user's own identity to those relays, and writes the community into the user's
* private kind-13302 list. Doing that on arrival turned any link into a one-click
* deanonymize-and-enroll primitive, so the screen now opens on a local-only preview
* and only calls [com.vitorpamplona.amethyst.model.Account.joinConcordViaInvite] from
* the Join button.
*
* Everything shown before that tap comes from decoding the URL itself
* ([ConcordActions.parseInviteLink] — pure base64 + NIP-19, no I/O): the link's
* signer key and the bootstrap relays it would contact. The community's *name* lives
* inside the kind-33301 bundle, which only those relays can serve, so it is
* deliberately left unknown rather than fetched — fetching it is precisely the IP
* disclosure this screen exists to gate.
*/
@Composable
fun ConcordInviteScreen(
@@ -74,7 +99,19 @@ fun ConcordInviteScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var state by remember(link) { mutableStateOf<RedeemState>(RedeemState.Working) }
// Local decode only: base64 fragment + NIP-19 naddr. No relay is contacted here.
val parsed = remember(link) { ConcordActions.parseInviteLink(link) }
var state by
remember(link) {
mutableStateOf<RedeemState>(
if (parsed == null) {
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
} else {
RedeemState.AwaitingConsent
},
)
}
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
@@ -82,13 +119,15 @@ fun ConcordInviteScreen(
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
is ConcordInviteResult.Incompatible ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.Expired ->
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
is ConcordInviteResult.NotReachable ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
RedeemState.Failed(R.string.concord_invite_failed, canRetry = true)
}
}
}
@@ -96,8 +135,8 @@ fun ConcordInviteScreen(
LaunchedEffect(state) {
(state as? RedeemState.Done)?.let { done ->
// Replace this invite screen with the community, dropping it from the back stack. If it
// stayed, Back from the community would land on the auto-redeeming spinner, which would
// immediately re-join and forward here again — trapping the user in a Back→forward loop.
// stayed, Back from the community would land on a consent screen for a community the
// user has already joined — a dead end offering to re-do what just happened.
nav.popUpTo(Route.ConcordServer(done.communityId), Route.ConcordInvite::class)
}
}
@@ -108,10 +147,19 @@ fun ConcordInviteScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is RedeemState.AwaitingConsent ->
parsed?.let {
ConcordInviteConsent(
parsed = it,
accountViewModel = accountViewModel,
onJoin = { state = RedeemState.Working },
)
}
is RedeemState.Working -> {
CircularProgressIndicator()
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite),
stringRes(R.string.concord_redeeming_invite),
modifier = Modifier.padding(top = 16.dp),
textAlign = TextAlign.Center,
)
@@ -129,7 +177,7 @@ fun ConcordInviteScreen(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
Text(stringRes(R.string.retry))
}
}
}
@@ -138,3 +186,55 @@ fun ConcordInviteScreen(
}
}
}
/**
* The pre-consent preview. Renders only what the URL itself decodes to — the link
* signer (used as the avatar seed) and the bootstrap relays the join would contact —
* plus a plain-language statement of what tapping Join will do. It performs **no**
* network I/O: the community name would require fetching the bundle from those very
* relays, which is the IP disclosure the consent gate exists to prevent, so it shows
* an explicit "name unknown until you join" instead.
*/
@Composable
private fun ConcordInviteConsent(
parsed: ParsedInviteLink,
accountViewModel: AccountViewModel,
onJoin: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val relayList = remember(parsed) { parsed.fragment.relays.joinToString(", ") }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
ConcordInvitePreviewRow(
robotSeed = parsed.linkSignerPubKey,
title = stringRes(R.string.concord_invite_card_subtitle),
subtitle = stringRes(R.string.concord_invite_preview_unknown_name),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
)
}
Text(
stringRes(R.string.concord_invite_preview_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 20.dp),
)
if (relayList.isNotEmpty()) {
Text(
stringRes(R.string.concord_invite_preview_relays, relayList),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = onJoin,
modifier = Modifier.padding(top = 24.dp),
) {
Text(stringRes(R.string.concord_invite_card_join))
}
}

View File

@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -43,6 +46,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -124,12 +128,28 @@ fun ConcordMembersScreen(
.minByOrNull { r -> r.position }
?.name
?.takeIf { n -> n.isNotBlank() }
RosterEntry(it, ConcordMembership.of(authority, it), roleName)
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it))
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(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
// not *strictly* outrank every assigned role, so offering a role at or above our own
// position would publish an edition that every client then silently discards. The owner
// sits at rank 0 and no role may claim position 0, so this admits everything for them.
val assignableRoles =
remember(state, myPubKey) {
val authority = state?.authority ?: return@remember emptyList<AssignableRole>()
val myRank = authority.rank(myPubKey) ?: return@remember emptyList()
authority
.roles()
.filter { (_, role) -> myRank < role.position }
.map { (id, role) -> AssignableRole(id, role.name, role.position) }
.sortedBy { it.position }
}
Scaffold(
topBar = {
@@ -168,6 +188,19 @@ fun ConcordMembersScreen(
isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true),
viewerIsOwner = iAmOwner,
viewerCanBan = iCanBan,
// Ban/Remove are rank-gated the same way Roles… is. The owner short-circuits
// because canActOn begins at hasPermission, which is false while banned, and
// a rogue BAN holder can currently banlist the owner — see the note on
// Account.concordBanTarget.
canBanTarget =
iAmOwner ||
state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true,
viewerCanManageRoles = iCanManageRoles,
// canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not
// banned, the target isn't the owner (unremovable), and we strictly outrank
// them — which also rules out acting on ourselves (equal cannot act on equal).
canManageRolesOnTarget = state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.MANAGE_ROLES) == true,
assignableRoles = assignableRoles,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -185,6 +218,10 @@ private fun ConcordMemberRow(
isSelf: Boolean,
viewerIsOwner: Boolean,
viewerCanBan: Boolean,
canBanTarget: Boolean,
viewerCanManageRoles: Boolean,
canManageRolesOnTarget: Boolean,
assignableRoles: List<AssignableRole>,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -193,13 +230,38 @@ private fun ConcordMemberRow(
val isBanned = entry.membership == ConcordMembership.BANNED
val isAdmin = entry.membership == ConcordMembership.ADMIN
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders,
// never against the owner or yourself. A banned user only offers "unban".
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders that
// strictly outrank the target, never against the owner or yourself. A banned user only offers
// "unban" — and unban is rank-gated too, so whoever cannot ban you cannot lift your ban either.
val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf
val canBan = viewerCanBan && !isOwnerTarget && !isSelf
val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
val canRemove = viewerCanBan && !isOwnerTarget && !isSelf
val hasMenu = canToggleAdmin || canBan || canRemove
val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular
// member (or every defined role) is out of our reach — a grant we don't outrank
// publishes fine and is then dropped by every client's fold, so a silently no-op
// control would be worse than none. The owner's own row never offers it: the owner
// is unremovable and outranks everyone, so canManageRolesOnTarget is false there.
val rolesBlockedReason =
when {
!canManageRolesOnTarget -> stringRes(R.string.concord_members_roles_out_of_reach)
assignableRoles.isEmpty() -> stringRes(R.string.concord_members_roles_none_assignable)
else -> null
}
val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles
var editRoles by remember { mutableStateOf(false) }
if (editRoles) {
ConcordRolesDialog(
assignable = assignableRoles,
current = entry.roleIds,
onConfirm = { selected ->
accountViewModel.setConcordRoles(communityId, entry.pubkey, selected)
editRoles = false
},
onDismiss = { editRoles = false },
)
}
var confirmRemove by remember { mutableStateOf(false) }
if (confirmRemove) {
@@ -212,7 +274,7 @@ private fun ConcordMemberRow(
)
}
androidx.compose.foundation.layout.Row(
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -241,6 +303,27 @@ private fun ConcordMemberRow(
},
)
}
if (viewerCanManageRoles) {
DropdownMenuItem(
text = {
Column {
Text(stringRes(R.string.concord_members_roles))
rolesBlockedReason?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
enabled = rolesBlockedReason == null,
onClick = {
editRoles = true
expanded = false
},
)
}
if (canBan) {
DropdownMenuItem(
text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) },
@@ -298,6 +381,64 @@ private fun MemberBadge(
}
}
/**
* Multi-select over the roles the viewer may assign (CORD-04 role grant).
*
* A grant REPLACES the member's role set rather than merging into it, so the box starts
* checked on everything they already hold — otherwise saving would silently strip the
* roles that weren't re-checked. Every currently-held role is guaranteed to appear in
* [assignable]: the caller only opens this when it strictly outranks the member, and the
* member's rank is the *lowest* position they hold, so all of their roles sit strictly
* below us too. Like "Make admin", saving applies immediately — no extra confirmation.
*/
@Composable
private fun ConcordRolesDialog(
assignable: List<AssignableRole>,
current: Set<String>,
onConfirm: (List<String>) -> Unit,
onDismiss: () -> Unit,
) {
val selected = remember(current) { mutableStateListOf<String>().apply { addAll(current) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.concord_members_roles_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringRes(R.string.concord_members_roles_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
assignable.forEach { role ->
val checked = role.id in selected
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
if (checked) selected.remove(role.id) else selected.add(role.id)
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(checked = checked, onCheckedChange = null)
Text(role.name.ifBlank { role.id.take(8) }, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selected.toList()) }) {
Text(stringRes(R.string.concord_members_roles_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
@Composable
private fun ConcordRemoveMemberDialog(
@@ -324,6 +465,15 @@ private class RosterEntry(
val membership: ConcordMembership,
/** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */
val roleName: String?,
/** Every role id the member currently holds — the preselection for the role picker. */
val roleIds: Set<String>,
)
/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */
private class AssignableRole(
val id: String,
val name: String,
val position: Long,
)
/** Owner first, then admins, then plain members, then banned last. */

View File

@@ -61,7 +61,7 @@ fun ChannelFilterAssemblerSubscription(
}
// Relay groups: when the kind-39005 pin list changes, re-invalidate so the id-based back-fill
// for pinned message bodies (see filterMetadataToRelayGroup) picks up the new ids. Keyed on the
// for pinned message bodies (see filterRelayGroupState) picks up the new ids. Keyed on the
// pin list alone, so unrelated roster/metadata churn doesn't force a re-subscribe.
if (channel is RelayGroupChannel) {
val metadataState by channel

View File

@@ -43,7 +43,11 @@ class ChannelFromUserFilterSubAssembler(
is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since)
is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since)
is LiveActivitiesChannel -> filterMyMessagesToLiveActivities(channel, userHex(key), since)
is RelayGroupChannel -> filterMyMessagesToRelayGroup(channel, userHex(key), since)
// A relay group's timeline is served by the group content tail + history pager (both
// all-authors `#h` on the single host relay), which already return my own messages — so a
// dedicated `authors=[me]` reconciliation filter is redundant here. See
// amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
is RelayGroupChannel -> emptyList()
else -> null
}

View File

@@ -59,8 +59,11 @@ class ChannelPublicFilterSubAssembler(
}
is RelayGroupChannel -> {
filterMessagesToRelayGroup(channel, since) +
filterMetadataToRelayGroup(channel, since)
// Content (recent + older) is served by the group content tail + history pager; keep only
// the relay-signed metadata + pinned-id back-fill so an OPEN group — including a non-joined
// one not covered by the always-on joined-groups state sub — resolves its name/roster/pins.
// See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
filterRelayGroupState(channel, since)
}
else -> {

View File

@@ -1,55 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** Timeline kinds shown in a NIP-29 group: chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The message timeline for a NIP-29 group. A group's chat lives entirely on its
* host relay, scoped by the `h` tag, so the filter is pinned to
* [RelayGroupChannel.relays] (always the single host) and never fans out to the
* user's other relays.
*/
fun filterMessagesToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
limit = 200,
since = since?.get(it)?.time,
),
)
}

View File

@@ -1,57 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The current user's own messages in a NIP-29 group. A smaller companion to
* [filterMessagesToRelayGroup] that back-fills the user's sent messages (so
* optimistic sends reconcile) — pinned to the group's host relay and scoped by
* both the `h` group tag and the author.
*/
fun filterMyMessagesToRelayGroup(
channel: RelayGroupChannel,
pubKey: HexKey,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
authors = listOf(pubKey),
limit = 50,
since = since?.get(it)?.time,
),
)
}

View File

@@ -22,45 +22,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */
private val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/**
* The relay-signed metadata for a NIP-29 group (name/picture/about + admin,
* member and role lists), addressed by the group id (`d` tag) and pinned to the
* group's host relay. The relay signs these with its own key, so a single-relay
* query scoped by `#d` returns exactly this group's directory.
*
* The 39000-39003 metadata block and the 39005 pin list go out as **two separate filters**: relay29-family
* relays (0xchat's included) reject a filter that mixes them and drop the whole REQ, which would leave the
* group with no name, no roster and no membership. See
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS].
*/
fun filterMetadataToRelayGroup(
fun filterRelayGroupState(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = channel.relays().toSet()
val scope = mapOf("d" to listOf(channel.groupId.id))
val directory =
relays.map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
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)),
)
}

View File

@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
@@ -101,13 +102,13 @@ fun RelayGroupChannelListScreen(
// updates as directory events arrive with no polling. The initial value is sorted too
// so the first frame doesn't reshuffle when the first emission arrives.
val allChannels by produceState(
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() },
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() },
relay,
) {
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.collect {
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() }
}
}
@@ -185,9 +186,9 @@ private fun RelayGroupChannelRow(
// messages for its group (content only — the directory subscription already streams metadata),
// so opening the chat lands on cached content instead of a blank load. Bounded to visible rows
// by the LazyColumn, and released as they scroll off.
RelayGroupWarmupSubscription(
RelayGroupCardWarmupSubscription(
channel,
accountViewModel.dataSources().relayGroupWarmup,
accountViewModel.dataSources().relayGroupCardWarmup,
accountViewModel,
contentOnly = true,
contentLimit = CHANNEL_LIST_WARMUP_LIMIT,

View File

@@ -41,18 +41,39 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistorySubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@Composable
fun RelayGroupChannelView(
@@ -127,8 +148,31 @@ private fun ChannelView(
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
// Metadata/pins live-watch (roster comes from the always-on state sub; this only keeps the pinned-id
// back-fill firing when the pin list changes). The old fixed-window content path here is superseded
// by the live tail + history pager below.
ChannelFilterAssemblerSubscription(channel, accountViewModel.dataSources().channel, accountViewModel)
// Recent chat (live) + on-demand backward history, the group analog of the NIP-04 per-conversation
// stack. The tail also covers a non-joined group opened by link (not in the batched preview tail).
RelayGroupOpenChatTailSubscription(channel.groupId, accountViewModel.dataSources().relayGroupOpenChatTail, accountViewModel)
RelayGroupOpenChatHistorySubscription(channel.groupId, accountViewModel.dataSources().relayGroupOpenChatHistory, accountViewModel)
val history = remember(accountViewModel) { accountViewModel.dataSources().relayGroupOpenChatHistory.history }
val loadingHistory by history.loadingMore.collectAsStateWithLifecycle()
val historyStatus by history.status.collectAsStateWithLifecycle()
val limits =
remember(historyStatus) {
buildList {
if (!historyStatus.exhausted) {
historyStatus.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("nip29:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "Group") { history.advance(relay) })
}
}
}
}
RelayGroupBackfillHistoryToWindow(feedViewModel.feedState, history)
// Collect the metadata flow once for the whole screen: it drives both the pinned-message
// bar (kind-39005 pins) and the composer gating (roster membership), and updates the moment
// a pin lands or my join is accepted.
@@ -174,6 +218,36 @@ private fun ChannelView(
onWantsToEditDraft = newPostModel::editFromDraft,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
olderBoundary = {
DmHistoryLoadingCard(
"Group",
"Group",
loadingHistory,
historyStatus.exhausted,
historyStatus.relayCount,
historyStatus.stalledCount,
historyStatus.reachedBack,
historyStatus.relayProgress,
::formatHistoryReachDate,
)
},
// The host relay's window-limit marker at its reached cursor (pure UI). Hidden when exhausted.
markersInGap =
if (limits.isEmpty()) {
null
} else {
{ newer, older -> RelayReachMarkers(limits, newer, older) {} }
},
// Pulls the next page while its marker is on screen, off viewport visibility.
sentinels =
if (limits.isEmpty()) {
null
} else {
{ items, listState ->
RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt }
}
},
)
}
@@ -196,6 +270,53 @@ private fun ChannelView(
}
}
/** The number of messages a freshly-opened group eagerly backfills to before paging goes demand-driven. */
private const val RELAY_GROUP_HISTORY_TARGET = 50
/**
* On open, eagerly backfill this group's older history until the feed holds at least
* [RELAY_GROUP_HISTORY_TARGET] messages (or the host relay is exhausted) — mirroring the Concord channel
* backfill. The live tail only carries the recent window, so without this a group with plenty of history
* opens showing just its last few messages until the user scrolls. Once the target is reached, paging is
* purely demand-driven by the markers' visibility. A short startup delay skips the transient empty feed
* navigation flashes through.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun RelayGroupBackfillHistoryToWindow(
feedContentState: FeedContentState,
history: RelayGroupOpenChatHistorySubAssembler,
) {
LaunchedEffect(feedContentState, history) {
delay(1200L)
val loadedCount =
feedContentState.feedContent.flatMapLatest { state ->
when (state) {
is FeedState.Loaded -> state.feed.map { it.list.size }
else -> flowOf(0)
}
}
combine(loadedCount, history.loadingMore, history.status) { count, loading, status ->
count < RELAY_GROUP_HISTORY_TARGET && !loading && !status.exhausted
}.distinctUntilChanged()
.filter { it }
.collect { history.advanceAll() }
}
}
private fun reachState(p: RelayPagingProgress): RelayReachState =
when {
p.done -> RelayReachState.DONE
p.stalled -> RelayReachState.STALLED
else -> RelayReachState.REACHING
}
private fun relayShortName(relay: NormalizedRelayUrl): String =
relay.url
.removePrefix("wss://")
.removePrefix("ws://")
.removeSuffix("/")
/**
* Shown in place of the composer when I'm not (yet) a member: a relay group won't accept my kind-9
* chat until its roster lists me, so typing would only earn a silent relay rejection. Points me at

View File

@@ -79,8 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -119,9 +118,8 @@ fun RelayGroupDiscoveryScreen(
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel)
RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel)
// Keep the joined groups' metadata + rosters live so the "My Groups" filter can list them
// (their host relays aren't fetched by the discovery filter set).
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// The joined groups' metadata + rosters are kept live by the always-on state sub (mounted at
// LoggedInPage), so the "My Groups" filter can list them without a per-screen subscription.
DisappearingScaffold(
isInvertedLayout = false,
@@ -279,7 +277,7 @@ private fun RelayGroupDiscoveryRow(
// Prefetch the group's recent content so opening the card lands on a populated screen. The
// metadata/rosters are already streaming from the directory subscription, so only ask for
// content here (contentOnly) instead of re-requesting 39000-39003 per visible row.
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel, contentOnly = true)
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel, contentOnly = true)
val channelState by baseChannel
.flow()

View File

@@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -111,7 +111,7 @@ private fun RelayGroupMembers(
// standalone screen is open. observeChannel alone does NOT do this for a relay group
// (its finder only handles public-chat / live-activity channels), so without this the
// screen would only show whatever the chat screen happened to cache.
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
// Recompose when the relay-signed roster (39001/39002) changes.
val channelState by observeChannel(baseChannel, accountViewModel)

View File

@@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPicke
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
@@ -141,7 +141,7 @@ fun RelayGroupEditScreen(
LoadRelayGroupChannel(groupId, accountViewModel) { channel ->
// Keep the relay-signed metadata fresh while editing so a late load prefills.
RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
RelayGroupCardWarmupSubscription(channel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
val channelState by channel
.flow()

View File

@@ -65,12 +65,13 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -142,7 +143,7 @@ private fun ParentSelectorCard(
val liveParent: RelayGroupChannel? =
parentId?.let { id ->
val channel = remember(id, relay) { accountViewModel.checkGetOrCreateRelayGroupChannel(GroupId(id, relay)) }
RelayGroupWarmupSubscription(channel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
RelayGroupCardWarmupSubscription(channel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
val state by channel
.flow()
.metadata.stateFlow
@@ -467,8 +468,8 @@ private fun pickCandidates(
.asSequence()
.filter { it.groupId.id !in forbidden }
.filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) }
.sortedBy { it.toBestDisplayName().lowercase() }
.toList()
.sortedBySnapshot { it.toBestDisplayName().lowercase() }
/**
* The set of group ids reachable as descendants of [rootId] on [relay], following each group's

View File

@@ -30,7 +30,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -38,11 +40,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,18 +63,23 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
/**
* A group's forum-style threads (kind 11) — the secondary content type kept out of
* the kind-9 chat feed. Streams the group's threads + their comments via
* [RelayGroupThreadFeedSubscription]; tapping a thread opens the generic thread view
* [RelayGroupOpenThreadsSubscription]; tapping a thread opens the generic thread view
* ([Route.Note]) with its comment tree. Members can start a new thread.
*/
@Composable
@@ -93,9 +103,19 @@ private fun RelayGroupThreads(
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupThreadFeedSubscription(channel, accountViewModel.dataSources().relayGroupThreadFeed, accountViewModel)
// Recent live tail + on-demand backward history, the Threads analog of the chat stack. Without the
// pager a group with more threads than the relay's default result cap would silently hide the older ones.
RelayGroupOpenThreadsSubscription(channel, accountViewModel.dataSources().relayGroupOpenThreads, accountViewModel)
val historySource = accountViewModel.dataSources().relayGroupOpenThreadsHistory
RelayGroupOpenThreadsHistorySubscription(channel.groupId, historySource, accountViewModel)
val threads by channel.threads.collectAsStateWithLifecycle()
val history = remember(historySource) { historySource.history }
val loadingOlder by history.loadingMore.collectAsStateWithLifecycle()
val status by history.status.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
RelayGroupThreadsPaging(threadCount = { threads.size }, listState = listState, history = history)
// Only members can post a thread (the relay rejects a non-member's kind-11), so the
// compose FAB is hidden for everyone else.
@@ -156,18 +176,80 @@ private fun RelayGroupThreads(
)
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
LazyColumn(state = listState, modifier = Modifier.padding(padding)) {
itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
}
item(key = "threads-history-footer") {
RelayGroupThreadsHistoryFooter(loadingOlder, status.exhausted)
}
}
}
}
}
/** How many threads to eagerly backfill on open before paging goes demand-driven, and the scroll lead. */
private const val RELAY_GROUP_THREADS_TARGET = 30
private const val RELAY_GROUP_THREADS_PREFETCH_AHEAD = 5
/**
* Drives the Threads backward pager: eagerly backfill to a window on open (so a group with deep history
* doesn't show just its last few threads), then page older content demand-driven as the list nears its end.
* Mirrors the chat screen's `RelayGroupBackfillHistoryToWindow` + reach sentinels, on the plain thread list.
*/
@Composable
private fun RelayGroupThreadsPaging(
threadCount: () -> Int,
listState: LazyListState,
history: RelayGroupOpenThreadsHistorySubAssembler,
) {
LaunchedEffect(history) {
combine(snapshotFlow { threadCount() }, history.loadingMore, history.status) { count, loading, s ->
count < RELAY_GROUP_THREADS_TARGET && !loading && !s.exhausted
}.distinctUntilChanged()
.filter { it }
.collect { history.advanceAll() }
}
LaunchedEffect(history, listState) {
snapshotFlow {
val last =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: 0
val total = threadCount()
total > 0 && last >= total - RELAY_GROUP_THREADS_PREFETCH_AHEAD
}.distinctUntilChanged()
.filter { it }
.collect {
if (!history.status.value.exhausted && !history.loadingMore.value) history.advanceAll()
}
}
}
/** A quiet footer at the bottom of the thread list: what the pager is doing, or nothing when idle. */
@Composable
private fun RelayGroupThreadsHistoryFooter(
loadingOlder: Boolean,
exhausted: Boolean,
) {
val text =
when {
loadingOlder -> stringRes(R.string.relay_group_threads_loading_older)
exhausted -> stringRes(R.string.relay_group_threads_all_caught_up)
else -> return
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
}
@Composable
private fun ThreadRow(
thread: Note,

View File

@@ -22,21 +22,19 @@ 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.model.Account
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.filterMetadataToRelayGroup
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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One on-screen group card's request to warm a single group. */
class RelayGroupWarmupQueryState(
class RelayGroupCardWarmupQueryState(
val account: Account,
val channel: RelayGroupChannel,
/** When true, prefetch only recent content — the caller's screen already streams metadata. */
val contentOnly: Boolean = false,
@@ -44,15 +42,11 @@ class RelayGroupWarmupQueryState(
val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
)
/** Newest content kinds we prefetch so opening the card lands on populated screens. */
private val RELAY_GROUP_WARMUP_CONTENT_KINDS =
listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
/**
* Default number of recent events to pull ahead of a tap enough to fill the first screen AND drive
* the discovery card's "50+ messages" activity signal (a chat that returns the full page reads as
* "50+"; fewer shows the exact loaded count). Callers that only need a first-screen preview (e.g. a
* relay's channel list) pass a smaller [RelayGroupWarmupQueryState.contentLimit].
* relay's channel list) pass a smaller [RelayGroupCardWarmupQueryState.contentLimit].
*/
const val RELAY_GROUP_WARMUP_LIMIT = 50
@@ -63,12 +57,12 @@ const val RELAY_GROUP_WARMUP_LIMIT = 50
* messages and threads so tapping the card lands on already-cached content. Both are
* pinned to the group's single host relay. Active only while the card is on-screen.
*/
class RelayGroupWarmupFilterAssembler(
class RelayGroupCardWarmupFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupWarmupQueryState>() {
) : ComposeSubscriptionManager<RelayGroupCardWarmupQueryState>() {
val group =
listOf(
RelayGroupWarmupSubAssembler(client, ::allKeys),
RelayGroupCardWarmupSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -78,22 +72,30 @@ class RelayGroupWarmupFilterAssembler(
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupWarmupSubAssembler(
class RelayGroupCardWarmupSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupWarmupQueryState>,
) : PerUniqueIdEoseManager<RelayGroupWarmupQueryState, GroupId>(client, allKeys) {
allKeys: () -> Set<RelayGroupCardWarmupQueryState>,
) : PerUniqueIdEoseManager<RelayGroupCardWarmupQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupWarmupQueryState,
key: RelayGroupCardWarmupQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val groupId = key.channel.groupId
val metadata = if (key.contentOnly) emptyList() else filterMetadataToRelayGroup(key.channel, since)
// A joined group is already kept fully warm app-wide by the always-on state sub (metadata/roster)
// and preview tail (recent chat), and by the chat tail + history pager once opened. Warmup is for
// groups shown as cards that those don't cover — above all NON-joined groups (discovery, a relay's
// channel list, member/metadata/parent screens). So skip a group we've already joined to avoid
// re-fetching what's live everywhere. See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
if (isRelayGroupJoined(key.account.relayGroupList.liveRelayGroupList.value, groupId)) {
return emptyList()
}
val metadata = if (key.contentOnly) emptyList() else filterRelayGroupState(key.channel, since)
return metadata +
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS,
kinds = RELAY_GROUP_CARD_WARMUP_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
limit = key.contentLimit,
since = since?.get(groupId.relayUrl)?.time,
@@ -101,5 +103,5 @@ class RelayGroupWarmupSubAssembler(
)
}
override fun id(key: RelayGroupWarmupQueryState) = key.channel.groupId
override fun id(key: RelayGroupCardWarmupQueryState) = key.channel.groupId
}

View File

@@ -32,16 +32,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* subscription already streams that for the whole relay, so each visible row asks for content only.
*/
@Composable
fun RelayGroupWarmupSubscription(
fun RelayGroupCardWarmupSubscription(
channel: RelayGroupChannel,
dataSource: RelayGroupWarmupFilterAssembler,
dataSource: RelayGroupCardWarmupFilterAssembler,
accountViewModel: AccountViewModel,
contentOnly: Boolean = false,
contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
) {
val account = accountViewModel.account
val state =
remember(channel.groupId, contentOnly, contentLimit) {
RelayGroupWarmupQueryState(channel, contentOnly, contentLimit)
remember(account, channel.groupId, contentOnly, contentLimit) {
RelayGroupCardWarmupQueryState(account, channel, contentOnly, contentLimit)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)

View File

@@ -0,0 +1,96 @@
/*
* 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.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Always-on preload of the relay-signed **state** (metadata/roster/roles/pins) of every joined group,
* mounted once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage])
* — the NIP-29 analog of the always-on account/DM tail and [ConcordChannelPreload]. The query state is
* keyed on the account (stable), so we watch the joined-group list and re-derive on every join/leave.
*/
@Composable
fun RelayGroupJoinedStatePreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val dataSource = accountViewModel.dataSources().relayGroupJoinedState
val state = remember(account) { RelayGroupJoinedStateQueryState(account) }
val joined by account.relayGroupList.liveRelayGroupList.collectAsStateWithLifecycle()
LaunchedEffect(joined) { dataSource.invalidateFilters() }
KeyDataSourceSubscription(state, dataSource)
}
/**
* Always-on preview **live tail** for joined groups' recent chat, mounted alongside [RelayGroupJoinedStatePreload]
* — keeps the Messages-list previews reflecting the true newest message app-wide. Re-derives on join/leave.
*/
@Composable
fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val dataSource = accountViewModel.dataSources().relayGroupJoinedChatTail
val state = remember(account) { RelayGroupJoinedChatTailQueryState(account) }
val joined by account.relayGroupList.liveRelayGroupList.collectAsStateWithLifecycle()
LaunchedEffect(joined) { dataSource.invalidateFilters() }
KeyDataSourceSubscription(state, dataSource)
}
/**
* Mount on the open group chat screen to keep the *currently open* group's recent chat live — covers a
* non-joined group opened by link (the batched preview tail is joined-only) and live updates. Lifecycle-
* aware so it stops when the screen leaves.
*/
@Composable
fun RelayGroupOpenChatTailSubscription(
groupId: GroupId,
dataSource: RelayGroupOpenChatTailFilterAssembler,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val state = remember(account, groupId) { RelayGroupOpenChatTailQueryState(account, groupId) }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Mount on the open group chat screen to keep its backward-history pager bound and armed (older
* kind-9/poll by `until`+`limit` on the host relay), the NIP-29 analog of [ConcordChannelHistorySubscription].
*/
@Composable
fun RelayGroupOpenChatHistorySubscription(
groupId: GroupId,
dataSource: RelayGroupOpenChatHistoryFilterAssembler,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val state = remember(account, groupId) { RelayGroupOpenChatHistoryQueryState(account, groupId) }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}

View File

@@ -0,0 +1,264 @@
/*
* 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.relayGroup.datasource
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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/*
* Pure REQ-filter builders for the NIP-29 group-chat data sources. Kept separate from the assemblers so
* the exact filter each screen puts on the wire (kinds, #d/#h scope, per-relay batching, since/until/limit,
* authors) can be unit-tested without standing up an Account or relay client.
*
* See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan.
*/
/**
* The relay's **directory** kinds for a group — metadata + admins + members + roles (39000-39003).
* These four are what NIP-29 relays treat as a group's "metadata" block, and they must be requested
* **alone**: see [RELAY_GROUP_PIN_KINDS].
*/
val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/**
* The pin list (39005), deliberately kept in its **own** filter rather than merged into
* [RELAY_GROUP_METADATA_KINDS].
*
* NIP-29 relays derived from `relay29`/`khatru29` (0xchat's `groups.0xchat.com` among them) reject a REQ
* whose filter mixes the 39000-39003 metadata kinds with any other kind, replying
* `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`. A single filter asking for
* 39000-39003 **plus** 39005 is therefore dropped **whole** — the group never resolves its name, roster,
* roles or the user's own membership, so it renders as a raw id and offers "Join" to somebody the relay
* already lists as an admin.
*
* Splitting into two filter objects fixes it: those relays evaluate the rule per filter, so the
* metadata filter is served normally and the pins filter is served (or harmlessly ignored) on its own.
*/
val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND)
/**
* Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable
* events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and
* [RELAY_GROUP_PIN_KINDS] as separate filters instead (see [RELAY_GROUP_PIN_KINDS]). Kept as the
* semantic "all state kinds" set for cache/consume-side code.
*/
val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
/** Timeline kinds shown in a group's chat — chat messages and polls. */
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
/** Content kinds a card warms ahead of a tap (chat + polls + threads + comments). */
val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
/**
* A relay's whole-directory kinds — metadata + admins + members + roles (39000-39003), **no pins**.
* Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each
* group's pin list.
*/
val RELAY_GROUP_DIRECTORY_KINDS = RELAY_GROUP_METADATA_KINDS
/** How many directory entries to pull per relay when browsing its whole group list. */
const val RELAY_GROUP_DIRECTORY_LIMIT = 500
/** `d`-tag key of the relay-signed state events (39xxx are addressable by the group id). */
private const val D_TAG = "d"
private fun byHostRelay(joined: Collection<GroupTag>): Map<NormalizedRelayUrl, List<String>> {
val out = LinkedHashMap<NormalizedRelayUrl, MutableList<String>>()
joined.forEach { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@forEach
out.getOrPut(relay) { mutableListOf() }.add(tag.groupId)
}
return out
}
/**
* State (39000-39005) for every joined group, **two `#d` filters per host relay** carrying that relay's
* group ids: the 39000-39003 metadata block and the 39005 pin list, kept apart because relay29-family
* relays refuse a filter that mixes them (see [RELAY_GROUP_PIN_KINDS]). `since` is per-relay (replaceable
* events; a reconnect just re-confirms).
*/
fun buildRelayGroupStateFilters(
joined: Collection<GroupTag>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> =
byHostRelay(joined).flatMap { (relay, ids) ->
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)),
)
}
/**
* Recent chat of every joined group, **one `#h` filter per host relay** carrying that relay's group ids,
* bounded by a shared time floor ([sinceEpoch]) and **no per-group `limit`** — this is what lets the whole
* relay's groups batch into a single REQ and makes it reconnect-safe.
*/
fun buildRelayGroupJoinedChatTailFilters(
joined: Collection<GroupTag>,
sinceEpoch: Long,
): List<RelayBasedFilter> =
byHostRelay(joined).map { (relay, ids) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
)
}
/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */
fun buildRelayGroupOpenChatTailFilter(
groupId: GroupId,
sinceEpoch: Long,
): RelayBasedFilter =
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
)
/**
* Backward-history page(s) for a single open group: one `#h` filter per **armed** relay at its own
* `until`, capped by [limit], **all authors** (so it also re-materializes the user's own history). A
* relay with no requested `until` contributes nothing (it is parked).
*/
fun buildRelayGroupHistoryFilters(
groupId: GroupId,
armedRelays: Collection<NormalizedRelayUrl>,
untilForRelay: (NormalizedRelayUrl) -> Long?,
limit: Int,
): List<RelayBasedFilter> =
armedRelays.mapNotNull { relay ->
val until = untilForRelay(relay) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,
),
)
}
/**
* The whole group directory a single [relay] hosts: kinds 39000-39003, unscoped by `d`/`h` (every group
* the relay signs), capped at [RELAY_GROUP_DIRECTORY_LIMIT]. Backs the "browse a relay's channels" screen.
*/
fun buildRelayGroupDirectoryFilter(
relay: NormalizedRelayUrl,
sinceEpoch: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = RELAY_GROUP_DIRECTORY_LIMIT,
since = sinceEpoch,
),
)
/**
* Backward-history page(s) for a group's **Threads** tab: one `#h` filter per **armed** relay at its own
* `until`, capped by [limit], over the thread kinds (11/1111). The forum analog of
* [buildRelayGroupHistoryFilters]; a parked relay (no requested `until`) contributes nothing.
*/
fun buildRelayGroupThreadsHistoryFilters(
groupId: GroupId,
armedRelays: Collection<NormalizedRelayUrl>,
untilForRelay: (NormalizedRelayUrl) -> Long?,
limit: Int,
): List<RelayBasedFilter> =
armedRelays.mapNotNull { relay ->
val until = untilForRelay(relay) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_THREAD_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,
),
)
}
/** The Threads-tab feed for a single open group: kind-11/1111 `#h`-scoped on the host relay. */
fun buildRelayGroupThreadsFilter(
groupId: GroupId,
sinceEpoch: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_THREAD_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
)
/**
* Whether [groupId] is in the user's joined set — a joined group is kept warm app-wide by the always-on
* state + chat-tail subs, so the on-screen [RelayGroupCardWarmupFilterAssembler] must skip it.
*/
fun isRelayGroupJoined(
joined: Collection<GroupTag>,
groupId: GroupId,
): Boolean =
joined.any {
it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl
}

View File

@@ -0,0 +1,123 @@
/*
* 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.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.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.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
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.Job
import kotlinx.coroutines.flow.StateFlow
/** One screen's request to keep the user's joined groups' recent chat live. */
class RelayGroupJoinedChatTailQueryState(
val account: Account,
)
/**
* Always-on **live tail** for the recent chat of every NIP-29 group the user has joined — the group
* analog of the NIP-04 rooms-list live tail
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListNip04SubAssembler]).
*
* One `#h`-scoped filter **per host relay** carries every joined group id on that relay at once,
* `since = recentBoundary()` and **no per-group limit** — a time floor bounds it, so unlike the fixed
* `limit=50` window it can batch all of a relay's groups into a single REQ. This keeps the Messages-list
* previews reflecting the true newest message and joined groups' recent chat warm in cache without
* opening each one. Older history is the [RelayGroupOpenChatHistorySubAssembler]'s job (below the floor).
*
* Batching by a shared time floor (rather than a per-group `limit` + shared per-relay EOSE `since`) is
* what makes this both correct — every joined group is covered the moment it joins — and reconnect-safe:
* a reconnect re-issues one `since=window` REQ per relay, never a per-group page replay.
*/
class RelayGroupJoinedChatTailFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupJoinedChatTailQueryState>() {
val tail = RelayGroupJoinedChatTailSubAssembler(client, ::allKeys)
val group = listOf(tail)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupJoinedChatTailSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupJoinedChatTailQueryState>,
) : PerUniqueIdEoseManager<RelayGroupJoinedChatTailQueryState, Account>(client, allKeys) {
private val windowLoad = WindowLoadTracker("relayGroup.preview.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
override fun updateFilter(
key: RelayGroupJoinedChatTailQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) {
windowLoad.setExpectedRelays(emptySet())
return null
}
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) {
windowLoad.setExpectedRelays(emptySet())
return null
}
// One #h filter per host relay carrying every joined group id on it; bounded by the shared
// recent-tail floor, so no per-group limit and no per-group re-subscribe on join.
val filters = buildRelayGroupJoinedChatTailFilters(joined, DmHistoryTuning.recentBoundary())
windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay })
return filters
}
override fun id(key: RelayGroupJoinedChatTailQueryState) = key.account
private val toggleJobs = mutableMapOf<Account, Job>()
override fun newSub(key: RelayGroupJoinedChatTailQueryState): Subscription {
windowLoad.startLoading(key.account.scope)
toggleJobs.remove(key.account)?.cancel()
toggleJobs[key.account] =
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() }
return requestNewSubscription(
windowLoad.trackingListener { relay: NormalizedRelayUrl, filters -> newEose(key, relay, TimeUtils.now(), filters) },
)
}
override fun endSub(
key: Account,
subId: String,
) {
super.endSub(key, subId)
toggleJobs.remove(key)?.cancel()
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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.relayGroup.datasource
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.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
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.Job
/** One request to keep the relay-signed state of the user's joined groups live. */
class RelayGroupJoinedStateQueryState(
val account: Account,
)
/**
* Keeps the relay-signed **state** of every group the user has joined live and current — name, picture,
* about, admin/member rosters, roles and pins. Because these are small replaceable events, this is a
* single **always-on** account subscription (mounted at `LoggedInPage`, gated on the NIP-29 chat toggle),
* not a per-screen fetch: the cache always reflects the latest state, so no screen — Messages preview,
* open chat, discovery card — ever has to re-query metadata. One `#d`-scoped filter per host relay carries
* every joined group id on it; `since` is the shared per-relay EOSE (fine for replaceable events — a
* reconnect just re-confirms).
*
* Membership, pending→member transitions and roster counts therefore stay accurate everywhere without
* opening each chat — the reason the old `RelayGroupMyJoinedGroups` roster path existed, promoted from
* "while a groups screen is up" to genuinely always-on.
*/
class RelayGroupJoinedStateFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupJoinedStateQueryState>() {
val group = listOf(RelayGroupJoinedStateSubAssembler(client, ::allKeys))
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupJoinedStateSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupJoinedStateQueryState>,
) : PerUniqueIdEoseManager<RelayGroupJoinedStateQueryState, Account>(client, allKeys) {
override fun updateFilter(
key: RelayGroupJoinedStateQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
return buildRelayGroupStateFilters(joined) { since?.get(it)?.time }
}
override fun id(key: RelayGroupJoinedStateQueryState) = key.account
private val toggleJobs = mutableMapOf<Account, Job>()
override fun newSub(key: RelayGroupJoinedStateQueryState): Subscription {
toggleJobs.remove(key.account)?.cancel()
toggleJobs[key.account] =
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() }
return super.newSub(key)
}
override fun endSub(
key: Account,
subId: String,
) {
super.endSub(key, subId)
toggleJobs.remove(key)?.cancel()
}
}

View File

@@ -1,174 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
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.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.Job
/** One screen's request to keep the roster of the user's joined groups fresh. */
class RelayGroupMyJoinedGroupsQueryState(
val account: Account,
)
/**
* Roster kinds only (39000 metadata + 39001 admins + 39002 members) — enough to
* resolve name, member count and this user's membership. Roles (39003) are pulled
* by the per-chat / directory subscriptions when actually needed.
*/
private val RELAY_GROUP_ROSTER_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
)
/**
* Timeline kinds shown in a group's chat — chat messages and polls. Kept in sync with the
* in-group feed ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
* .subassemblies.filterMessagesToRelayGroup]) so the preview and the opened chat agree.
*/
private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* How many recent chat events to prefetch per joined group. Enough for the Messages-list
* preview to reflect the true newest message and for opening the group to land on a populated
* first screen. Matches [RELAY_GROUP_WARMUP_LIMIT].
*/
private const val RELAY_GROUP_JOINED_PREVIEW_LIMIT = 50
/**
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
* has joined live while a groups-bearing screen is on top, so membership,
* pending→member transitions and member counts stay accurate in list views
* without having to open each chat. This matters most for closed/private groups,
* where the only way to confirm a join was admitted is a fresh 39002.
*
* On top of the roster it prefetches a bounded slice of each group's most recent chat
* (kind 9 + polls), so the Messages-list preview shows the true newest message instead of
* whatever kind-9 events happened to already be cached, and opening a group lands on
* populated content. Without this the list would only ever surface "scattered" messages
* that arrived through unrelated subscriptions until the group was actually opened.
*
* Roster is one `#d`-scoped filter per host relay (only what we're in, not the relay's whole
* directory); content is one `#h`-scoped, limited filter per group (a per-filter limit can't be
* shared across groups, and `#d`/`#h` can't be merged into a single filter).
*/
class RelayGroupMyJoinedGroupsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupMyJoinedGroupsQueryState>() {
val group =
listOf(
RelayGroupMyJoinedGroupsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupMyJoinedGroupsSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupMyJoinedGroupsQueryState>,
) : PerUniqueIdEoseManager<RelayGroupMyJoinedGroupsQueryState, Account>(client, allKeys) {
override fun updateFilter(
key: RelayGroupMyJoinedGroupsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
// Group the joined group ids by their host relay: one #d-scoped roster filter each.
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
val rosterFilters =
idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_ROSTER_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
// One #h-scoped, limited content slice per joined group so list previews show the true
// newest chat and opening the group lands on cached messages. A group's #d roster id and
// its #h message id are the same string, but #d and #h can't be merged into one filter and
// a limit is per-filter, so this stays one bounded filter per group.
val contentFilters =
joined.mapNotNull { group ->
val relay = RelayUrlNormalizer.normalizeOrNull(group.relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(group.groupId)),
limit = RELAY_GROUP_JOINED_PREVIEW_LIMIT,
since = since?.get(relay)?.time,
),
)
}
return rosterFilters + contentFilters
}
override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account
private val toggleJobs = mutableMapOf<Account, Job>()
override fun newSub(key: RelayGroupMyJoinedGroupsQueryState): Subscription {
toggleJobs.remove(key.account)?.cancel()
toggleJobs[key.account] =
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() }
return super.newSub(key)
}
override fun endSub(
key: Account,
subId: String,
) {
super.endSub(key, subId)
toggleJobs.remove(key)?.cancel()
}
}

View File

@@ -1,56 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Mount on any screen that lists the user's joined groups (the Messages tab's
* inline/grouped views, the Relay Groups home) to keep their rosters live.
*
* The query state is keyed on the account (stable), so the assembler wouldn't
* re-run its filter derivation on its own when the joined-group set changes.
* We watch [liveRelayGroupList] and invalidate the assembler on every change, so
* a join/leave while this screen stays foregrounded immediately re-subscribes to
* the new group's roster (critical for confirming admission to a closed group).
*/
@Composable
fun RelayGroupMyJoinedGroupsSubscription(
dataSource: RelayGroupMyJoinedGroupsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
RelayGroupMyJoinedGroupsQueryState(accountViewModel.account)
}
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
LaunchedEffect(joined) { dataSource.invalidateFilters() }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}

View File

@@ -0,0 +1,162 @@
/*
* 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.relayGroup.datasource
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.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** One open NIP-29 group whose older history the chat screen wants paged in. */
class RelayGroupOpenChatHistoryQueryState(
val account: Account,
val groupId: GroupId,
)
/**
* Mounts the on-demand **history** pager for whichever NIP-29 group chat screen is open. The live
* tail ([RelayGroupOpenChatTailFilterAssembler]) holds the recent window each host relay serves; this
* pages older kind-9/poll messages backward by `until`+`limit` on the group's host relay, exactly
* like the per-conversation NIP-04 history and the Concord channel history
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler]).
*/
class RelayGroupOpenChatHistoryFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupOpenChatHistoryQueryState>() {
val history = RelayGroupOpenChatHistorySubAssembler(client, ::allKeys)
val group = listOf(history)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
/**
* Pages one group's older chat by `until`+`limit`, on the single host relay, on demand. The per-relay
* cursors live on the group's [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]
* (so reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open group on
* [newSub], builds the `#h`-scoped REQ at the relay's requested cursor, and forwards relay callbacks in.
* Landing happens on the normal ingest path (host echo → `attachToRelayGroupIfScoped`); the pager only
* needs each event's `createdAt`. All authors (never author-filtered), so it also re-materializes the
* user's own older sent messages — the job the retired `filterMyMessagesToRelayGroup` used to do.
*/
class RelayGroupOpenChatHistorySubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupOpenChatHistoryQueryState>,
) : PerUniqueIdEoseManager<RelayGroupOpenChatHistoryQueryState, GroupId>(client, allKeys) {
private val pager = BackwardRelayPager("relayGroup.chat.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
override fun id(key: RelayGroupOpenChatHistoryQueryState) = key.groupId
// This group's persistent paging cursors, held on its LocalCache RelayGroupChannel.
private fun cursorsFor(key: RelayGroupOpenChatHistoryQueryState) = LocalCache.getOrCreateRelayGroupChannel(key.groupId).history
/** A relay group lives on exactly one relay: its host. */
private fun relaysFor(key: RelayGroupOpenChatHistoryQueryState): Set<NormalizedRelayUrl> = setOf(key.groupId.relayUrl)
override fun updateFilter(
key: RelayGroupOpenChatHistoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val relays = relaysFor(key)
// Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked
// relay keeps no filter here, so re-assembly (a marker advancing) doesn't re-REQ a settled window.
val armed = pager.armedRelays(relays)
if (armed.isEmpty()) return emptyList()
return buildRelayGroupHistoryFilters(key.groupId, armed, { pager.requestedUntilFor(it) }, pager.pageLimit)
}
/** Steps a single [relay] to its next, older page for the open group. Driven by its on-screen marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For a group too short to scroll / eager backfill. */
fun advanceAll() {
if (pager.advanceAll()) invalidateFilters()
}
override fun newSub(key: RelayGroupOpenChatHistoryQueryState): Subscription {
// Repoint the single-active orchestrator at this group's cursors and its host relay.
pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) }
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: RelayGroupOpenChatHistoryQueryState): SubscriptionListener {
// A just-backgrounded group's subscription can still deliver after the orchestrator rebinds to
// another group; gate the pager (single-active) on whether it's still bound to THIS group's
// 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(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEose(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.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.eoseManagers.PerUniqueIdEoseManager
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.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** One open NIP-29 group whose recent chat the screen wants live. */
class RelayGroupOpenChatTailQueryState(
val account: Account,
val groupId: GroupId,
)
/**
* Per-open-group **live tail**: the recent chat window of the *currently open* group, `#h`-scoped on
* its host relay, `since = recentBoundary()`. The group analog of the NIP-04 per-conversation live tail.
*
* The batched [RelayGroupJoinedChatTailFilterAssembler] already covers every *joined* group; this exists so
* an open **non-joined** group (opened by link before joining, not in `liveRelayGroupList`) still gets
* its recent messages and live updates. For a joined open group it simply overlaps the batched tail
* (harmless — events dedup by id on ingest). Older history is the [RelayGroupOpenChatHistorySubAssembler]'s job.
*/
class RelayGroupOpenChatTailFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupOpenChatTailQueryState>() {
val tail = RelayGroupOpenChatTailSubAssembler(client, ::allKeys)
val group = listOf(tail)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupOpenChatTailSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupOpenChatTailQueryState>,
) : PerUniqueIdEoseManager<RelayGroupOpenChatTailQueryState, GroupId>(client, allKeys) {
private val windowLoad = WindowLoadTracker("relayGroup.chat.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
override fun updateFilter(
key: RelayGroupOpenChatTailQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
windowLoad.setExpectedRelays(setOf(key.groupId.relayUrl))
return listOf(buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()))
}
override fun id(key: RelayGroupOpenChatTailQueryState) = key.groupId
override fun newSub(key: RelayGroupOpenChatTailQueryState): Subscription {
windowLoad.startLoading(key.account.scope)
return requestNewSubscription(
windowLoad.trackingListener { relay: NormalizedRelayUrl, filters -> newEose(key, relay, TimeUtils.now(), filters) },
)
}
}

View File

@@ -26,13 +26,10 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEo
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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
/** One threads-screen's request for a single group's kind-11 threads. */
class RelayGroupThreadFeedQueryState(
class RelayGroupOpenThreadsQueryState(
val channel: RelayGroupChannel,
)
@@ -43,12 +40,12 @@ class RelayGroupThreadFeedQueryState(
* kind-9 chat, so we don't pay for them until asked). Fetching the comments here
* too means opening a thread from the list has its replies already cached.
*/
class RelayGroupThreadFeedFilterAssembler(
class RelayGroupOpenThreadsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupThreadFeedQueryState>() {
) : ComposeSubscriptionManager<RelayGroupOpenThreadsQueryState>() {
val group =
listOf(
RelayGroupThreadFeedSubAssembler(client, ::allKeys),
RelayGroupOpenThreadsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -58,27 +55,17 @@ class RelayGroupThreadFeedFilterAssembler(
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupThreadFeedSubAssembler(
class RelayGroupOpenThreadsSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupThreadFeedQueryState>,
) : PerUniqueIdEoseManager<RelayGroupThreadFeedQueryState, GroupId>(client, allKeys) {
allKeys: () -> Set<RelayGroupOpenThreadsQueryState>,
) : PerUniqueIdEoseManager<RelayGroupOpenThreadsQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupThreadFeedQueryState,
key: RelayGroupOpenThreadsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val groupId = key.channel.groupId
return listOf(
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND),
tags = mapOf("h" to listOf(groupId.id)),
since = since?.get(groupId.relayUrl)?.time,
),
),
)
return listOf(buildRelayGroupThreadsFilter(groupId, since?.get(groupId.relayUrl)?.time))
}
override fun id(key: RelayGroupThreadFeedQueryState) = key.channel.groupId
override fun id(key: RelayGroupOpenThreadsQueryState) = key.channel.groupId
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
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.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.TimeUtils
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,
val groupId: GroupId,
)
/**
* Mounts the on-demand **history** pager for whichever NIP-29 group's Threads tab is open. The Threads
* live tail ([RelayGroupOpenThreadsFilterAssembler]) holds the recent window each host relay serves; this
* pages older kind-11/1111 thread content backward by `until`+`limit` on the group's host relay, exactly
* like the chat history pager ([RelayGroupOpenChatHistoryFilterAssembler]) but on the group's separate
* [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel.threadsHistory] cursors — so
* a group with more threads than the relay's default result cap doesn't silently hide the older ones.
*/
class RelayGroupOpenThreadsHistoryFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupOpenThreadsHistoryQueryState>() {
val history = RelayGroupOpenThreadsHistorySubAssembler(client, ::allKeys)
val group = listOf(history)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
/**
* Pages one group's older threads by `until`+`limit`, on the single host relay, on demand. The per-relay
* cursors live on the group's [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel.threadsHistory]
* (so reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open group on
* [newSub], builds the `#h`-scoped kind-11/1111 REQ at the relay's requested cursor, and forwards relay
* callbacks in. Landing happens on the normal ingest path (kind-11 → `addThread`, kind-1111 → its thread
* tree); the pager only needs each event's `createdAt`.
*/
class RelayGroupOpenThreadsHistorySubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupOpenThreadsHistoryQueryState>,
) : PerUniqueIdEoseManager<RelayGroupOpenThreadsHistoryQueryState, GroupId>(client, allKeys) {
private val pager = BackwardRelayPager("relayGroup.threads.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
override fun id(key: RelayGroupOpenThreadsHistoryQueryState) = key.groupId
// This group's persistent thread-paging cursors, held on its LocalCache RelayGroupChannel.
private fun cursorsFor(key: RelayGroupOpenThreadsHistoryQueryState) = LocalCache.getOrCreateRelayGroupChannel(key.groupId).threadsHistory
/** A relay group lives on exactly one relay: its host. */
private fun relaysFor(key: RelayGroupOpenThreadsHistoryQueryState): Set<NormalizedRelayUrl> = setOf(key.groupId.relayUrl)
override fun updateFilter(
key: RelayGroupOpenThreadsHistoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val armed = pager.armedRelays(relaysFor(key))
if (armed.isEmpty()) return emptyList()
return buildRelayGroupThreadsHistoryFilters(key.groupId, armed, { pager.requestedUntilFor(it) }, pager.pageLimit)
}
/** Steps a single [relay] to its next, older page for the open group. Driven by its on-screen marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For a short list / eager backfill. */
fun advanceAll() {
if (pager.advanceAll()) invalidateFilters()
}
override fun newSub(key: RelayGroupOpenThreadsHistoryQueryState): Subscription {
// Repoint the single-active orchestrator at this group's thread cursors and its host relay.
pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) }
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: RelayGroupOpenThreadsHistoryQueryState): SubscriptionListener {
// A just-backgrounded group's subscription can still deliver after the orchestrator rebinds to
// another group; gate the pager (single-active) on whether it's still bound to THIS group's
// 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(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEose(relay)
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}

View File

@@ -25,18 +25,34 @@ import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments. */
/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments (the recent live tail). */
@Composable
fun RelayGroupThreadFeedSubscription(
fun RelayGroupOpenThreadsSubscription(
channel: RelayGroupChannel,
dataSource: RelayGroupThreadFeedFilterAssembler,
dataSource: RelayGroupOpenThreadsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(channel.groupId) {
RelayGroupThreadFeedQueryState(channel)
RelayGroupOpenThreadsQueryState(channel)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
/**
* Mount on a group's Threads screen to keep its backward-history pager bound and armed (older kind-11/1111
* by `until`+`limit` on the host relay), the Threads analog of [RelayGroupOpenChatHistorySubscription].
*/
@Composable
fun RelayGroupOpenThreadsHistorySubscription(
groupId: GroupId,
dataSource: RelayGroupOpenThreadsHistoryFilterAssembler,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val state = remember(account, groupId) { RelayGroupOpenThreadsHistoryQueryState(account, groupId) }
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}

View File

@@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeRelayGroupsDiscoveryFilter(
fun filterRelayGroupsDiscovery(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,

View File

@@ -55,7 +55,7 @@ class RelayGroupsDiscoverySubAssembler(
val feedSettings = key.followsPerRelay()
val defaultSince = key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled()
val base = makeRelayGroupsDiscoveryFilter(feedSettings, since, defaultSince)
val base = filterRelayGroupsDiscovery(feedSettings, since, defaultSince)
// The follow-list filter sets resolve their relays via the outbox model (a follow's own
// publish relays), but a NIP-29 roster (39001/39002) lives ONLY on the group's host relay.

View File

@@ -26,12 +26,7 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEo
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.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** One screen's request for the full channel directory of a single relay. */
class RelayGroupsOnRelayQueryState(
@@ -39,14 +34,6 @@ class RelayGroupsOnRelayQueryState(
val account: Account,
)
private val RELAY_GROUP_DIRECTORY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/**
* Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay,
* so the "browse a relay's channels" screen sees every group the relay hosts. The
@@ -76,18 +63,7 @@ class RelayGroupsOnRelaySubAssembler(
override fun updateFilter(
key: RelayGroupsOnRelayQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = key.relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = 500,
since = since?.get(key.relay)?.time,
),
),
)
): List<RelayBasedFilter> = listOf(buildRelayGroupDirectoryFilter(key.relay, since?.get(key.relay)?.time))
override fun id(key: RelayGroupsOnRelayQueryState) = key.relay
}

View File

@@ -214,6 +214,13 @@ open class ChannelNewMessageViewModel :
fun user(): User = account.userProfile()
open fun init(accountVM: AccountViewModel) {
// The channel screens call this straight from their composable body, so it runs on the main
// thread on every recomposition of that body. Guard against re-running the allocating setup
// (new UserSuggestionState/EmojiSuggestionState/ChatFileUploadState) when nothing changed:
// only (re)initialize when the account actually differs. Beyond the wasted allocations, a
// blind re-init would also reset `uploadState` mid-upload, discarding in-flight progress.
if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return
this.accountViewModel = accountVM
this.account = accountVM.account
this.canAddInvoice = hasLnAddress()

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -58,6 +59,8 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -762,26 +765,7 @@ private fun UserRoomCompose(
TimeAgo(lastMessage.createdAt())
},
secondRow = {
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
if (content != null) {
Text(
content,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
} else {
Text(
stringRes(R.string.referenced_event_not_found),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
LastMessagePreview(lastMessage, accountViewModel)
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
val newestEvent = lastMessage.event
@@ -816,6 +800,51 @@ private fun UserRoomCompose(
}
}
/**
* The one-line preview of the room's newest message.
*
* NIP-04 rooms carry ciphertext in `event.content`, so the preview may only ever come from the
* decryption cache. [chatPreviewOf] keeps the three not-a-body outcomes apart — still decrypting,
* never decryptable, and no event at all — so a message that simply hasn't been opened yet isn't
* mislabelled as unreadable. The pending state resolves on its own: [LoadDecryptedContentOrNull]
* pushes the plaintext into its state as soon as the signer answers.
*/
@Composable
private fun RowScope.LastMessagePreview(
lastMessage: Note,
accountViewModel: AccountViewModel,
) {
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
// Keyed so a scrolling list doesn't re-scan the DM's `p` tags on every recomposition.
val preview =
remember(lastMessage.event, content) {
chatPreviewOf(
event = lastMessage.event,
decrypted = content,
myPubKey = accountViewModel.account.signer.pubKey,
canDecrypt = accountViewModel.account.isWriteable(),
)
}
val text =
when (preview) {
is ChatPreview.Body -> preview.text
ChatPreview.Decrypting -> stringRes(R.string.chat_preview_decrypting)
ChatPreview.Undecryptable -> stringRes(R.string.could_not_decrypt_the_message)
ChatPreview.Missing -> stringRes(R.string.referenced_event_not_found)
}
Text(
text,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
}
}
@Composable
fun LoadUser(
baseUserHex: String,

View File

@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager
@@ -102,9 +101,8 @@ fun MessagesSinglePane(
},
accountViewModel = accountViewModel,
) {
// Keep joined groups' rosters live while the messages list is on top, so
// membership/pending state is accurate inline without opening each chat.
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
// Joined groups' rosters + recent-chat previews are kept live by the always-on state + preview
// subs (mounted at LoggedInPage), so no per-screen group subscription is needed here.
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a cache hit
// when those groups surface in discovery or any gated surface.

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