mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
129 Commits
claude/nip
...
078758888a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078758888a | ||
|
|
387bfe99ee | ||
|
|
6033957b1c | ||
|
|
37923d9101 | ||
|
|
57ffb3386d | ||
|
|
a3e239d33f | ||
|
|
41240eeab9 | ||
|
|
5988db010d | ||
|
|
72e27a3530 | ||
|
|
556ef9f880 | ||
|
|
52c863a9c6 | ||
|
|
5e5ecdab8b | ||
|
|
062ab0f012 | ||
|
|
5f5356c0fe | ||
|
|
4e0ac212ee | ||
|
|
63d01c9287 | ||
|
|
fa32ea50a3 | ||
|
|
1a77c95531 | ||
|
|
d3e208322c | ||
|
|
b06184ac49 | ||
|
|
45d33c016e | ||
|
|
9922eef9f7 | ||
|
|
ad66e6c224 | ||
|
|
da451145af | ||
|
|
a5ac06ad6c | ||
|
|
77908b2be0 | ||
|
|
05426d8c66 | ||
|
|
c8f43a1bd4 | ||
|
|
cc94ef9103 | ||
|
|
ad75344c3e | ||
|
|
ef9a891974 | ||
|
|
b2cfb5e7ee | ||
|
|
0601cfafbc | ||
|
|
0214382d0f | ||
|
|
a0ab3ec66d | ||
|
|
15ce992b61 | ||
|
|
8913af7a79 | ||
|
|
ea6762b137 | ||
|
|
0ae6bc6698 | ||
|
|
ba8de9ba21 | ||
|
|
c15774e4e1 | ||
|
|
4e7242a295 | ||
|
|
97b861dd5e | ||
|
|
4ee0416a90 | ||
|
|
a040aa522c | ||
|
|
4255ba3345 | ||
|
|
08bd5d9b4a | ||
|
|
b0668baeec | ||
|
|
62440748a7 | ||
|
|
2d29f7ccda | ||
|
|
5f712b3a8a | ||
|
|
ab2f4d2da8 | ||
|
|
a87049e451 | ||
|
|
894dc7d99b | ||
|
|
717461f4d0 | ||
|
|
f2e42105f7 | ||
|
|
c6a6068486 | ||
|
|
73d59a29bf | ||
|
|
7792c42f1d | ||
|
|
6045e28830 | ||
|
|
c84acddc0c | ||
|
|
5c23f8490d | ||
|
|
f8d8a2b135 | ||
|
|
fac1bf5b5d | ||
|
|
30f4638954 | ||
|
|
0f53eb09a2 | ||
|
|
c9c91a8c98 | ||
|
|
996b800da5 | ||
|
|
2ccd837f30 | ||
|
|
c8be65a02e | ||
|
|
153191e722 | ||
|
|
5fd8be64fd | ||
|
|
7f135a12ab | ||
|
|
095b156953 | ||
|
|
5bce87d76e | ||
|
|
389b460f7a | ||
|
|
dbf15a2146 | ||
|
|
ee3b8786d4 | ||
|
|
49b93db532 | ||
|
|
710c68d6aa | ||
|
|
824f9898de | ||
|
|
fb730d7ea8 | ||
|
|
fe761ab7f2 | ||
|
|
768e19ff9b | ||
|
|
18c0c4291f | ||
|
|
618664c45a | ||
|
|
7b5e5f572b | ||
|
|
f875d2c397 | ||
|
|
b801a6b138 | ||
|
|
6ba4ab0a4b | ||
|
|
035c50421c | ||
|
|
4efb2cca98 | ||
|
|
962d1706dc | ||
|
|
620fc465df | ||
|
|
43704772a7 | ||
|
|
8d3a47b8f8 | ||
|
|
82f041b1f5 | ||
|
|
6e664b2db6 | ||
|
|
ad2c232868 | ||
|
|
dd6e7e2852 | ||
|
|
850c3e58e4 | ||
|
|
e66550091d | ||
|
|
b1b6190d2f | ||
|
|
bff7592383 | ||
|
|
a4713c89c2 | ||
|
|
d8584a7935 | ||
|
|
7ea6920679 | ||
|
|
80be7b909b | ||
|
|
55884f81eb | ||
|
|
7b5d9a0fe4 | ||
|
|
11901df2c6 | ||
|
|
471883a9f2 | ||
|
|
6bcae39b83 | ||
|
|
7e3c01ab08 | ||
|
|
b852a163c7 | ||
|
|
3214095b99 | ||
|
|
c5f10c7518 | ||
|
|
db5170a82e | ||
|
|
399bef6bf7 | ||
|
|
4cf86ca030 | ||
|
|
c8362fbc18 | ||
|
|
ee5dc25414 | ||
|
|
54b2b42d87 | ||
|
|
050ecaaf9f | ||
|
|
af9d5a7278 | ||
|
|
7dcf2afa0f | ||
|
|
9d8e07fbee | ||
|
|
a1b7cad85c | ||
|
|
6ac8e475df |
@@ -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)
|
||||
|
||||
201
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md
Normal file
201
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md
Normal 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` (39000–39005 + 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.
|
||||
177
amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md
Normal file
177
amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md
Normal 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 1–6 + 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 C1–C9 + 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 C1–C9 pass (esp. **C3**).
|
||||
- Tier D1–D9 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.
|
||||
190
amethyst/plans/2026-07-20-v1.13.0-release-qa.md
Normal file
190
amethyst/plans/2026-07-20-v1.13.0-release-qa.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) + "…"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =====")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()} "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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+")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user