mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
109 Commits
claude/cha
...
12aa9fe954
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12aa9fe954 | ||
|
|
54364731a6 | ||
|
|
a0d50c69f6 | ||
|
|
5d9efc800a | ||
|
|
f869834e9d | ||
|
|
fb7bdf3057 | ||
|
|
f31e7a4c0b | ||
|
|
4a4baf6a23 | ||
|
|
bce544e8c3 | ||
|
|
0ea5d0e876 | ||
|
|
5137b92046 | ||
|
|
2124409025 | ||
|
|
1b76dd250e | ||
|
|
201aafb5bf | ||
|
|
81fa81612b | ||
|
|
c79b795477 | ||
|
|
6bc16314e8 | ||
|
|
c85f51c40a | ||
|
|
54ecae50af | ||
|
|
7903834efd | ||
|
|
d7125b8b08 | ||
|
|
f5d22ce720 | ||
|
|
1757ef4513 | ||
|
|
a54c808763 | ||
|
|
a960ceb6a8 | ||
|
|
3fdb976aa9 | ||
|
|
a6b40cfc0f | ||
|
|
b616f69c1d | ||
|
|
b6bf3b8a70 | ||
|
|
73a58e042d | ||
|
|
e3dae26483 | ||
|
|
86c8d0b12c | ||
|
|
db0ff71432 | ||
|
|
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 | ||
|
|
fe761ab7f2 | ||
|
|
a1b7cad85c | ||
|
|
6ac8e475df |
@@ -302,6 +302,21 @@ Do this before considering the task complete.
|
||||
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
|
||||
no intermediate list, and no lambda object. Don't "modernize" those into
|
||||
stdlib collection calls; match the surrounding hot-path style.
|
||||
- **Never put raw invisible/bidirectional Unicode characters in source files**
|
||||
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
|
||||
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
|
||||
flags — U+202A–U+202E, the isolates U+2066–U+2069, U+200E/U+200F, U+061C —
|
||||
plus zero-width characters (U+200B–U+200D, U+FEFF, U+2060). The escape
|
||||
compiles to the identical codepoint, so behaviour is unchanged; the point is
|
||||
that the file on disk stays visually unambiguous. Applies even when the
|
||||
character is *intentional* (sanitizer strip-lists, adversarial test
|
||||
payloads) — that's data, and escapes express it just as well. Exceptions:
|
||||
U+200D as part of a real emoji ZWJ sequence in test data (👩👧 — functional,
|
||||
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
|
||||
translations (legitimate RTL typography; don't touch those files by hand
|
||||
anyway). Note the tooling trap: the Edit tool may normalise a typed
|
||||
`\uXXXX` back into the raw character — if that happens, do the replacement
|
||||
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
|
||||
|
||||
### Navigation Shell
|
||||
- **Desktop**: Sidebar + main content area
|
||||
|
||||
@@ -332,6 +332,35 @@ When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
|
||||
```bash
|
||||
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
|
||||
for l in cs de-rDE sv-rSE pt-rBR; do
|
||||
missing=$(comm -23 \
|
||||
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' $base/values-$l/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
|
||||
done
|
||||
```
|
||||
|
||||
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
|
||||
|
||||
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
|
||||
|
||||
```bash
|
||||
for f in <every edited strings.xml>; do
|
||||
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
|
||||
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
|
||||
[ -n "$dups" ] && echo "DUP in $f: $dups"
|
||||
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|
||||
|| echo "MALFORMED $f"
|
||||
done
|
||||
# For a commons change, also run the build task that enforces this:
|
||||
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
@@ -344,6 +373,7 @@ When adding translated strings to locale files:
|
||||
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
|
||||
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
|
||||
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
|
||||
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
|
||||
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
|
||||
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
@@ -694,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.
|
||||
|
||||
@@ -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
|
||||
@@ -152,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
@@ -208,6 +210,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 +365,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 +375,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 +662,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 +2107,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 +2139,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 +2163,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 +2375,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 +2437,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 +2454,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 +2487,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 +2502,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 +2514,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 +2528,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 +2553,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 +2637,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 +2652,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 +2686,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 +2772,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 +2790,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 +2803,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 +2825,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
|
||||
}
|
||||
@@ -2751,6 +2904,17 @@ class Account(
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
|
||||
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
|
||||
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
|
||||
*/
|
||||
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
|
||||
if (!isWriteable()) return
|
||||
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
|
||||
client.publish(signed, setOf(channel.groupId.relayUrl))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
@@ -4916,7 +5080,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 +5110,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 +5545,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
|
||||
|
||||
@@ -28,6 +28,9 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
@@ -50,6 +53,82 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
|
||||
import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
|
||||
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
|
||||
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
||||
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
|
||||
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
@@ -1257,6 +1336,16 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
is StreamMessageV2Event -> {
|
||||
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
|
||||
// buzz-sdk). Link both so inbound replies render their quote bubble —
|
||||
// we emit these markers on send, so we must also read them.
|
||||
listOfNotNull(
|
||||
event.tags.buzzThreadRoot(),
|
||||
event.tags.buzzThreadReply(),
|
||||
).distinct().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1986,6 +2075,82 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
|
||||
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
|
||||
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
|
||||
* The note-has-event check is the same verification gate the attach path uses.
|
||||
*/
|
||||
private fun markBuzzIfVerified(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
if (relay != null && getNoteIfExists(event.id)?.event != null) {
|
||||
BuzzRelayDialect.mark(relay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
|
||||
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
|
||||
* exist on `block/buzz` relays, so their (verified) arrival IS the dialect
|
||||
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
|
||||
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
|
||||
* share one timeline and non-host strays never mint phantom channels.
|
||||
*/
|
||||
private fun consumeBuzzTimelineEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
markBuzzIfVerified(event, relay)
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
|
||||
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
|
||||
private fun consumeBuzzRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
markBuzzIfVerified(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: StreamMessageEditEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
|
||||
// earlier message's content, never a row of its own. Store it and record the
|
||||
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
|
||||
// land too), but do NOT attach it to the timeline.
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val target = event.editedMessage() ?: return@also
|
||||
val channelId = event.channel() ?: return@also
|
||||
val editNote = getOrCreateNote(event.id)
|
||||
if (editNote.event != null) {
|
||||
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: CanvasEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
// The canvas is the channel's single living document, not a chat row: track
|
||||
// only the newest revision as overlay state, never attach it to the timeline.
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val channelId = event.channel() ?: return@also
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) {
|
||||
BuzzWorkspaceStates.getOrCreate(channelId).updateCanvas(note)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
|
||||
@@ -3040,6 +3205,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
|
||||
}
|
||||
|
||||
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
|
||||
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
|
||||
// message was just pruned, else they pin the edit note + author forever.
|
||||
if (channel is RelayGroupChannel) {
|
||||
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
@@ -4407,6 +4579,107 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
|
||||
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
|
||||
// rest are stored for query/state. Kinds 9041/20001/39005/49001 are
|
||||
// absent on purpose: their numbers belong to GoalEvent,
|
||||
// GeohashPresenceEvent, GroupPinnedEvent and a non-wire audit kind.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
|
||||
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is CanvasEvent -> consume(event, relay, wasVerified)
|
||||
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz addressable/replaceable state.
|
||||
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
// Buzz store-only regular kinds (queryable state; no timeline row yet).
|
||||
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz relay-signed sidecars and audit projections: store-only, queryable.
|
||||
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
||||
// pollute the note store, and do NOT mark the dialect from them (they are
|
||||
// never stored, so the verified-mark gate has nothing to check).
|
||||
is TypingIndicatorEvent -> {
|
||||
// Live "who is typing" side-effect only — record the heartbeat into the
|
||||
// process-wide typing state (the channel view collects it) and return
|
||||
// false so it never becomes a feed row. Own-typing is filtered in the UI.
|
||||
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
|
||||
false
|
||||
}
|
||||
is ObserverFrameEvent -> false
|
||||
is HuddleReactionEvent -> false
|
||||
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
||||
// pairing before any workspace relationship is established.
|
||||
is PairingEvent -> false
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -135,7 +137,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(authTemplate))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -169,6 +171,27 @@ class AuthCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
|
||||
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -98,6 +99,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
@@ -153,6 +159,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 +341,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)
|
||||
@@ -447,9 +458,13 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||
@@ -578,6 +593,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) }
|
||||
@@ -745,6 +761,7 @@ fun BuildNavigation(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
|
||||
@@ -69,6 +69,7 @@ enum class NavBarItem {
|
||||
PODCASTS,
|
||||
PUBLIC_CHATS,
|
||||
RELAY_GROUPS,
|
||||
BUZZ,
|
||||
CONCORD,
|
||||
GEOHASH_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Forum,
|
||||
resolveRoute = { Route.RelayGroups },
|
||||
),
|
||||
NavBarItem.BUZZ to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.BUZZ,
|
||||
labelRes = R.string.buzz_workspaces_title,
|
||||
icon = MaterialSymbols.AutoAwesome,
|
||||
resolveRoute = { Route.BuzzWorkspaces },
|
||||
),
|
||||
NavBarItem.CONCORD to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CONCORD,
|
||||
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.BUZZ,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
),
|
||||
|
||||
@@ -469,6 +469,14 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object AgentConsole : Route()
|
||||
|
||||
@Serializable object AgentAttestation : Route()
|
||||
|
||||
@Serializable data class AgentPersonaEdit(
|
||||
val slug: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
@@ -507,6 +515,10 @@ sealed class Route {
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ShareNoteAsQr(
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ContactListUsers(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
@@ -687,6 +699,10 @@ sealed class Route {
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzCanvas(
|
||||
val channelId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
@@ -698,6 +714,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object RelayGroups : Route()
|
||||
|
||||
@Serializable object BuzzWorkspaces : Route()
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
// Concord Channels (encrypted communities). Addressed by community id + channel id
|
||||
|
||||
@@ -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,
|
||||
@@ -667,6 +685,11 @@ class AccountViewModel(
|
||||
account.sendConcordTyping(communityId, channelIdHex)
|
||||
}
|
||||
|
||||
fun sendBuzzTyping(channel: RelayGroupChannel) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendBuzzTyping(channel)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class NoteComposeReportState(
|
||||
val isPostHidden: Boolean = false,
|
||||
@@ -1580,6 +1603,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 +2412,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(
|
||||
|
||||
@@ -137,6 +137,10 @@ private fun PreloadFor(
|
||||
// this tab needs no extra preload.
|
||||
NavBarItem.RELAY_GROUPS -> Unit
|
||||
|
||||
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
|
||||
// always-on relay-group state subscription, so there's nothing extra to preload.
|
||||
NavBarItem.BUZZ -> Unit
|
||||
|
||||
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
|
||||
|
||||
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
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.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
|
||||
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
|
||||
* events and the relay grants it virtual membership while the owner stays a member.
|
||||
*
|
||||
* This is entirely offline: the signature covers a hashed commitment string, not a
|
||||
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
|
||||
* external signer cannot produce it. Read-only accounts see an explanation instead of
|
||||
* the form. Nothing is published; the signed tag is a credential the owner hands to the
|
||||
* agent operator out-of-band.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentAttestationScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val keyPair = accountViewModel.account.settings.keyPair
|
||||
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Attestations", nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
} else {
|
||||
AttestationForm(ownerKey = keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
|
||||
* now — re-paste after an app restart.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Hold an attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (mine != null) {
|
||||
Text(
|
||||
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
Text("Remove")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("auth tag JSON") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Hold attestation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface HoldOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : HoldOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
* non-`auth` tag, or a signature that doesn't verify for this key.
|
||||
*/
|
||||
private fun parseHeldAttestation(
|
||||
input: String,
|
||||
myPubkey: String,
|
||||
): HoldOutcome {
|
||||
val tag =
|
||||
try {
|
||||
Json
|
||||
.parseToJsonElement(input.trim())
|
||||
.jsonArray
|
||||
.map { it.jsonPrimitive.content }
|
||||
.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
|
||||
}
|
||||
val attestation =
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadOnlyKeyNotice() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Local key required",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
|
||||
"so it can only be produced by a signer that holds your raw private key. This " +
|
||||
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
|
||||
"sign an attestation.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationForm(ownerKey: KeyPair) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var agentInput by remember { mutableStateOf("") }
|
||||
var kindInput by remember { mutableStateOf("") }
|
||||
var afterInput by remember { mutableStateOf("") }
|
||||
var beforeInput by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
|
||||
"The agent attaches the signed tag below to its events.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = agentInput,
|
||||
onValueChange = {
|
||||
agentInput = it
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Agent public key (npub or hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = kindInput,
|
||||
onValueChange = {
|
||||
kindInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Restrict to kind (0–65535)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = afterInput,
|
||||
onValueChange = {
|
||||
afterInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events after (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = beforeInput,
|
||||
onValueChange = {
|
||||
beforeInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events before (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
is AttestationOutcome.Failure -> {
|
||||
error = outcome.message
|
||||
result = null
|
||||
}
|
||||
is AttestationOutcome.Success -> {
|
||||
error = null
|
||||
result = outcome.attestation
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = agentInput.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Generate attestation")
|
||||
}
|
||||
|
||||
result?.let { attestation ->
|
||||
AttestationResultCard(
|
||||
attestation = attestation,
|
||||
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationResultCard(
|
||||
attestation: OwnerAttestation,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Signed attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"Grants: " +
|
||||
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = attestation.toTagJson(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onCopy) {
|
||||
Text("Copy tag")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
|
||||
"workspace member, the relay lets this agent post as a member under the " +
|
||||
"conditions above.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface AttestationOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : AttestationOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : AttestationOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form inputs and signs an [OwnerAttestation], or returns a
|
||||
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
|
||||
* plain function.
|
||||
*/
|
||||
private fun buildAttestation(
|
||||
agentInput: String,
|
||||
kindInput: String,
|
||||
afterInput: String,
|
||||
beforeInput: String,
|
||||
ownerKey: KeyPair,
|
||||
): AttestationOutcome {
|
||||
val agentHex =
|
||||
decodePublicKeyAsHexOrNull(agentInput.trim())
|
||||
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
if (!agentHex.isValid()) {
|
||||
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
}
|
||||
|
||||
val kind =
|
||||
if (kindInput.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val parsed = kindInput.toIntOrNull()
|
||||
if (parsed == null || parsed !in 0..65535) {
|
||||
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 0–4294967295.")
|
||||
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 0–4294967295.")
|
||||
|
||||
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
|
||||
|
||||
return try {
|
||||
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
|
||||
private class OptionalUnix(
|
||||
val value: Long?,
|
||||
)
|
||||
|
||||
private fun parseOptionalUnix(input: String): OptionalUnix? {
|
||||
if (input.isBlank()) return OptionalUnix(null)
|
||||
val parsed = input.toLongOrNull() ?: return null
|
||||
if (parsed !in 0..4294967295L) return null
|
||||
return OptionalUnix(parsed)
|
||||
}
|
||||
|
||||
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
|
||||
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import 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
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
|
||||
* that publish to them. Three tabs:
|
||||
* - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend),
|
||||
* derived from decrypted NIP-AM turn metrics (`kind:44200`).
|
||||
* - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`).
|
||||
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
|
||||
*
|
||||
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
|
||||
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
|
||||
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
|
||||
* follow-up.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentConsoleScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey")
|
||||
|
||||
viewModel.bindAccountIfMissing(accountViewModel.account)
|
||||
|
||||
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
|
||||
val personas by viewModel.personas.collectAsStateWithLifecycle()
|
||||
val observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val tabs = remember { listOf("Costs", "Personas", "Observer") }
|
||||
|
||||
// The observer stream is a live REQ; only keep it open while its tab is on screen.
|
||||
if (selectedTab == 2) {
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startObserving()
|
||||
onDispose { viewModel.stopObserving() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Agent Console", nav) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text("Attest") },
|
||||
icon = { Icon(symbol = MaterialSymbols.Key, contentDescription = null) },
|
||||
onClick = { nav.nav(Route.AgentAttestation) },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selectedTab) {
|
||||
0 -> CostsTab(metrics)
|
||||
1 -> PersonasTab(personas, nav)
|
||||
else -> ObserverTab(observerFrames)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CostsTab(metrics: AgentFleetMetrics) {
|
||||
if (metrics.agents.isEmpty()) {
|
||||
EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
FleetSummaryCard(metrics)
|
||||
}
|
||||
items(metrics.agents) { agent ->
|
||||
AgentCard(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FleetSummaryCard(metrics: AgentFleetMetrics) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Fleet total",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(metrics.totals.costUsd),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
MetaRow("Agents", metrics.agents.size.toString())
|
||||
MetaRow("Sessions", metrics.totalSessions.toString())
|
||||
MetaRow("Turns", metrics.totalTurns.toString())
|
||||
TokenBreakdown(metrics.totals)
|
||||
if (metrics.hasUnreliableEstimates) {
|
||||
Text(
|
||||
text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentCard(agent: AgentUsageSummary) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = shortKey(agent.agentPubKey),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(agent.totals.costUsd),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
MetaRow("Sessions", agent.sessions.toString())
|
||||
MetaRow("Turns", agent.turns.toString())
|
||||
if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", "))
|
||||
if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", "))
|
||||
agent.lastActivity?.let { MetaRow("Last turn", it) }
|
||||
TokenBreakdown(agent.totals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenBreakdown(totals: TokenTotals) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
||||
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
||||
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
||||
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
|
||||
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonasTab(
|
||||
personas: List<AgentConsoleViewModel.PersonaCard>,
|
||||
nav: INav,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = { nav.nav(Route.AgentPersonaEdit()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
|
||||
Text(" New persona")
|
||||
}
|
||||
}
|
||||
if (personas.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No personas published yet. Create one to define an agent (kind:30175).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(personas) { persona ->
|
||||
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCardView(
|
||||
persona: AgentConsoleViewModel.PersonaCard,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = persona.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = persona.slug,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
persona.model?.let { MetaRow("Model", it) }
|
||||
persona.provider?.let { MetaRow("Provider", it) }
|
||||
persona.runtime?.let { MetaRow("Runtime", it) }
|
||||
persona.systemPrompt?.takeIf { it.isNotBlank() }?.let {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
|
||||
if (frames.isEmpty()) {
|
||||
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(frames) { frame ->
|
||||
ObserverFrameRow(frame)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = frame.kind,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = frame.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val context =
|
||||
buildString {
|
||||
append(shortKey(frame.agentPubKey))
|
||||
frame.sessionId?.let { append(" · session ${it.take(8)}") }
|
||||
frame.turnId?.let { append(" · turn ${it.take(8)}") }
|
||||
append(" · #${frame.seq}")
|
||||
}
|
||||
Text(
|
||||
text = context,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(
|
||||
label: String,
|
||||
value: String,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(message: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) }
|
||||
|
||||
/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */
|
||||
private fun padCents(value: String): String {
|
||||
val dot = value.indexOf('.')
|
||||
if (dot < 0) return "$value.00"
|
||||
val decimals = value.length - dot - 1
|
||||
return when {
|
||||
decimals >= 2 -> value.substring(0, dot + 3)
|
||||
decimals == 1 -> value + "0"
|
||||
else -> value + "00"
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */
|
||||
private fun formatCount(n: Long): String {
|
||||
val s = n.toString()
|
||||
if (s.length <= 3) return s
|
||||
val sb = StringBuilder()
|
||||
val firstGroup = s.length % 3
|
||||
if (firstGroup > 0) {
|
||||
sb.append(s, 0, firstGroup)
|
||||
}
|
||||
var i = firstGroup
|
||||
while (i < s.length) {
|
||||
if (sb.isNotEmpty()) sb.append(' ')
|
||||
sb.append(s, i, i + 3)
|
||||
i += 3
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "…" + hex.takeLast(8)
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filter
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
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 kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
|
||||
* dashboard over their AI-agent fleet.
|
||||
*
|
||||
* It reads two Buzz kinds the owner authored or received:
|
||||
* - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner.
|
||||
* - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an
|
||||
* agent published to the owner (`p` tag = owner). Either party can decrypt; here the
|
||||
* owner's [Account.signer] does. Decryption is cached by event id so a refresh that
|
||||
* re-reads the cache never re-runs NIP-44 on an event it already opened.
|
||||
*
|
||||
* [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set,
|
||||
* lets [LocalCache] consume them, then re-derives the aggregate via the pure
|
||||
* [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by
|
||||
* the owner pubkey in the screen, so one instance survives navigation in and out.
|
||||
*/
|
||||
class AgentConsoleViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** event id -> decrypted payload (null = decryption failed; don't retry). */
|
||||
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
|
||||
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
|
||||
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
|
||||
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
|
||||
|
||||
private var observerJob: Job? = null
|
||||
|
||||
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
|
||||
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
|
||||
|
||||
fun bindAccountIfMissing(account: Account) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
refreshMutex.withLock {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
fetchFromRelays(account)
|
||||
reloadFromCache(account)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot paged fetch of the owner's personas (authored by owner) and turn metrics
|
||||
* (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the
|
||||
* owner's own outbox relays. Events land in [LocalCache] via the normal consume path;
|
||||
* this only primes the cache before [reloadFromCache] reads it back.
|
||||
*/
|
||||
private suspend fun fetchFromRelays(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val filters =
|
||||
listOf(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
|
||||
)
|
||||
|
||||
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
|
||||
}
|
||||
|
||||
private suspend fun reloadFromCache(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val signer = account.signer
|
||||
|
||||
_personas.value =
|
||||
LocalCache.addressables
|
||||
.filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent }
|
||||
.mapNotNull { note ->
|
||||
val event = note.event as? PersonaEvent ?: return@mapNotNull null
|
||||
val content = event.personaOrNull()
|
||||
PersonaCard(
|
||||
slug = event.slug() ?: "",
|
||||
displayName = content?.displayName ?: event.slug() ?: "",
|
||||
model = content?.model,
|
||||
runtime = content?.runtime,
|
||||
provider = content?.provider,
|
||||
systemPrompt = content?.systemPrompt,
|
||||
)
|
||||
}.sortedBy { it.displayName.lowercase() }
|
||||
|
||||
val metricNotes =
|
||||
LocalCache.filter(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
)
|
||||
|
||||
val decrypted =
|
||||
metricNotes.mapNotNull { note ->
|
||||
val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null
|
||||
val payload =
|
||||
if (decryptCache.containsKey(event.id)) {
|
||||
decryptCache[event.id]
|
||||
} else {
|
||||
event.decryptOrNull(signer).also { decryptCache[event.id] = it }
|
||||
} ?: return@mapNotNull null
|
||||
val agent = event.agentPubKey() ?: event.pubKey
|
||||
agent to payload
|
||||
}
|
||||
|
||||
_metrics.value = AgentFleetAggregator.aggregate(decrypted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a live subscription to the owner's ephemeral observer telemetry frames
|
||||
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
|
||||
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
|
||||
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
|
||||
* to tear the subscription down. Observer frames are never stored by relays or
|
||||
* [LocalCache], so this live REQ is the only way to see them.
|
||||
*/
|
||||
fun startObserving() {
|
||||
val account = account ?: return
|
||||
if (observerJob != null) return
|
||||
|
||||
val relays = BuzzRelayDialect.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val signer = account.signer
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
|
||||
|
||||
observerJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
val fresh =
|
||||
events
|
||||
.filterIsInstance<ObserverFrameEvent>()
|
||||
.filter { observerSeen.add(it.id) }
|
||||
if (fresh.isEmpty()) return@collect
|
||||
|
||||
val rows =
|
||||
fresh.mapNotNull { frame ->
|
||||
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
|
||||
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
|
||||
ObserverRow(
|
||||
seq = payload.seq,
|
||||
timestamp = payload.timestamp,
|
||||
kind = payload.kind,
|
||||
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
|
||||
sessionId = payload.sessionId,
|
||||
turnId = payload.turnId,
|
||||
)
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
_observerFrames.update { existing ->
|
||||
(rows + existing)
|
||||
.sortedByDescending { it.timestamp }
|
||||
.take(MAX_OBSERVER_ROWS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
observerJob?.cancel()
|
||||
observerJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
@Immutable
|
||||
data class ObserverRow(
|
||||
val seq: Long,
|
||||
val timestamp: String,
|
||||
val kind: String,
|
||||
val agentPubKey: HexKey,
|
||||
val sessionId: String?,
|
||||
val turnId: String?,
|
||||
)
|
||||
|
||||
/** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */
|
||||
@Immutable
|
||||
data class PersonaCard(
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val model: String?,
|
||||
val runtime: String?,
|
||||
val provider: String?,
|
||||
val systemPrompt: String?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
|
||||
const val MAX_OBSERVER_ROWS = 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import 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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
|
||||
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
|
||||
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
|
||||
* in-progress edit survives recomposition.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentPersonaEditScreen(
|
||||
slug: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
|
||||
viewModel.bind(accountViewModel.account, slug)
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val title = if (slug == null) "New persona" else "Edit persona"
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(title, nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.slug,
|
||||
onValueChange = viewModel::onSlugChange,
|
||||
label = { Text("Slug (persona id)") },
|
||||
singleLine = true,
|
||||
enabled = !state.slugLocked,
|
||||
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.displayName,
|
||||
onValueChange = viewModel::onDisplayNameChange,
|
||||
label = { Text("Display name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.systemPrompt,
|
||||
onValueChange = viewModel::onSystemPromptChange,
|
||||
label = { Text("System prompt") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.model,
|
||||
onValueChange = viewModel::onModelChange,
|
||||
label = { Text("Model (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.provider,
|
||||
onValueChange = viewModel::onProviderChange,
|
||||
label = { Text("Provider (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.runtime,
|
||||
onValueChange = viewModel::onRuntimeChange,
|
||||
label = { Text("Runtime (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.avatarUrl,
|
||||
onValueChange = viewModel::onAvatarUrlChange,
|
||||
label = { Text("Avatar URL (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.save(onDone = nav::popBack) },
|
||||
enabled = state.canSave,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.isSaving) "Publishing…" else "Publish persona")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
|
||||
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
|
||||
* owner and addressed by `(owner, 30175, slug)`.
|
||||
*
|
||||
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
||||
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
|
||||
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
|
||||
* different slug is a different persona.
|
||||
*
|
||||
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
||||
* none are known), so the workspace and other members see it.
|
||||
*/
|
||||
class AgentPersonaEditViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
||||
private var existing: PersonaContent? = null
|
||||
private var editingSlug: String? = null
|
||||
|
||||
private val _state = MutableStateFlow(FormState())
|
||||
val state: StateFlow<FormState> = _state.asStateFlow()
|
||||
|
||||
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
|
||||
fun bind(
|
||||
account: Account,
|
||||
slug: String?,
|
||||
) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
|
||||
if (slug.isNullOrBlank()) return
|
||||
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
|
||||
val event = note?.event as? PersonaEvent ?: return
|
||||
val content = event.personaOrNull() ?: return
|
||||
existing = content
|
||||
editingSlug = slug
|
||||
_state.value =
|
||||
FormState(
|
||||
slug = slug,
|
||||
slugLocked = true,
|
||||
displayName = content.displayName,
|
||||
systemPrompt = content.systemPrompt.orEmpty(),
|
||||
model = content.model.orEmpty(),
|
||||
runtime = content.runtime.orEmpty(),
|
||||
provider = content.provider.orEmpty(),
|
||||
avatarUrl = content.avatarUrl.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
|
||||
|
||||
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
|
||||
|
||||
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
|
||||
|
||||
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
|
||||
|
||||
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
|
||||
|
||||
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
|
||||
|
||||
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
||||
|
||||
/**
|
||||
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
||||
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
||||
*/
|
||||
fun save(onDone: () -> Unit) {
|
||||
val account = account ?: return
|
||||
val current = _state.value
|
||||
|
||||
val slug = current.slug.trim()
|
||||
if (!isValidSlug(slug)) {
|
||||
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
|
||||
return
|
||||
}
|
||||
if (current.displayName.isBlank()) {
|
||||
_state.update { it.copy(error = "Display name is required.") }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base = existing ?: PersonaContent(displayName = current.displayName)
|
||||
val content =
|
||||
base.copy(
|
||||
displayName = current.displayName.trim(),
|
||||
systemPrompt = current.systemPrompt.blankToNull(),
|
||||
model = current.model.blankToNull(),
|
||||
runtime = current.runtime.blankToNull(),
|
||||
provider = current.provider.blankToNull(),
|
||||
avatarUrl = current.avatarUrl.blankToNull(),
|
||||
)
|
||||
|
||||
val template = PersonaEvent.build(content, slug)
|
||||
account.signAndSendPrivatelyOrBroadcast(template) {
|
||||
BuzzRelayDialect.flow.value
|
||||
.toList()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) { onDone() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
|
||||
|
||||
data class FormState(
|
||||
val slug: String = "",
|
||||
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
|
||||
val slugLocked: Boolean = false,
|
||||
val displayName: String = "",
|
||||
val systemPrompt: String = "",
|
||||
val model: String = "",
|
||||
val runtime: String = "",
|
||||
val provider: String = "",
|
||||
val avatarUrl: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
|
||||
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
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.stringRes
|
||||
|
||||
/**
|
||||
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
|
||||
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
|
||||
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
|
||||
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
|
||||
* newer revision lands.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzCanvasScreen(
|
||||
channelId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
|
||||
// Re-read canvasNote whenever a newer canvas is consumed.
|
||||
val version by state.canvasUpdates.collectAsStateWithLifecycle()
|
||||
val canvas = remember(version) { state.canvasNote }
|
||||
val content = canvas?.event?.content
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
|
||||
) { padding ->
|
||||
if (content.isNullOrBlank() || canvas == null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_canvas_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val bgColor = remember { mutableStateOf(Color.Transparent) }
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = bgColor,
|
||||
id = canvas.idHex,
|
||||
callbackUri = canvas.toNostrUri(),
|
||||
authorPubKey = canvas.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import 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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
|
||||
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
|
||||
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
|
||||
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
|
||||
* Agent Console) up top.
|
||||
*
|
||||
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
|
||||
* at login), so no per-screen fetch is needed — it just projects the joined set.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzWorkspacesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
|
||||
|
||||
val workspaces =
|
||||
remember(joined, buzzRelays) {
|
||||
joined
|
||||
.mapNotNull { tag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
|
||||
if (relay !in buzzRelays) return@mapNotNull null
|
||||
GroupId(tag.groupId, relay)
|
||||
}.sortedBy { it.id }
|
||||
}
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
|
||||
|
||||
if (workspaces.isEmpty()) {
|
||||
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
|
||||
WorkspaceRow(groupId, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
|
||||
@Composable
|
||||
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
|
||||
@Composable
|
||||
private fun WorkspaceRow(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val hasUnread by remember(groupId) {
|
||||
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
|
||||
}.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val name = channel.toBestDisplayName()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
Card(
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WorkspaceAvatar(name = name, seed = groupId.id)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = groupId.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (hasUnread) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A round monogram whose color is derived deterministically from the workspace id. */
|
||||
@Composable
|
||||
private fun WorkspaceAvatar(
|
||||
name: String,
|
||||
seed: String,
|
||||
) {
|
||||
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
|
||||
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
|
||||
val initial =
|
||||
remember(name) {
|
||||
name
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.toString() ?: "#"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
|
||||
@Composable
|
||||
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.size(2.dp))
|
||||
FilledTonalButton(onClick = onBrowse) {
|
||||
Text(stringRes(R.string.buzz_workspaces_browse))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
|
||||
// callers with no external jump affordance.
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
|
||||
listStateObserver(listState)
|
||||
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val feedState by feed.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -233,6 +238,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,
|
||||
@@ -245,19 +268,7 @@ fun ChatFeedLoaded(
|
||||
onHighlightFinished = { highlightedNoteId.value = null },
|
||||
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,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var wantsToEditPost by remember { mutableStateOf(false) }
|
||||
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
|
||||
// Stage one: the primary chat action (reply / edit draft) is always shown.
|
||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||
|
||||
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
|
||||
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
|
||||
// authorship restricts it to my own messages.
|
||||
val canEditBuzz =
|
||||
onWantsToEditBuzz != null &&
|
||||
note.event is StreamMessageV2Event &&
|
||||
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
if (canEditBuzz) {
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
|
||||
onWantsToEditBuzz!!(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlers =
|
||||
NoteActionHandlers(
|
||||
onShare = { showShareSheet = true },
|
||||
|
||||
@@ -56,6 +56,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
|
||||
@@ -65,8 +70,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
|
||||
@@ -104,6 +114,9 @@ fun ChatroomMessageCompose(
|
||||
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
||||
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
||||
onBlank: (@Composable () -> Unit)? = null,
|
||||
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
|
||||
// every non-Buzz chat surface, which hides the action.
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
|
||||
// chat message renders with the chat reply design instead of the quoted-note card.
|
||||
@@ -129,6 +142,19 @@ fun ChatroomMessageCompose(
|
||||
RenderChatClip(baseNote, accountViewModel, nav)
|
||||
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
|
||||
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
|
||||
} else if (event is SystemMessageEvent) {
|
||||
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
|
||||
RenderBuzzSystemMessage(baseNote)
|
||||
} else if (event is StreamMessageDiffEvent) {
|
||||
// Buzz kind-40008: a code/text diff pushed into the channel.
|
||||
RenderBuzzDiff(baseNote)
|
||||
} else if (event is ForumVoteEvent) {
|
||||
// Buzz kind-45002: a forum up/down vote.
|
||||
RenderBuzzForumVote(baseNote)
|
||||
} else if (isBuzzActivityRow(event)) {
|
||||
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
|
||||
// especially must be caught here — their content is JSON, not chat text.
|
||||
RenderBuzzActivityRow(baseNote)
|
||||
} else {
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
@@ -145,6 +171,7 @@ fun ChatroomMessageCompose(
|
||||
onHighlightFinished,
|
||||
groupPosition,
|
||||
previousNoteId,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +202,7 @@ fun NormalChatNote(
|
||||
onHighlightFinished: (() -> Unit)? = null,
|
||||
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
|
||||
previousNoteId: HexKey? = null,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
|
||||
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
|
||||
@@ -305,6 +333,7 @@ fun NormalChatNote(
|
||||
onDismiss = onDismiss,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
},
|
||||
reactionsRow =
|
||||
@@ -582,7 +611,17 @@ fun NoteRow(
|
||||
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
|
||||
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
|
||||
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
|
||||
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
else -> {
|
||||
// Buzz channels overlay kind-40003 edits on their messages: when one
|
||||
// exists, render the newest edit's content instead of the stale
|
||||
// original. Null for every non-Buzz chat surface.
|
||||
val buzzEdit = observeBuzzEdit(note)
|
||||
if (buzzEdit != null) {
|
||||
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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.feed.types
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
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.layouts.ChatSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
|
||||
/**
|
||||
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
|
||||
* arrive. Returns null when the message is unedited or has no channel scope.
|
||||
*
|
||||
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
|
||||
* (a Buzz UUID) rather than any channel object: the state exists independently of
|
||||
* when — or whether — the channel materialized, so a row composed before the first
|
||||
* edit arrived still starts rendering overlays the moment one lands.
|
||||
*/
|
||||
@Composable
|
||||
fun observeBuzzEdit(note: Note): Note? {
|
||||
val channelId = remember(note) { note.event?.groupId() } ?: return null
|
||||
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
|
||||
// Subscribing to the version counter is what re-runs editFor on new arrivals.
|
||||
val version by state.editUpdates.collectAsState()
|
||||
return remember(note, version) { state.editFor(note.idHex) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
|
||||
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
|
||||
* marker, mirroring Buzz's own last-write-wins presentation.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzEditedNote(
|
||||
note: Note,
|
||||
editNote: Note,
|
||||
canPreview: Boolean,
|
||||
innerQuote: Boolean,
|
||||
bgColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// The edit note may still be loading; fall back to the original rendering rather
|
||||
// than committing to an edited branch that would show a blank row.
|
||||
val content = editNote.event?.content
|
||||
if (content == null) {
|
||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
Column {
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = if (innerQuote) 0 else 1,
|
||||
modifier = Modifier,
|
||||
tags = tags,
|
||||
backgroundColor = bgColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
authorPubKey = note.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_message_edited),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
|
||||
* narrates the room rather than speaking in it, so it renders as a centered system
|
||||
* line like the NIP-28 admin events, from the relay-signed JSON payload.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzSystemMessage(note: Note) {
|
||||
val event = note.event as? SystemMessageEvent ?: return
|
||||
// Relay-emitted machine text (join/leave/topic); shown as-is rather than through
|
||||
// string resources — the payload vocabulary is Buzz's, not ours to translate yet.
|
||||
val text =
|
||||
remember(event) {
|
||||
val payload = event.payload()
|
||||
when (payload?.type) {
|
||||
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
|
||||
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
|
||||
else -> payload?.type?.replace('_', ' ')
|
||||
} ?: event.content.take(120)
|
||||
}
|
||||
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
|
||||
* narrates as a centered system line rather than a chat bubble. Huddle events in
|
||||
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
|
||||
* chat message would show raw `{"ephemeral_channel_id":…}`.
|
||||
*/
|
||||
fun isBuzzActivityRow(event: Event?): Boolean =
|
||||
event is JobRequestEvent ||
|
||||
event is JobAcceptedEvent ||
|
||||
event is JobProgressEvent ||
|
||||
event is JobResultEvent ||
|
||||
event is JobCancelEvent ||
|
||||
event is JobErrorEvent ||
|
||||
event is HuddleStartedEvent ||
|
||||
event is HuddleParticipantJoinedEvent ||
|
||||
event is HuddleParticipantLeftEvent ||
|
||||
event is HuddleEndedEvent
|
||||
|
||||
/**
|
||||
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
|
||||
* label is derived from the kind; job progress/result/error also append a short content
|
||||
* snippet (the human-readable status/result/error the agent wrote).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzActivityRow(note: Note) {
|
||||
val event = note.event ?: return
|
||||
val text =
|
||||
remember(event) {
|
||||
when (event) {
|
||||
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
|
||||
is JobAcceptedEvent -> "⚙ job accepted"
|
||||
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
|
||||
is JobResultEvent -> "⚙ job result" + event.result().snippet()
|
||||
is JobCancelEvent -> "⚙ job cancelled"
|
||||
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
|
||||
is HuddleStartedEvent -> "🔊 huddle started"
|
||||
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
|
||||
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
|
||||
is HuddleEndedEvent -> "🔊 huddle ended"
|
||||
else -> event.content.take(120)
|
||||
}
|
||||
}
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
|
||||
private fun String.snippet(): String {
|
||||
val oneLine = trim().replace('\n', ' ')
|
||||
if (oneLine.isEmpty()) return ""
|
||||
return ": " + if (oneLine.length > 80) oneLine.take(80) + "…" else oneLine
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
|
||||
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
|
||||
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
|
||||
* `+`/`-` gutters into wrapped prose).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzDiff(note: Note) {
|
||||
val event = note.event as? StreamMessageDiffEvent ?: return
|
||||
val meta = remember(event) { event.diffMeta() }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
val header =
|
||||
remember(meta) {
|
||||
meta?.let {
|
||||
buildString {
|
||||
it.filePath?.let(::append)
|
||||
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
|
||||
it.prNumber?.let { pr -> append(" · PR #$pr") }
|
||||
it.commitSha
|
||||
?.takeIf { sha -> sha.isNotBlank() }
|
||||
?.let { sha -> append(" · ${sha.take(8)}") }
|
||||
}.ifBlank { null }
|
||||
}
|
||||
}
|
||||
if (header != null) {
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
if (meta?.truncated == true) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_diff_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
|
||||
@Composable
|
||||
fun RenderBuzzForumVote(note: Note) {
|
||||
val event = note.event as? ForumVoteEvent ?: return
|
||||
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
|
||||
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
|
||||
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
|
||||
* it slides in when someone starts typing and out when they stop (heartbeats age out of
|
||||
* the freshness window). Own typing is filtered by [myPubkey].
|
||||
*
|
||||
* The three animated dots are a small modern flourish; the wording collapses to a count
|
||||
* once more than two people type at once so the row never overflows.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzTypingIndicator(
|
||||
channelId: String,
|
||||
myPubkey: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
|
||||
// but only while this channel actually has any — an idle channel never wakes the loop.
|
||||
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||
val raw = typingByChannel[channelId]
|
||||
LaunchedEffect(channelId, raw) {
|
||||
if (raw.isNullOrEmpty()) return@LaunchedEffect
|
||||
while (true) {
|
||||
nowSecs = TimeUtils.now()
|
||||
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
|
||||
delay(2000L)
|
||||
}
|
||||
}
|
||||
|
||||
val typers =
|
||||
remember(raw, myPubkey, nowSecs) {
|
||||
(raw ?: emptyMap())
|
||||
.filterKeys { it != myPubkey }
|
||||
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
|
||||
.keys
|
||||
.sorted()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = typers.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TypingDots()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = typingLabel(typers, accountViewModel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun typingLabel(
|
||||
typers: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String =
|
||||
when (typers.size) {
|
||||
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
|
||||
2 ->
|
||||
stringRes(
|
||||
R.string.buzz_typing_two,
|
||||
rememberTypistName(typers[0], accountViewModel),
|
||||
rememberTypistName(typers[1], accountViewModel),
|
||||
)
|
||||
else -> stringRes(R.string.buzz_typing_many)
|
||||
}
|
||||
|
||||
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
|
||||
@Composable
|
||||
private fun TypingDots() {
|
||||
val transition = rememberInfiniteTransition(label = "buzz-typing")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(3) { index ->
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900, delayMillis = index * 150),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot-$index",
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(5.dp)
|
||||
.graphicsLayer {
|
||||
translationY = -3f * phase
|
||||
val s = 0.7f + 0.3f * phase
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}.alpha(0.4f + 0.6f * phase)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
|
||||
@Composable
|
||||
private fun rememberTypistName(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
|
||||
val info by observeUserInfo(user, accountViewModel)
|
||||
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
@@ -216,6 +217,7 @@ private fun ChannelView(
|
||||
avoidDraft = newPostModel.draftTag,
|
||||
onWantsToReply = newPostModel::reply,
|
||||
onWantsToEditDraft = newPostModel::editFromDraft,
|
||||
onWantsToEditBuzz = newPostModel::editBuzzMessage,
|
||||
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.
|
||||
@@ -251,6 +253,17 @@ private fun ChannelView(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
|
||||
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
|
||||
// Buzz-only); a no-op otherwise.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
BuzzTypingIndicator(
|
||||
channelId = channel.groupId.id,
|
||||
myPubkey = accountViewModel.userProfile().pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
// NIP-29 relays reject writes from non-members, so only show the composer when the
|
||||
|
||||
@@ -65,6 +65,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
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
|
||||
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.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
|
||||
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
|
||||
// the moment the first canvas is consumed, without swapping the channel object.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
|
||||
val canvasVersion by canvasState.canvasUpdates.collectAsState()
|
||||
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
|
||||
if (hasCanvas) {
|
||||
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dashboard,
|
||||
contentDescription = stringRes(R.string.buzz_canvas_title),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val naddr = channel.toNAddr()
|
||||
if (naddr != null) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
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
|
||||
@@ -45,19 +64,98 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
* See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan.
|
||||
*/
|
||||
|
||||
/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */
|
||||
val RELAY_GROUP_STATE_KINDS =
|
||||
/**
|
||||
* 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,
|
||||
GroupPinnedEvent.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)
|
||||
|
||||
/**
|
||||
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
|
||||
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
|
||||
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
|
||||
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
|
||||
* worse one — history pages fetched before the mark advance their cursors past ranges
|
||||
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
|
||||
*
|
||||
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
|
||||
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
|
||||
* - forum posts/votes/comments (45001-45003)
|
||||
* - agent jobs (43001-43006)
|
||||
* - huddle lifecycle (48100-48103)
|
||||
*
|
||||
* Consumption for every one of these already exists in `LocalCache` (see
|
||||
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
|
||||
* a group feed instead of only appearing if another subscription happened to fetch them.
|
||||
*/
|
||||
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
listOf(
|
||||
StreamMessageV2Event.KIND,
|
||||
StreamMessageEditEvent.KIND,
|
||||
StreamMessageDiffEvent.KIND,
|
||||
SystemMessageEvent.KIND,
|
||||
CanvasEvent.KIND,
|
||||
ForumPostEvent.KIND,
|
||||
ForumVoteEvent.KIND,
|
||||
ForumCommentEvent.KIND,
|
||||
JobRequestEvent.KIND,
|
||||
JobAcceptedEvent.KIND,
|
||||
JobProgressEvent.KIND,
|
||||
JobResultEvent.KIND,
|
||||
JobCancelEvent.KIND,
|
||||
JobErrorEvent.KIND,
|
||||
HuddleStartedEvent.KIND,
|
||||
HuddleParticipantJoinedEvent.KIND,
|
||||
HuddleParticipantLeftEvent.KIND,
|
||||
HuddleEndedEvent.KIND,
|
||||
)
|
||||
|
||||
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
|
||||
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
|
||||
|
||||
/**
|
||||
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
|
||||
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
|
||||
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
|
||||
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
|
||||
* and drops it). It matches nothing on a vanilla relay.
|
||||
*/
|
||||
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
|
||||
|
||||
/** Forum-thread kinds shown in a group's Threads tab. */
|
||||
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
|
||||
|
||||
@@ -69,13 +167,7 @@ val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, Threa
|
||||
* 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 =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
)
|
||||
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
|
||||
@@ -93,22 +185,21 @@ private fun byHostRelay(joined: Collection<GroupTag>): Map<NormalizedRelayUrl, L
|
||||
}
|
||||
|
||||
/**
|
||||
* State (39000-39005) for every joined group, **one `#d` filter per host relay** carrying that relay's
|
||||
* group ids. `since` is per-relay (replaceable events; a reconnect just re-confirms).
|
||||
* 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).map { (relay, ids) ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_STATE_KINDS,
|
||||
tags = mapOf(D_TAG to ids.distinct()),
|
||||
since = sinceForRelay(relay),
|
||||
),
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,7 +217,7 @@ fun buildRelayGroupJoinedChatTailFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -142,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -165,7 +256,7 @@ fun buildRelayGroupHistoryFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
until = until,
|
||||
limit = limit,
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
@@ -68,6 +69,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
@@ -85,6 +91,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
@@ -156,6 +163,11 @@ open class ChannelNewMessageViewModel :
|
||||
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
||||
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
||||
|
||||
// When set, the composer is editing an existing Buzz stream message (kind 40002):
|
||||
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
||||
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
||||
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
@@ -264,6 +276,26 @@ open class ChannelNewMessageViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
|
||||
* the next send as a kind-40003 edit of it. Editing and replying are mutually
|
||||
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
|
||||
* kind-40002 messages on a Buzz relay.
|
||||
*/
|
||||
fun editBuzzMessage(note: Note) {
|
||||
replyTo.value = null
|
||||
replyMode.value = ReplyMode.INLINE
|
||||
editingBuzzMessage.value = note
|
||||
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun clearBuzzEdit() {
|
||||
editingBuzzMessage.value = null
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun editFromDraft(draft: Note) {
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
@@ -525,7 +557,12 @@ open class ChannelNewMessageViewModel :
|
||||
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
|
||||
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
|
||||
// not the raw text — so replying in a thread never silently drops any of them.
|
||||
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
|
||||
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
|
||||
// registry — a minichat comment sent to a workspace channel would be refused
|
||||
// by the relay AFTER the composer already cleared. Buzz replies always go
|
||||
// through the 40002 branch with its thread markers instead.
|
||||
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
||||
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
||||
if (minichatParent != null) {
|
||||
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
|
||||
if (channel is RelayGroupChannel) {
|
||||
@@ -676,6 +713,56 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
editingBuzzMessage.value != null -> {
|
||||
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
|
||||
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
||||
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
||||
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
||||
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
||||
// re-renders with this content.
|
||||
val target = editingBuzzMessage.value!!
|
||||
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
||||
// Buzz workspace message: the native kind is 40002 (stream message v2)
|
||||
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
|
||||
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
|
||||
// collapsing to a single "reply" when the parent IS the root — mirrors
|
||||
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
|
||||
// author. The dialect check comes from BuzzRelayDialect (marked off
|
||||
// verified Buzz events), not a channel subtype: channel instances are
|
||||
// captured by screens for their whole life, so the dialect must be
|
||||
// able to flip mid-session without swapping objects.
|
||||
StreamMessageV2Event.build(channel.groupId.id, tagger.message) {
|
||||
replyTo.value?.let { parent ->
|
||||
// The parent's root marker (when it is itself a nested reply),
|
||||
// else the parent's OWN reply target (a direct reply's collapsed
|
||||
// form carries the root as its "reply" marker — Buzz's relay
|
||||
// validates ancestry and rejects a mis-derived root), else the
|
||||
// parent starts the thread.
|
||||
val parentTags = parent.event?.tags
|
||||
val root =
|
||||
parentTags?.buzzThreadRoot()
|
||||
?: parentTags?.buzzThreadReply()
|
||||
?: parent.idHex
|
||||
buzzThread(root, parent.idHex)
|
||||
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
|
||||
}
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel -> {
|
||||
// NIP-29 group message: a kind-9 chat scoped to the group with an
|
||||
// `h` tag. The event is published only to the group's host relay
|
||||
@@ -729,6 +816,7 @@ open class ChannelNewMessageViewModel :
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyTo.value = null
|
||||
editingBuzzMessage.value = null
|
||||
|
||||
urlPreview = null
|
||||
|
||||
|
||||
@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
||||
@@ -87,6 +98,35 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz edit mode: a banner reminding the user the next send replaces an existing
|
||||
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
|
||||
channelScreenModel.editingBuzzMessage.value?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_editing_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.cancel),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelScreenModel.uploadState?.let { uploading ->
|
||||
uploading.multiOrchestrator?.let { _ ->
|
||||
ChannelFileUploadDialog(
|
||||
@@ -121,9 +161,28 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
|
||||
val lastTypingSecs = remember { longArrayOf(0L) }
|
||||
|
||||
ThinPaddingTextField(
|
||||
state = channelScreenModel.message,
|
||||
onTextChanged = { channelScreenModel.onMessageChanged() },
|
||||
onTextChanged = {
|
||||
channelScreenModel.onMessageChanged()
|
||||
// Buzz typing indicator: fire a throttled heartbeat while composing in a
|
||||
// Buzz workspace channel, so other members see "… is typing". No-op on any
|
||||
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
|
||||
val channel = channelScreenModel.channel
|
||||
if (channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
channelScreenModel.message.text.isNotEmpty()
|
||||
) {
|
||||
val now = TimeUtils.now()
|
||||
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
|
||||
lastTypingSecs[0] = now
|
||||
accountViewModel.sendBuzzTyping(channel)
|
||||
}
|
||||
}
|
||||
},
|
||||
onContentReceived = { uri, mimeType ->
|
||||
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.embed
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
|
||||
/**
|
||||
* Makes every embedded tab (browser / nsite / napplet) follow an account switch.
|
||||
*
|
||||
* Each account gets its own WebView storage jar, and that partition is chosen once, when the WebView is
|
||||
* constructed — so a warm session built for account A can never serve account B. This asks
|
||||
* [EmbeddedTabHost] to tear down and re-warm every session whenever the active account's profile changes;
|
||||
* see [EmbeddedTabHost.rebuildIfProfileChanged] for why reusing them also leaves the tab blank.
|
||||
*
|
||||
* The whole logged-in subtree is recreated per account, so this composable is itself brand new after a
|
||||
* switch — which is exactly why the "which account are the warm sessions built for" marker lives in
|
||||
* [EmbeddedTabHost] (process-scoped) and not in a `remember` here.
|
||||
*
|
||||
* Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader], before them so the stale sessions are
|
||||
* dropped ahead of the first preload sweep. Draws nothing.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@Composable
|
||||
fun EmbeddedTabAccountWatcher() {
|
||||
// Read the same opaque profile name the controllers stamp on their sessions, from the same source, so
|
||||
// the sessions we rebuild are guaranteed to be built against the account we just checked for.
|
||||
val profile = NappletWebViewProfiles.current()
|
||||
|
||||
// SideEffect, not LaunchedEffect: this must land in the SAME frame as the switch. A LaunchedEffect
|
||||
// dispatches through the composition's coroutine scope, and an account switch floods the main thread —
|
||||
// measured 3.0 s and 4.3 s of delay on a real device. For that whole window the tab layer had already
|
||||
// rebuilt every SandboxedSdkView against the surviving (now-dead) controllers, so every embedded tab
|
||||
// was a black rectangle. SideEffect runs at the end of the apply phase, so the stale sessions are torn
|
||||
// down and the epoch bumped before the next composition renders any surface.
|
||||
//
|
||||
// Safe to run on every recomposition: [EmbeddedTabHost.rebuildIfProfileChanged] is idempotent — it
|
||||
// compares against the profile the warm sessions were built for and no-ops when nothing changed.
|
||||
SideEffect { EmbeddedTabHost.rebuildIfProfileChanged(profile) }
|
||||
}
|
||||
@@ -57,12 +57,14 @@ object EmbeddedTabHost {
|
||||
private set
|
||||
|
||||
/**
|
||||
* Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed
|
||||
* WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change
|
||||
* means rebuilding the surface — the favorite screens and the preloader key their acquisition on this
|
||||
* so they re-acquire a freshly-themed session instead of the stale warm one.
|
||||
* Bumped whenever every warm session must be rebuilt from scratch (see [rebuildAll]) — a DARK/LIGHT
|
||||
* theme flip, or an account switch. Both the theme (`nightThemedContext`) and the per-account storage
|
||||
* profile ([com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles]) are locked in at WebView
|
||||
* construction and can't be changed on a live WebView, so following either means building a new one.
|
||||
* The favorite screens and the preloader key their acquisition on this, so they re-acquire a freshly
|
||||
* built session instead of the stale warm one.
|
||||
*/
|
||||
var themeEpoch by mutableStateOf(0)
|
||||
var rebuildEpoch by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
/** Window-space bounds of the active tab's reserved content area. */
|
||||
@@ -169,15 +171,48 @@ object EmbeddedTabHost {
|
||||
}
|
||||
|
||||
/**
|
||||
* The app theme changed: tear down every warm session (their WebViews are pinned to the old theme)
|
||||
* and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions.
|
||||
* Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen
|
||||
* re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface.
|
||||
* Something a WebView can only pick up at construction changed (the theme, or the account): tear down
|
||||
* every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly
|
||||
* built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant
|
||||
* its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface.
|
||||
*/
|
||||
fun rebuildAllForTheme() {
|
||||
fun rebuildAll() {
|
||||
val copy = warm.toList()
|
||||
warm.clear()
|
||||
copy.forEach { it.controller.teardown() }
|
||||
themeEpoch += 1
|
||||
rebuildEpoch += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
|
||||
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
|
||||
* the whole logged-in subtree is rebuilt per account (`key(pubKey)` in `SetAccountCentricViewModelStore`),
|
||||
* so a remembered "last applied" value would be re-seeded to the NEW account on the very first
|
||||
* composition after a switch and the change would never be detected.
|
||||
*/
|
||||
private var builtForProfile: String? = null
|
||||
private var profileSeeded = false
|
||||
|
||||
/**
|
||||
* Rebuilds every warm session when the active account changes, so all embedded apps follow the switch.
|
||||
*
|
||||
* A WebView's storage profile is fixed at construction (`WebViewCompat.setProfile` throws once it has
|
||||
* loaded content), so a live session can't be re-pointed at the new account's jar — it has to be
|
||||
* rebuilt. Rebuilding is also what makes the tab work at all after a switch: the account-keyed subtree
|
||||
* is recreated, which disposes each session's `SandboxedSdkView` and closes the sandbox-side session,
|
||||
* leaving the warm controller holding an already-consumed (and now dead) adapter. Reused as-is, it
|
||||
* would hand the fresh view no adapter and the tab would render permanently blank.
|
||||
*
|
||||
* Covers tabs that aren't on screen too: every warm session is torn down, and the preloader re-warms
|
||||
* the pinned ones against the new profile, so none can come back still bound to the old account.
|
||||
*/
|
||||
fun rebuildIfProfileChanged(profileName: String?) {
|
||||
if (profileSeeded && builtForProfile == profileName) return
|
||||
val isFirstCall = !profileSeeded
|
||||
profileSeeded = true
|
||||
builtForProfile = profileName
|
||||
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
|
||||
// needless bump would restart the preload sweep that is just getting going.
|
||||
if (!isFirstCall) rebuildAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,13 +215,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay
|
||||
// can't live in the screen. A spinner until a real page paints, or an error+retry when the load
|
||||
// failed or stalled, so a slow / blank / failed load isn't a bare black/white void.
|
||||
//
|
||||
// Gated on the active *id*, not on a live controller: right after [EmbeddedTabHost.rebuildAll] (an
|
||||
// account switch or a theme flip) the active tab has no session at all for a frame or more, and a
|
||||
// SandboxedSdkView with no adapter paints nothing but its background — a black rectangle that reads
|
||||
// as broken. Treat "no session yet" as "still loading" so the spinner covers that window too.
|
||||
val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller
|
||||
if (activeController != null && bounds.width > 0f && bounds.height > 0f) {
|
||||
var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) }
|
||||
if (activeId != null && bounds.width > 0f && bounds.height > 0f) {
|
||||
var loadStatus by remember(activeId) { mutableStateOf(activeController?.loadStatus ?: EmbeddedLoadStatus()) }
|
||||
var timedOut by remember(activeId) { mutableStateOf(false) }
|
||||
DisposableEffect(activeId, activeController) {
|
||||
activeController.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController.onLoadStatusChanged = null }
|
||||
// No session yet — the tab is mid-rebuild after an account switch, or being warmed for the
|
||||
// first time. Fall back to the "still loading" state so the spinner covers the surface
|
||||
// instead of leaving a bare black rectangle where a dead/absent adapter paints nothing.
|
||||
loadStatus = activeController?.loadStatus ?: EmbeddedLoadStatus()
|
||||
activeController?.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController?.onLoadStatusChanged = null }
|
||||
}
|
||||
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
|
||||
LaunchedEffect(activeId, loadStatus) {
|
||||
@@ -241,10 +250,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
).size(bounds.width.toDp(), bounds.height.toDp()),
|
||||
) {
|
||||
EmbeddedLoadOverlay(
|
||||
failed = loadStatus.failed || timedOut,
|
||||
// A tab with no session yet can't have failed — it hasn't started. Keep it on
|
||||
// the spinner so a re-arming tab never flashes an error the user can't act on.
|
||||
failed = activeController != null && (loadStatus.failed || timedOut),
|
||||
onRetry = {
|
||||
timedOut = false
|
||||
activeController.retry()
|
||||
activeController?.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.embed
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
|
||||
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
|
||||
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
|
||||
private const val PRELOAD_ATTEMPTS = 15
|
||||
private const val PRELOAD_RETRY_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Process-scoped owner of the bottom-bar warm-up sweep, driven by [EmbeddedTabPreloader].
|
||||
*
|
||||
* **Why this isn't just a `LaunchedEffect` in the composable.** After an account switch every warm
|
||||
* session is torn down ([EmbeddedTabHost.rebuildAll]) and must be rebuilt against the new storage
|
||||
* profile — until that happens the tabs have nothing to show. A `LaunchedEffect` dispatches its body
|
||||
* through the composition's coroutine scope, and an account switch floods the main thread: the same
|
||||
* construct made [EmbeddedTabAccountWatcher] fire 3-4 s late before it became a `SideEffect`. Waiting
|
||||
* that long to even *start* the re-warm is what left the tab blank for seconds after a switch.
|
||||
*
|
||||
* So the **kickoff** is synchronous — [request] is called from a `SideEffect`, in the same apply phase
|
||||
* that bumped the epoch — while the sweep itself stays a coroutine, because it genuinely has to suspend
|
||||
* (it awaits the network-registry hydration, then retries pending favorites for ~45 s).
|
||||
* [CoroutineStart.UNDISPATCHED] on [Dispatchers.Main.immediate] means the body runs *inline* up to its
|
||||
* first real suspension, so on a re-warm (registries already hydrated, so `awaitReady` returns without
|
||||
* suspending) the first tab is rebuilt before [request] even returns.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
object EmbeddedTabPreloadSweeper {
|
||||
private data class SweepKey(
|
||||
val favoriteIds: List<String>,
|
||||
val backgroundColor: Int,
|
||||
val rebuildEpoch: Int,
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||
private var job: Job? = null
|
||||
private var lastKey: SweepKey? = null
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the sweep for [favoriteIds]. Idempotent: repeated calls with the same inputs
|
||||
* no-op, so it is safe to call from a `SideEffect` that runs on every recomposition. A changed
|
||||
* [rebuildEpoch] — a theme flip or an account switch — cancels the in-flight sweep and starts a fresh
|
||||
* one, which is what re-warms the tabs the user never opened so none survives bound to the old
|
||||
* account's storage profile.
|
||||
*/
|
||||
fun request(
|
||||
context: Context,
|
||||
favoriteIds: List<String>,
|
||||
backgroundColor: Int,
|
||||
rebuildEpoch: Int,
|
||||
) {
|
||||
val key = SweepKey(favoriteIds, backgroundColor, rebuildEpoch)
|
||||
if (lastKey == key) return
|
||||
lastKey = key
|
||||
job?.cancel()
|
||||
job = null
|
||||
if (favoriteIds.isEmpty()) return
|
||||
// The sweep outlives any single composition, so it must not pin an Activity.
|
||||
val appContext = context.applicationContext
|
||||
job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
sweep(appContext, favoriteIds, backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.sweep(
|
||||
context: Context,
|
||||
favoriteIds: List<String>,
|
||||
backgroundColor: Int,
|
||||
) {
|
||||
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
|
||||
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
|
||||
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers. On a re-warm these
|
||||
// are already hydrated, so they return without suspending and the first tab is built inline.
|
||||
WebAppNetworkRegistry.init(context)
|
||||
NappletNetworkRegistry.init(context)
|
||||
WebAppNetworkRegistry.awaitReady()
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
|
||||
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
|
||||
// so the sweep doesn't monopolize the frame and jank the paint that follows.
|
||||
yield()
|
||||
}
|
||||
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
|
||||
delay(PRELOAD_RETRY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -33,19 +33,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.yield
|
||||
|
||||
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
|
||||
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
|
||||
private const val PRELOAD_ATTEMPTS = 15
|
||||
private const val PRELOAD_RETRY_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Warms every bottom-bar favorite at startup so the first tap is instant — the surfaces are built,
|
||||
@@ -54,7 +43,9 @@ private const val PRELOAD_RETRY_MS = 3_000L
|
||||
*
|
||||
* Mount once next to [EmbeddedTabLayer]. Draws nothing; it just drives acquisition. Napplet favorites
|
||||
* whose events haven't synced yet, and Tor-routed sites whose proxy isn't up yet, are retried for a
|
||||
* bounded window (the latter so a clearnet preload can never race ahead of Tor).
|
||||
* bounded window (the latter so a clearnet preload can never race ahead of Tor) — that retry loop, and
|
||||
* everything else that has to suspend, lives in [EmbeddedTabPreloadSweeper]; this composable only kicks
|
||||
* it off, synchronously.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@Composable
|
||||
@@ -66,40 +57,37 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) {
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoriteIds = bottomBarItems.favoriteIds()
|
||||
|
||||
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
|
||||
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
|
||||
// Subscribe to the epoch here (a read inside a SideEffect wouldn't recompose us), so a bump that lands
|
||||
// outside our apply phase — [EmbeddedTabThemeWatcher] bumps it from a LaunchedEffect — still re-runs the
|
||||
// kickoff below. The SideEffect re-reads it, so a bump in the SAME apply phase is picked up immediately.
|
||||
val observedEpoch = EmbeddedTabHost.rebuildEpoch
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
LaunchedEffect(configuration) {
|
||||
|
||||
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
|
||||
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
|
||||
// Synchronous, and declared BEFORE the kickoff, because the kickoff is synchronous too: as a
|
||||
// LaunchedEffect this would now land *after* the first preload and hand it the off-screen fallback.
|
||||
SideEffect {
|
||||
with(density) {
|
||||
EmbeddedTabHost.seedBoundsIfUnset(Rect(0f, 0f, configuration.screenWidthDp.dp.toPx(), configuration.screenHeightDp.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch,
|
||||
// so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown).
|
||||
LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) {
|
||||
if (favoriteIds.isEmpty()) return@LaunchedEffect
|
||||
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
|
||||
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
|
||||
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers.
|
||||
WebAppNetworkRegistry.init(context)
|
||||
NappletNetworkRegistry.init(context)
|
||||
WebAppNetworkRegistry.awaitReady()
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
|
||||
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
|
||||
// so the startup sweep doesn't monopolize the frame and jank the first paint.
|
||||
yield()
|
||||
}
|
||||
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
|
||||
delay(PRELOAD_RETRY_MS)
|
||||
}
|
||||
// Re-warms after a theme flip or an account switch: [rebuildAll] tears down the warm sessions and bumps
|
||||
// the epoch, so this sweep re-acquires them freshly built (keying on the epoch also orders it after the
|
||||
// teardown). This is also what re-warms tabs the user never opened, so none survives bound to the old
|
||||
// account's storage profile.
|
||||
//
|
||||
// SideEffect, not LaunchedEffect: [EmbeddedTabAccountWatcher] tears the sessions down in the apply phase
|
||||
// of the switch, and until this sweep runs there is nothing for the tab to show. A LaunchedEffect
|
||||
// dispatches through the composition's scope, and an account switch floods the main thread — the very
|
||||
// congestion that made the watcher itself fire 3-4 s late. Running here re-arms the tabs in the same
|
||||
// frame that dropped them. The epoch is re-read inside the lambda so we see the watcher's bump (its
|
||||
// SideEffect is ordered before ours), and [EmbeddedTabPreloadSweeper.request] is idempotent, so running
|
||||
// on every recomposition is free.
|
||||
SideEffect {
|
||||
EmbeddedTabPreloadSweeper.request(context, favoriteIds, backgroundColor, maxOf(observedEpoch, EmbeddedTabHost.rebuildEpoch))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fun EmbeddedTabThemeWatcher() {
|
||||
LaunchedEffect(resolvedDark) {
|
||||
if (applied.value != resolvedDark) {
|
||||
applied.value = resolvedDark
|
||||
EmbeddedTabHost.rebuildAllForTheme()
|
||||
EmbeddedTabHost.rebuildAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.annotation.RequiresApi
|
||||
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.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
|
||||
@@ -72,9 +73,18 @@ class EmbeddedNostrAppController(
|
||||
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].
|
||||
*/
|
||||
private var adapterDelivered = false
|
||||
|
||||
// A single NappletHostService instance serves every embedded napplet tab, so each controller stamps
|
||||
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
|
||||
private val sessionId: String = "napplet-${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 = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
|
||||
// A parked tab can be hidden (paused) before the service even binds, so the pause message is
|
||||
// dropped (no messenger yet). Remember the intent and replay it right after the session is created,
|
||||
@@ -129,6 +139,7 @@ class EmbeddedNostrAppController(
|
||||
serviceMessenger = null
|
||||
sandboxedSdkView = null
|
||||
pendingAdapter = null
|
||||
adapterDelivered = false
|
||||
onStateChanged = null
|
||||
onNotice = null
|
||||
onImeEvent = null
|
||||
@@ -136,14 +147,44 @@ class EmbeddedNostrAppController(
|
||||
onLoadStatusChanged = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.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 and the sandbox destroy its WebView, so the
|
||||
* adapter already handed out is dead and cannot serve the fresh view. A [SandboxedSdkView] with no
|
||||
* adapter never builds a ContentView/SurfaceView and paints nothing but its background colour, forever.
|
||||
*
|
||||
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
|
||||
* session; the reply arms this view. [sendCreateSession] re-stamps the CURRENT account's storage
|
||||
* profile, so re-arming can never resurrect the previous account's 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(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE))
|
||||
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 and can land AFTER this create. Reusing the id would let that late close
|
||||
// reap the session we just asked for, leaving the surface black.
|
||||
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
adapterDelivered = false
|
||||
sendCreateSession()
|
||||
}
|
||||
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +198,15 @@ class EmbeddedNostrAppController(
|
||||
val msg =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
|
||||
replyTo = incoming
|
||||
data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) }
|
||||
data =
|
||||
Bundle(params).apply {
|
||||
putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
|
||||
// Re-stamp the storage partition at SEND time rather than trusting the one baked
|
||||
// into [params] at construction: a session re-created for a new view (see
|
||||
// [attachView]) must land in the CURRENT account's jar, never the one this
|
||||
// controller was originally built for.
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
|
||||
@@ -172,7 +221,12 @@ class EmbeddedNostrAppController(
|
||||
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.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
|
||||
}
|
||||
}
|
||||
NappletEmbedContract.MSG_STATE -> {
|
||||
val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false
|
||||
|
||||
@@ -114,7 +114,7 @@ private fun EmbeddedNostrAppTab(
|
||||
|
||||
// Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted
|
||||
// on a theme flip (the params carry the resolved theme into the sandbox host's WebView).
|
||||
val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
|
||||
val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
|
||||
if (params == null) {
|
||||
UnavailableTab(coordinate, accountViewModel, nav)
|
||||
return
|
||||
@@ -134,7 +134,7 @@ private fun EmbeddedNostrAppTab(
|
||||
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
|
||||
|
||||
val controller =
|
||||
remember(id, EmbeddedTabHost.themeEpoch) {
|
||||
remember(id, EmbeddedTabHost.rebuildEpoch) {
|
||||
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
|
||||
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
|
||||
import com.vitorpamplona.amethyst.napplet.NappletBrokerService
|
||||
import com.vitorpamplona.amethyst.napplet.counterpartyLabel
|
||||
import com.vitorpamplona.amethyst.napplet.descriptionRes
|
||||
import com.vitorpamplona.amethyst.napplet.labelRes
|
||||
import com.vitorpamplona.amethyst.napplet.resolveNappletMeta
|
||||
@@ -117,7 +119,7 @@ fun ConnectedAppDetailScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
val untitled = stringResource(CommonsR.string.napplet_untitled)
|
||||
|
||||
@@ -219,7 +221,15 @@ fun ConnectedAppDetailScreen(
|
||||
PolicyPicker(
|
||||
selected = current.signerPolicy,
|
||||
onSelect = { newPolicy ->
|
||||
mutate { signerLedger.setPolicy(coordinate, newPolicy) }
|
||||
mutate {
|
||||
signerLedger.setPolicy(coordinate, newPolicy)
|
||||
// Live session grants are consulted BEFORE the policy, so tightening an app
|
||||
// to PARANOID would not have stopped it signing — the grant it already holds
|
||||
// short-circuits the check the new policy would fail. Changing the trust
|
||||
// level is a decision about how this app is treated from now on, so drop
|
||||
// what it is holding and let the new policy actually apply.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -238,7 +248,14 @@ fun ConnectedAppDetailScreen(
|
||||
OpOverrideRow(
|
||||
opKey = opKey,
|
||||
decision = decision,
|
||||
onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } },
|
||||
onRevoke = {
|
||||
mutate {
|
||||
signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate)
|
||||
// The persisted override is gone, but a live "allow for this session"
|
||||
// grant would keep authorizing this app until the broker dies.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +311,11 @@ fun ConnectedAppDetailScreen(
|
||||
signerLedger.revokeAll(coordinate)
|
||||
}
|
||||
capabilityLedger.revokeAll(identity)
|
||||
// Forgetting an app has to stop it signing *now*. The two ledgers above only
|
||||
// drop persisted + capability grants; the broker separately holds the signer's
|
||||
// in-memory "allow for this session" grants, which would otherwise keep the
|
||||
// app authorized for as long as any applet surface stays open.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
nav.popBack()
|
||||
},
|
||||
@@ -703,6 +725,7 @@ private fun NostrSignerOp.opLabel(): String =
|
||||
is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind)
|
||||
NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> stringResource(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -96,7 +96,7 @@ fun ConnectedAppsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
var items by remember { mutableStateOf<List<ConnectedAppEntry>?>(null) }
|
||||
|
||||
@@ -53,6 +53,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
@@ -70,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.note.NutzapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.ShowDonationCard
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
@@ -89,9 +93,21 @@ fun RenderCardFeed(
|
||||
routeForLastRead: String,
|
||||
scrollToEventId: String? = null,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
// Whether THIS feed drives the shared account history pager. False for an off-screen split tab / second
|
||||
// pane, so only the feed the user is actually looking at pages the pager (see #2 in the audit).
|
||||
drivesPaging: Boolean = true,
|
||||
) {
|
||||
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
// A genuinely empty feed has no rows for the look-ahead buffer driver to measure, so step every relay
|
||||
// one page at a time to hunt for the first notifications. Once cards appear the buffer driver takes
|
||||
// over. Gated on Empty only (never the transient Loading navigation flashes through), and only for the
|
||||
// active feed so an off-screen tab doesn't hunt on the shared pager.
|
||||
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
|
||||
if (drivesPaging) {
|
||||
BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() }
|
||||
}
|
||||
|
||||
// Direct switch instead of CrossfadeIfEnabled: the crossfade's `currentlyVisible`
|
||||
// accumulator can leave a previous `Loaded` instance composed alongside the new
|
||||
// one when refreshes (e.g. double-tap on the Notifications tab) bounce the
|
||||
@@ -116,6 +132,7 @@ fun RenderCardFeed(
|
||||
nav = nav,
|
||||
scrollToEventId = scrollToEventId,
|
||||
headerContent = headerContent,
|
||||
drivesPaging = drivesPaging,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,10 +166,30 @@ private fun FeedLoaded(
|
||||
nav: INav,
|
||||
scrollToEventId: String? = null,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
drivesPaging: Boolean = true,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val openPolls by polls.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Infinite-scroll backward pagination of the notifications history. All the driving (look-ahead buffer,
|
||||
// auto-retry, per-relay sentinels) lives in [rememberNotificationHistoryPaging]; here we just get back
|
||||
// the per-relay cursors to draw as frontier markers, and keep [history] for the detail dialog's retry.
|
||||
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
|
||||
// Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open
|
||||
// polls), so the hoisted sentinel can map a visible LazyColumn index back to a card.
|
||||
val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size
|
||||
val limits =
|
||||
rememberNotificationHistoryPaging(history, listState, drivesPaging) { index ->
|
||||
items.list.getOrNull(index - leadingItemCount)?.createdAt()
|
||||
}
|
||||
|
||||
// The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup.
|
||||
var syncDetail by remember { mutableStateOf<List<RelayReachCursor>?>(null) }
|
||||
syncDetail?.let { detail ->
|
||||
// Tap-through offers a Try Again on stalled relays, so a user who sees a bad relay can act on it.
|
||||
RelayReachDetailDialog(detail, ::formatHistoryReachDate, onRetry = { history.advanceAll() }) { syncDetail = null }
|
||||
}
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.id())
|
||||
|
||||
// Track which card is highlighted (will auto-clear after animation)
|
||||
@@ -233,7 +270,7 @@ private fun FeedLoaded(
|
||||
items = items.list,
|
||||
key = { _, item -> item.id() },
|
||||
contentType = { _, item -> item.javaClass.simpleName },
|
||||
) { _, item ->
|
||||
) { index, item ->
|
||||
val isHighlighted = highlightedCardId == item.id()
|
||||
val highlightColor by animateColorAsState(
|
||||
targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
|
||||
@@ -257,6 +294,18 @@ private fun FeedLoaded(
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
|
||||
// Per-relay markers in the gap toward the next-older card, at the depth each relay has paged to.
|
||||
// The bulk load is buffer-driven (above); these mark each relay's frontier and drive the
|
||||
// stalled-relay retry when scrolled into view (see the sentinel above). olderCreatedAt is null
|
||||
// past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded".
|
||||
if (limits.isNotEmpty()) {
|
||||
RelayReachMarkers(
|
||||
limits,
|
||||
item.createdAt(),
|
||||
items.list.getOrNull(index + 1)?.createdAt(),
|
||||
) { syncDetail = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.notifications
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Drives the notifications feed's infinite-scroll backward pagination over the account's per-relay
|
||||
* [AccountNotificationsHistoryEoseManager] (a [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]:
|
||||
* each relay keeps its own until+limit cursor so faulty relays with different datasets page independently
|
||||
* and can't gap each other) and returns the per-relay [RelayReachCursor]s the feed draws as frontier
|
||||
* markers.
|
||||
*
|
||||
* Three cooperating drivers, all gated on [drivesPaging] so only the on-screen feed pages the shared
|
||||
* account pager (an off-screen split tab / second pane stays idle):
|
||||
* 1. **Look-ahead buffer** — keeps a fat runway of older notifications loaded ahead of the viewport, so
|
||||
* healthy relays fill the feed and the user practically never reaches the end. Bounded per burst
|
||||
* ([NOTIFICATION_MAX_PAGES_PER_BURST]) and reset by scrolling, so a dense account whose events collapse
|
||||
* into few cards can't burst-download its whole history to hit the row target, while a normal account
|
||||
* still preloads the full look-ahead from the top.
|
||||
* 2. **Auto-retry** — when every relay is done-or-stalled (exhausted) but some are merely stalled (a
|
||||
* slow/unreachable relay, not a real end), re-advances them on a backoff so recovery doesn't depend on
|
||||
* the user scrolling to the marker or reopening.
|
||||
* 3. **Per-relay sentinels** — retry an individual relay the moment its frontier marker scrolls into view;
|
||||
* the buffer keeps the frontier below the fold, so these stay quiet unless the buffer can't keep up.
|
||||
*
|
||||
* @param createdAtAt createdAt of the card at a LazyColumn index (null past the ends / non-card rows), so
|
||||
* the hoisted sentinel can test which inter-card gap a relay's cursor sits in against the visible rows.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberNotificationHistoryPaging(
|
||||
history: AccountNotificationsHistoryEoseManager,
|
||||
listState: LazyListState,
|
||||
drivesPaging: Boolean,
|
||||
createdAtAt: (index: Int) -> Long?,
|
||||
): List<RelayReachCursor> {
|
||||
val historyStatus by history.status.collectAsStateWithLifecycle()
|
||||
val exhausted = historyStatus.exhausted
|
||||
val loadingMore by history.loadingMore.collectAsStateWithLifecycle()
|
||||
|
||||
// Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end.
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleIndex =
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull()
|
||||
?.index ?: 0
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER
|
||||
}
|
||||
}
|
||||
|
||||
// Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications
|
||||
// collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill
|
||||
// would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive
|
||||
// pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so
|
||||
// paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a
|
||||
// normal account (1–2 pages), yet a dense whale can't burst-download everything on open.
|
||||
val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
var pagesThisBurst by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 }
|
||||
|
||||
// Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed
|
||||
// (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the
|
||||
// next (up to the burst cap) until the buffer is full or relays run dry.
|
||||
LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) {
|
||||
if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) {
|
||||
history.advanceAll()
|
||||
pagesThisBurst++
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry faulty relays with backoff, only while this feed drives paging. A single non-restarting
|
||||
// loop so the backoff survives the transient in-flight blips each retry causes.
|
||||
LaunchedEffect(history, drivesPaging) {
|
||||
if (!drivesPaging) return@LaunchedEffect
|
||||
var backoffMs = STALLED_RETRY_MIN_MS
|
||||
while (true) {
|
||||
history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay
|
||||
while (true) {
|
||||
delay(backoffMs)
|
||||
val s = history.status.value
|
||||
if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried)
|
||||
history.advanceAll()
|
||||
history.loadingMore.first { !it } // let the retry settle before escalating
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS)
|
||||
}
|
||||
backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall
|
||||
}
|
||||
}
|
||||
|
||||
// One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls
|
||||
// its next page. A done relay's marker sinks to the oldest end reading "fully loaded".
|
||||
val limits =
|
||||
remember(historyStatus) {
|
||||
historyStatus.relayProgress.map { (relay, p) ->
|
||||
RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the
|
||||
// frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay.
|
||||
if (drivesPaging && limits.isNotEmpty()) {
|
||||
RelayReachSentinels(limits, listState, createdAtAt)
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a
|
||||
* time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this
|
||||
* stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport.
|
||||
*
|
||||
* Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a
|
||||
* hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages.
|
||||
*/
|
||||
@Composable
|
||||
fun BootstrapNotificationHistoryWhenEmpty(
|
||||
active: Boolean,
|
||||
loadingMore: StateFlow<Boolean>,
|
||||
status: StateFlow<PagingStatus>,
|
||||
advanceAll: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(active, loadingMore, status) {
|
||||
if (!active) return@LaunchedEffect
|
||||
delay(BOOTSTRAP_DEBOUNCE_MS)
|
||||
combine(loadingMore, status) { loading, s -> !loading && !s.exhausted }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { advanceAll() }
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore the transient empty feed that navigation flashes through before notifications re-appear.
|
||||
private const val BOOTSTRAP_DEBOUNCE_MS = 1200L
|
||||
|
||||
// How many already-loaded rows to keep below the last visible one before pulling the next older page.
|
||||
// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom.
|
||||
private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100
|
||||
|
||||
// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on
|
||||
// scroll). Generous so a normal account preloads the full look-ahead from the top in 1–2 pages, while a
|
||||
// dense account whose events collapse into few cards is bounded instead of burst-downloading everything.
|
||||
private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6
|
||||
|
||||
// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall,
|
||||
// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own.
|
||||
private const val STALLED_RETRY_MIN_MS = 3_000L
|
||||
private const val STALLED_RETRY_MAX_MS = 30_000L
|
||||
|
||||
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
|
||||
.substringAfter("://")
|
||||
.trimEnd('/')
|
||||
.substringBefore('/')
|
||||
@@ -258,6 +258,9 @@ private fun SplitNotificationsBody(
|
||||
nav: INav,
|
||||
) {
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
// Only the settled, on-screen tab drives the shared account history pager, so the off-screen tab
|
||||
// (composed during a swipe) doesn't page notifications the user isn't looking at.
|
||||
val drivesPaging = page == pagerState.currentPage
|
||||
when (page) {
|
||||
0 -> {
|
||||
NotificationPagerPage(
|
||||
@@ -265,6 +268,7 @@ private fun SplitNotificationsBody(
|
||||
pollContent = notifPolls,
|
||||
scrollStateKey = ScrollStateKeys.NOTIFICATION_FOLLOWING,
|
||||
scrollToEventId = scrollToEventId,
|
||||
drivesPaging = drivesPaging,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -278,6 +282,7 @@ private fun SplitNotificationsBody(
|
||||
// Only the Following tab honors the deep-link scroll target so users
|
||||
// aren't bounced when they swipe across to Everyone.
|
||||
scrollToEventId = null,
|
||||
drivesPaging = drivesPaging,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -294,6 +299,7 @@ private fun NotificationPagerPage(
|
||||
scrollToEventId: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
drivesPaging: Boolean = true,
|
||||
) {
|
||||
RefresheableBox(state, true) {
|
||||
val listState = rememberForeverLazyListState(scrollStateKey)
|
||||
@@ -309,6 +315,7 @@ private fun NotificationPagerPage(
|
||||
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
|
||||
scrollToEventId = scrollToEventId,
|
||||
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
|
||||
drivesPaging = drivesPaging,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
|
||||
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
|
||||
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
|
||||
symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<string name="channel_image">صورة القناة</string>
|
||||
<string name="referenced_event_not_found">لم يتم العثور على الحدث المشار إليه</string>
|
||||
<string name="could_not_decrypt_the_message">لا يمكن فك تشفير الرسالة</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">صورة المجموعة</string>
|
||||
<string name="explicit_content">محتوى فاضح</string>
|
||||
<string name="relay_notice">إشعار relay</string>
|
||||
@@ -730,6 +731,7 @@
|
||||
<string name="napplet_consent_query">يريد هذا nApplet قراءة الأحداث من relay الخاصة بك.</string>
|
||||
<string name="napplet_consent_storage">يريد هذا nApplet استخدام مساحة التخزين الخاصة به.</string>
|
||||
<string name="napplet_consent_pay">يريد هذا nApplet دفع فاتورة Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">يريد هذا nApplet جلب مورد ويب.</string>
|
||||
<string name="napplet_consent_upload">يريد هذا nApplet تحميل ملف إلى خادم الوسائط الخاص بك.</string>
|
||||
<string name="napplet_consent_notify">يريد هذا nApplet عرض إشعارات لك.</string>
|
||||
@@ -742,11 +744,24 @@
|
||||
<item quantity="many">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
|
||||
<item quantity="other">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">চ্যানেল ইমেজ</string>
|
||||
<string name="referenced_event_not_found">উল্লেখিত ইভেন্টটি পাওয়া যায় নি</string>
|
||||
<string name="could_not_decrypt_the_message">মেসেজটি ডিক্রিপ্ট করা যায় নি</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">দলগত ছবি</string>
|
||||
<string name="explicit_content">খোলামেলা আধেয়</string>
|
||||
<string name="relay_notice">রিলে নোটিশ</string>
|
||||
@@ -703,6 +704,7 @@
|
||||
<string name="napplet_consent_query">এই nApplet আপনার relay-গুলো থেকে ইভেন্ট পড়তে চায়।</string>
|
||||
<string name="napplet_consent_storage">এই nApplet তার প্রাইভেট স্টোরেজ ব্যবহার করতে চায়।</string>
|
||||
<string name="napplet_consent_pay">এই nApplet একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">এই nApplet একটি ওয়েব রিসোর্স আনতে চায়।</string>
|
||||
<string name="napplet_consent_upload">এই nApplet আপনার মিডিয়া সার্ভারে একটি ফাইল আপলোড করতে চায়।</string>
|
||||
<string name="napplet_consent_notify">এই nApplet আপনাকে বিজ্ঞপ্তি দেখাতে চায়।</string>
|
||||
@@ -711,11 +713,24 @@
|
||||
<item quantity="one">এই nApplet %1$d sat-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
|
||||
<item quantity="other">এই nApplet %1$d sats-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user