Merge main into the preference-layer migration

Resolves the one conflict, in CalendarReminderSettingsScreen's imports. Main
(#4189) moved INav, TopBarWithBackButton, stringRes and pluralStringRes into
`commons.ui`; this branch had swapped that screen's CalendarReminderPrefs for
the DataStore-backed calendarReminderSettings(). The two changes are
orthogonal, so the resolution takes main's package moves and keeps this
branch's store, dropping the CalendarReminderPrefs import the store replaced.

Also repoints a KDoc link in CalendarReminderWorker that main wrote against
CalendarReminderStore, which this branch renamed to CalendarReminderLogStore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-23 23:54:39 +00:00
1282 changed files with 23582 additions and 8516 deletions
+12 -4
View File
@@ -58,10 +58,10 @@ amethyst/ # Android app module
│ │ │ │ ├── MainActivity.kt # Entry point
│ │ │ │ ├── navigation/
│ │ │ │ │ ├── AppNavigation.kt # NavHost
│ │ │ │ │ ├── routes/Routes.kt # @Serializable routes
│ │ │ │ │ ├── routes/RouteNavController.kt # NavHost-bound route helpers
│ │ │ │ │ └── bottombars/AppBottomBar.kt
│ │ │ │ ├── screen/ # 80+ screens
│ │ │ │ └── theme/Theme.kt # Material3 theme
│ │ │ │ └── theme/Theme.kt # AmethystTheme (accent, prefs, window insets)
│ │ │ └── Amethyst.kt # Application class
│ │ └── AndroidManifest.xml # Permissions, intent filters
│ └── androidMain/ # KMP Android source set
@@ -69,6 +69,12 @@ amethyst/ # Android app module
└── build.gradle # Android config
```
The `@Serializable` `Route` catalog is headless and lives in
`commons/.../commons/model/navigation/Routes.kt`; `INav`/`EmptyNav` and the top bars are
in `commonsUI/.../commons/ui/navigation/`. The palettes, `ColorScheme.*` tokens, sizes and
typography are in `commonsUI/.../commons/ui/theme/` (markdown styles in its `jvmAndroid`).
Add a route to the commons `Routes.kt`; wire its destination in the app's `AppNavigation.kt`.
## 1. Type-Safe Navigation
### Pattern: @Serializable Routes
@@ -953,9 +959,11 @@ fun testPermissionRequest() {
**Key Android Files:**
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt` (route catalog)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt` (`AmethystTheme`)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt` (palettes + `ColorScheme.*` tokens)
- `amethyst/src/main/AndroidManifest.xml`
- `amethyst/build.gradle`
@@ -608,7 +608,9 @@ fun testNavigationToProfile() {
## File Locations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt` (route catalog)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteNavController.kt` (NavHost helpers)
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
+900
View File
@@ -0,0 +1,900 @@
# DECK-0003 (SNO — Simple Nostr Objects) — feasibility and implementation plan
_Status: **built** on `claude/epic-ptolemy-48n37b`. The reader, the rasterizer,
the feed/thread rendering and kind-11333 avatars all ship; §6 records what
landed and what did not._
Source: <https://github.com/arkin0x/cyberspace/blob/master/decks/DECK-0003-sno.md>
(Draft, last updated 2026-09-16, requires `CYBERSPACE_V2.md` `2026-03-16-h34-corrected`).
Two implementations to check against, and they do not fully agree (§2.6):
`decks/sno-reference.py` in the same repo — no dependencies, 25 rejection cases, runs
clean — and [`arkin0x/sno-core`](https://github.com/arkin0x/sno-core) v0.1.4, the MIT
TypeScript library that ONOSENDAI and the snocrash workshop both read objects with.
The surrounding specs (`CYBERSPACE_V2.md` §7.6, §8.6, §8.10, §9.4, §11 and the other
two DECKs) are covered in §2.
## 1. What the spec defines
A **Simple Nostr Object** is a small triangle mesh written as JSON, small enough to
live in one event's `content`. Vertices sit on an integer lattice (whole model units
plus 120ths), one palette index per vertex, optional triangles, one of three draw
modes. No textures, no materials, no normals, no animation, no external file. The
object *is* the event.
| Thing | Value |
| ---- | ---- |
| Standalone object | `kind 33331`, addressable, one per `(pubkey, d)` |
| Object hidden in a Cyberspace bag | `kind 3330`, a `kind 33330` bag item |
| Avatar | `kind 11333`, payload in content (CYBERSPACE_V2 §8.10) |
| Palette published as its own event | any kind; `kind 3367` (color moments) is what carries them today |
| Model space | X right, Y up, +Z toward the viewer — glTF/three.js, right handed |
| Lattice | whole units + ticks (1/120 of a unit), exact rationals, never floats |
| Hard limits | 512 vertices, 1024 faces |
| `v` | `1` (Z negated on read, colors are literal `[r,g,b]` triples) or `2` (Z as written, colors are palette indices) |
The payload fields: `v`, `name`, `unit`, `mode`, `vertices`, `colors`, `faces`
required; `extent`, `ticks`, `facecolors`, `palette`, `up`, `spin` optional; `type`
a legacy field that MUST be ignored and MUST NOT be rejected on.
Three encodings are run-length: `ticks` (a negative `-N` means N vertices at
`[0,0,0]`), `facecolors` (a negative `-N` repeats the previous index N more times).
Both are "sign separates the two entry kinds", which works because an index is never
negative.
§1.9 is the validation table — ten numbered rules, and `decks/sno-reference.py`
carries a rejection case per rule. That table is the test suite; port it verbatim.
### Confirmed against the live network, not from memory
Queried `cyberspace.nostr1.com`, `relay.damus.io`, `nos.lol`, `relay.primal.net`
on 2026-09-21 (nostr.band reset the connection):
```
kind 33331 : 7 unique events, 6 distinct authors
kind 3330 : 2 unique events
kind 11333 : 0
kind 3367 : 8 sampled palettes
```
Running each 33331 payload through the reference validator:
| Event id (first 8) | Author | Result | verts/faces | `unit` | `mode` | event bytes | name |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| `fbe6a776` | `24bd64d4` | **rejected** | 4/4 | 0 | solid | 726 | hello world |
| `4b05663f` | `1b71f696` | **rejected** | 4/4 | 0 | solid | 735 | second thought |
| `04df8e12` | `e8ed3798` | **rejected** | 18/24 | 0 | lines | 1907 | Ship |
| `89f510e1` | `dcf07605` | valid | 35/20 | 0 | solid | 1231 | Shard 4 copy |
| `47031e0f` | `7e5dde75` | valid | 5/5 | 0 | solid | 684 | Shard 2 |
| `d1299608` | `f7e15ec0` | valid | 118/94 | 0 | solid | 3248 | first object |
| `8fe392c2` | `e8ed3798` | valid | 12/18 | 12 | solid | 981 | Triforce |
**Three of the seven objects on the network fail the spec's own validator.** All
three fail on the same rule (8b): they are `v: 2` payloads whose `colors` are still
`[r, g, b]` triples (`[1, 0.15, 0.15]`), and all three also carry `type: "shard"`.
This is exactly the transition §5 documents — `colors` changed meaning inside `v: 2`
on 2026-09-16 and the authors expected to reissue three objects. They have not, and
one of the three (`04df8e12`, "Ship") was published by an author who *also* has a
correctly-encoded object, so this is drift, not a single stale client.
§5 gives a reader explicit permission here: "a reader MAY reject one, and a reader
that would rather be generous MAY read the triples literally the way §1.3 reads a
`v: 1` payload's". **Take the generous branch** — otherwise Amethyst renders 4 of the
7 objects that exist and shows an error for the rest. Decision D1 below.
Real payloads also already use `mode: "lines"` and a non-zero `unit` (12), so neither
is a theoretical branch.
**The two `kind 3330` events on relays are v1 leftovers, and the current ones are
unreachable.** Both live shards have **empty `content`** and carry their geometry in
*tags*:
```
C 3d5ffa91… (64-hex cyberspace coordinate)
Cd 0
S 13346173940832169-15655245699645789-31273450162226572
ms 273
version 1
vertices (empty) colors (empty) indices (empty)
position 251894327,617397300,844414457
display solid nonce 0000000000000000 e cb5a78f7…
```
That tag-encoded form is v1's and the DECK does not describe it. The current client
does write the §1 JSON payload in `content` — `src/lib/hidden.ts` in ONOSENDAI is
explicit ("a shard, kind 3330 (v1's shard kind), geometry in the content"), so §3.2
is accurate about what is being produced today.
The reason to defer 3330 is better than a mismatch, and it is `CYBERSPACE_V2.md`
§7.6 rather than the DECK. A current shard is not a relay event at all: it is an item
inside a **bag**, a `kind 33330` event whose payload is AES-256-GCM ciphertext in an
`encrypted` tag, keyed by the `location_decryption_key` of a region at a height
(§7.2). Opening one means implementing the coordinate system (§2), the region key
derivation (§7.2) and discovery scanning (§7.4) — the Cyberspace protocol, not a
rendering feature. A reader without the key sees base64 and nothing else, and §7.6
says a failed decryption "MUST NOT be treated as an error in the bag". Defer it —
decision D2.
**Palette events match §1.3b exactly.** All sampled `kind 3367` events carry 4–6
`c` tags of well-formed `#rrggbb` in document order, plus `layout` / `alt` / `client`
/ `name`, with an emoji in `content`. The spec's survey holds; the `c`-tag reader is
the only palette-event reader worth writing.
### Spec warts to handle defensively
1. **`type` is poison to reject on.** §1.1a is emphatic, and three live events carry
it. Ignore it.
2. **No hard bound on how far a vertex may lie from the origin** — §8 open question 3
says so outright. "512 vertices at 2^50 units is valid under this text and will
produce a grid no renderer wants." §1.8 puts the 64-unit bound on publishers and
lets a reader "reject such a payload" or "repair it by growing the extent". Both
references repair; so do we (see §4.2d). What is left is the lattice's own limit:
a position is a total tick count in an Int.
3. **`extent` is repaired, never validated** (§1.8). Out of range → `8`, then grow to
contain the data. It is the one deliberately forgiving rule; a reader that rejects
on it is wrong.
4. **Winding order carries no meaning** (§1.4). Draw both sides, never cull.
5. **A palette reference is never load-bearing** (§1.3b). Render immediately with the
built-in; re-render if and when the referenced event arrives; never block, never
reject. A reference is pinned to the event id it names — do not follow the `e`
chain forward.
6. **Welding/equality must happen on the integer pair**, before any float conversion
(§1.2). 1/120 is not representable in binary floating point.
7. **`name` is truncated to 64 characters, not rejected** (§1.8).
8. **`spin` is validated even when `up` is absent** (§1.7) but ignored for rendering.
## 2. The surrounding specs, and the software that already reads this format
DECK-0003 does not stand alone. Reading the repository it lives in, and the two
clients that implement it, changes three things in this plan.
### 2.1 What a DECK is, and which ones matter here
`decks/README.md` defines a DECK (Design Extension and Compatibility Kit) as a
self-contained optional extension over `CYBERSPACE_V2.md`. A DECK MAY define new
kinds and tags; it MUST NOT change consensus-critical base rules. Three exist:
DECK-0001 (Hyperspace, Bitcoin block transit), DECK-0002 (Virtual Spawn, a game
mechanic), DECK-0003 (SNO). **Neither 0001 nor 0002 touches geometry, objects or
rendering** — grepped, zero hits. SNO is the only DECK Amethyst would implement.
DECK-0003 on `master` is the current revision: I fetched every pull-request head in
the repo and none carries a newer DECK-0003 than `master` does. The only artifact of
note is that SNO was DECK-**0004** in PR #25, renumbered in the same round that
dropped the mandatory `type: "shard"` and turned colors into palette indices — which
is precisely the change the three legacy events on the network predate.
### 2.2 `CYBERSPACE_V2.md` §7 — the bag, and what opening one would actually cost
Summarized in §1 above and it is the strongest argument in this plan: a current shard
is an item inside an AES-256-GCM bag keyed to a region, not a fetchable event.
**How a bag is found is not what §7.4 makes it look like, and this plan said it
wrong twice.** §7.4's discovery scanning — walk the aligned cubes around your own
coordinate, derive each key, ask the relay — is the *opportunistic* half, and §7.7
says outright what it is worth on its own: "a bag is found only by intentionally deep
scanning and/or wandering. With no additional information, any given bag is equally
likely to be at any point in the full 2^256 coordinate space: an impossibly hardened
secret."
The mechanism that actually finds things is §7.7's **hint and sweep**, and the
sentence that matters is this one:
> The seeker's own position never enters this cost, because §7.1 makes looking and
> walking equivalent: a region key can be computed for any coordinate without
> traveling there.
A hider publishes a `hint` tag naming an aligned box as coarse as they like; a seeker
sweeps it, deriving a key per candidate region and batching the `lookup_id`s into one
relay query. The price is `2^((Hx-h)+(Hy-h)+(Hz-h))` candidates and nothing else. At
the degenerate end, "three heights equal to `h` name the region itself: the hint is
then a destination the seeker can compute or walk to directly, not a search."
So **a client with no avatar and no position could open a hinted bag.** The reason
this plan defers it is not that Amethyst has nowhere to stand — that was the wrong
reason, stated in D2 and in `SnoShardEvent`'s KDoc — it is the cost and the surface.
**The cost, measured.** A region key is three Cantor axis roots at the bag's height,
each an `O(2^h)` fold of BigInts that double in width every level (§4.6). The spec
publishes its own figures in §7.7, one desktop core, per key: about 0.05 ms at
heights 0–4, 1.3 ms at h8, 30 ms at h12, 816 ms at h16. Measured here against the
same fold, per axis: 4 ms at h12, 92 ms at h16, 2.7 s at h20, 13 s at h22 with a
GMP-class bignum — which puts the spec's numbers between a GMP build and a
Karatsuba-only one, as it should. The growth is **~2.2x per height**, both in the
spec's own figures and in the measurement, and it is 2.2 rather than 2 because a
pairing doubles the leaves *and* the operand width. Extrapolating that ratio, the
spec's human-scale h34 is on the order of days per key and a 174 GB root, against
§9.3's stated 185 GB — which is why `cyberspace-core` caps at
`DEFAULT_MAX_COMPUTE_HEIGHT = 20` and throws above it, and why ONOSENDAI sells the
heights above a machine's own ceiling through a paid service.
**What that means for us.** Nothing changes about D2, but the reason is now the right
one: opening bags means carrying §2's coordinates, §4's Cantor trees and §7.2's
derivation, plus a sweep whose budget is somebody else's puzzle difficulty. That is a
protocol stack with its own compute economics, and it is not a rendering feature.
### 2.3 `CYBERSPACE_V2.md` §8.10 — the avatar, and a proof of work that is normative
This is the one piece of surrounding spec that is directly implementable and that the
DECK only gestures at. `kind 11333` is **replaceable** (one avatar per identity), its
content is an SNO payload or empty, and the work it owes is computed from the payload:
```python
AVATAR_FLOOR_BITS = 16; AVATAR_SIZE_BITS = 2; AVATAR_DETAIL_BITS = 3; AVATAR_DETAIL_FREE = 32
reach = max(1.0, max(abs(v + t/120) for every vertex coordinate) * 2 ** payload.unit)
detail = max(AVATAR_DETAIL_FREE, len(payload.vertices) + len(payload.faces))
work = ceil(16 + 2*log2(reach) + 3*log2(detail/32))
```
An avatar is **paid** when its NIP-13 `nonce` tag's committed `target` is at least
`work`, **and** its id carries at least `target` leading zero bits. Both conditions,
because committing the target before mining is what stops a lucky id being claimed
against a lower bar. And then, normatively: *"a client MUST NOT draw an avatar event
that is not paid, or that carries content it cannot read; it draws its default avatar
for that identity instead."*
For us that is a short, self-contained addition on top of step 1, and Quartz already
has `nip13Pow/` for the leading-zero-bit count — but **not** as a one-liner, and the
shortcut is wrong. `Event.pow()` is `PoWRankEvaluator.compute(id, tags.commitedPoW())`,
which returns `min(actualRank, committed)`, so `event.pow() >= avatarWork(payload)`
passes an event whose id is short of its own commitment: work 16, committed 30, id
carrying 20 gives `pow() == 20`, which clears 16 while failing §8.10's second
condition. The check has to stay two conditions, read separately —
`PoWTag.parseCommitment(...) >= avatarWork(payload)` **and**
`PoWRankEvaluator.calculatePowRankOf(id) >= committed` — with a missing `nonce` tag
meaning unpaid. It is also the only place in this
whole surface where a rendering decision is gated on verification rather than taste.
Note the kind history: `11333` **was** `kind 33331` with a `d` fixed at `"avatar"`,
and 33331 was then handed to standalone SNO objects. A reader should not be surprised
by an old 33331 whose `d` is literally `avatar`; none of the seven live ones is.
### 2.4 §9.4 and §11 — the axis conventions, which confirm the Z story
§11.1 fixes `+X_cs` screen-right, `+Y_cs` up, `+Z_cs` forward *toward the black sun*,
and §11.3 makes "facing the black sun" the canonical camera. That is why SNO `v: 1`
has +Z away from the viewer and `v: 2` has it toward: v1 was Cyberspace's own frame,
v2 is glTF's. §9.4's `X_cs = X_ecef, Y_cs = Z_ecef, Z_cs = Y_ecef` is the swap that
makes DECK §1.7's (east, up, north) frame right-handed.
**Do not copy the sign convention from the reference client.** `sno-core`'s
`fromPayload` flips Z on `v: 2` and leaves `v: 1` alone — the exact opposite of what
this plan says — because ONOSENDAI's internal frame *is* Cyberspace's, so it converts
the published frame inward. Amethyst has no Cyberspace frame and renders in the glTF
convention, so for us `v: 2` is as-written and `v: 1` is negated. Both are correct;
the direction depends on the renderer's frame, and getting it backwards mirrors every
asymmetric object without any error.
### 2.5 The implementations that already exist
| Project | What it is | License |
| ---- | ---- | ---- |
| [`arkin0x/sno-core`](https://github.com/arkin0x/sno-core) | The TypeScript SNO library, v0.1.4. The reader both clients use | MIT *(per `package.json`; no LICENSE file committed)* |
| [`arkin0x/onosendai`](https://github.com/arkin0x/onosendai) | The 3D Cyberspace client (react-three-fiber). Active — last commit the same day I read it | **CC-BY-SA-4.0** |
| `arkin0x/snocrash` | The shard workshop, the modeling tool. On sno-core 0.1.4 | not checked |
| `arkin0x/cyberspace-cli` / `cyberspace-cli-js` | Reference protocol implementations (Python / TS), where `avatar.py` / `avatar.ts` and the PoW golden vectors live | not checked |
**Licensing, per the repo's dependency rule.** Nothing here gets linked into an APK —
this is a Kotlin port of a specification. But the rule's spirit applies to copied
source: `sno-core` declares MIT and is safe to read and to mirror test vectors from,
though it commits no LICENSE file and someone should ask upstream for one before we
lean on it. **ONOSENDAI is CC-BY-SA-4.0 — share-alike — so do not copy code out of
it.** Write from DECK-0003 and check behavior against `sno-reference.py`.
`sno-core` also contains a great deal the DECK does not specify, all of it the
authoring side: `stamps` (seven parametric shapes on the grid), `triangulate` (ear
clipping plus the Newell normal), `outline` (each shared edge drawn once for `lines`
mode), `orient` (winding faces outward so a lit bench can paint backfaces dark),
`clip` (a shard cropped to the region cube it is sealed to — a rendering rule that
exists nowhere in the DECK), `pose`, `scale`, `hsv`. None of it is needed to *read* an
object. All of it would be needed to build a workshop, which is D3's argument.
### 2.5a Checked against the reference implementations, not only the spec
§8.10 says its two reference implementations are "pinned to one set of golden
vectors", and the first pass built the avatar ladder from the section's
pseudocode instead — which is the same text both implementations read, so
agreement with it proved nothing about agreement with them. Closed since:
`tests/fixtures/avatar_work.json` from cyberspace-cli is now a test, and **all
eight vectors pass**. The two fixture sets (cyberspace-cli and cyberspace-core)
carry the same eight names with identical `reach` and `required` throughout, so
the "one set" claim holds and checking either checks both.
The vector that earns its keep is **"half gibson, ticks"**: a vertex of
`[-1, -1, -1]` with ticks `[60, 60, 60]` is **-0.5** of a unit, not -1.5,
because the whole part is the floor and the ticks count up from it. An
implementation that takes the magnitude before adding the ticks prices that
avatar at three times its reach and passes every other vector in the set.
`verify_avatar_work` in the Python reference also confirms the shape of the
payment check independently: committed ≥ required **and** zeros ≥ committed, as
two separate conditions with distinct failure reasons — which is what §5 of this
plan's own note about `Event.pow()` is about.
**The Python reference is stale on the avatar kind, and we do not follow it.**
| Implementation | Avatar kind |
| ---- | ---- |
| `CYBERSPACE_V2.md` §8.10 | **11333** |
| cyberspace-core (TS, 2026-09-14) | 11333 |
| ONOSENDAI (the client) | 11333 |
| cyberspace-cli (Python, 2026-09-07) | **33331** |
The Python CLI predates the kind move by a week and still calls 33331 the avatar
kind, which DECK-0003 has since given to standalone objects. Run against the
current network it would refuse a conformant avatar as `not-an-avatar`, and
would treat one of the seven published `kind 33331` objects as an avatar and
demand proof of work of it. We follow the spec and the two current
implementations.
Nothing else in the Python CLI overlaps this work: it implements no §1.9, no
palette and no mode — its only SNO surface is the avatar ladder. It does
implement the `kind 33330` bag (encrypt/decrypt), which is the part we
deliberately do not.
### 2.6 The two reference implementations disagree, and I checked which way
The DECK's conformance note says `sno-reference.py` and ONOSENDAI's TypeScript reader
"agree on all twenty cases". That is no longer true. Running both against the same
payloads:
| Payload | `sno-reference.py` | `sno-core` v0.1.4 |
| ---- | ---- | ---- |
| `v: 2` with literal `[r,g,b]` triples | **rejected** (rule 8b) | **accepted**, deliberately |
| `type: "shard"` present | accepted, ignored | accepted, ignored |
| `name` missing entirely | **accepted** — no check at all | accepted, defaults to `"shard"` |
| `name` a number | **accepted** | accepted, defaults |
| a vertex at 1,000 units from origin | accepted, extent grows | accepted, extent grows |
Run against the seven live objects rather than synthetic payloads, the gap is the
whole legacy cohort: **`sno-core` v0.1.4 reads 7 of 7; `sno-reference.py` reads 4 of
7.** (Measured — `sno-core` built from source at v0.1.4 and `fromPayload` called on
each event's content.)
Two things follow. **D1 is settled upstream, not a judgement call.** sno-core's
commit says it plainly: version 2 shipped as two changes that did not land together —
the wire turned right-handed in ONOSENDAI #165 and color became an index two releases
later in #167 — so objects written in between declare `v: 2`, carry triples, and are
correct in every other respect *including their frame*. Refusing them "orphaned" real
work in both clients. The two forms cannot be confused because one is an array and
the other an integer. Amethyst should match sno-core: accept, read literally, keep
the declared version's frame (a v2-with-triples object is **not** flipped).
And **`name` is required in §1.1's table and enforced by nobody.** Treat it as
optional with a fallback; rejecting on it would be stricter than every implementation
that exists, over a field that is decoration.
## 3. Quartz — new code
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/`.
This follows the `concord/cord05Invites/` precedent for a spec family that is not a
NIP; `nipCC…`-style naming would be a lie about where the spec lives.
```
cyberspace/deck0003Sno/
├── SnoObjectEvent.kt kind 33331, extends BaseAddressableEvent
├── SnoPayload.kt @Immutable parsed object (IntArrays, not Lists)
├── SnoParser.kt §1.9, the whole table, rejection returns null/Result
├── SnoMode.kt solid | points | lines
├── SnoPalette.kt resolution: built-in | name | nevent/naddr | inline
├── SnoBuiltInPalette.kt the 256 cyberspace-neon-256 colors as an IntArray
├── SnoPaletteEventReader.kt c-tags first, content-array fallback (§1.3b)
├── TagArrayExt.kt / TagArrayBuilderExt.kt
└── tags/ reuse NameTag (nip01Core), AltTag (nip31Alts), DTag
```
### 3.1 Reuse — do not re-implement
| Need | Already exists |
| ---- | ---- |
| Addressable event base, `dTag()`, `address()` | `nip01Core/core/BaseAddressableEvent.kt` |
| Lenient third-party JSON field readers | `nip01Core/kotlinSerialization/LenientJson.kt` |
| `alt` tag (NIP-31) | `nip31Alts/` |
| `nevent` / `naddr` decoding for the palette reference | `nip19Bech32/` |
| Fetching the referenced palette event | `nip01Core/relay/client/accessories/` — `fetchFirst`. **Do not hand-roll a REQ loop** (CLAUDE.md) |
| Event construction/signing | `eventTemplate` + `TagArrayBuilder` |
### 3.2 Parse with `JsonElement`, not a `@Serializable` data class
`ticks` is a heterogeneous array (triples interleaved with negative ints),
`facecolors` likewise, and `colors` is `Int` in `v: 2` but `[Double, Double, Double]`
in `v: 1`. kotlinx's declarative path cannot express that without four custom
serializers. Parse `LenientJson.parseToJsonElement(content)` once and walk it with an
explicit validator that mirrors §1.9's ten rules one function each, so a rule id can
be quoted in the failure. `experimental/clink/kotlinSerialization/ClinkKSerializers.kt`
is the in-repo precedent for hand-written readers over foreign JSON.
Store the result as primitive arrays, not `List<List<Int>>`: at the ceiling that is
512×3 + 1024×3 ints, and the repo's hot-path style is raw arrays plus the `fast*`
operators (CLAUDE.md). `SnoPayload` holds `IntArray wholeX/Y/Y…` or a single flat
`IntArray(3 * n)` — flat is cheaper and the rasterizer wants it flat anyway.
Keep positions as the integer pair `(whole, tick)` on the payload. Expose
`fun position(i: Int, axis: Int): Pair<Int, Int>` and convert to float **only** at
the rasterizer boundary, per §1.2.
### 3.3 The built-in palette is a vendored artifact
`cyberspace-neon-256` is normative (Appendix C) and 256 entries. The upstream source
of truth is `decks/sno-palette.json` (2,748 bytes) and its generator
`decks/sno-palette.mjs`. Vendor the values as a `const val` hex string or a packed
`IntArray` in `SnoBuiltInPalette.kt` with a comment naming the upstream file and its
`built` date (`2026-09-16`), and a test that checks a handful of documented anchors
(238 = `#ff0000`, 235 = `#00ff00`, 239 = `#0000ff`, 225 = `#ffffff`, 224 = `#000000`)
so a bad paste cannot ship silently.
### 3.4 Tests — the table already exists
Port `_rejections()` from `sno-reference.py` verbatim: 25 cases across rules 1, 2, 3,
4, 5, 6, 7, 8, 8a, 8b, 8c and 10, each a one-field mutation of the Appendix A
tetrahedron. Plus:
- Appendix A validates, and `positions()[3] == (1, 2, 1)`.
- 40 ticks is exactly one third of a unit — asserted on the integer pair.
- `extent` repair: a vertex at 9 units grows the hint 8 → 9; `extent: 0` becomes 8.
- Absent `ticks` means every position is whole; `[-4]` means the same thing.
- `facecolors: [238, -3]` expands to four faces of 238; absent is **not** color 0.
- The seven live events above as committed fixtures — four parse strictly, three
parse under D1, zero crash. Under D1 all seven parse, which is the parity target:
`sno-core` reads all seven and `sno-reference.py` reads four.
### 3.5 Registration points (each is a real integration, not a formality)
| File | Change |
| ---- | ---- |
| `quartz/utils/EventFactory.kt` | `SnoObjectEvent.KIND -> SnoObjectEvent(...)` in the `when`, plus the import. (`factories[kind]` exists as a runtime map but every first-party kind is compiled in.) |
| `amethyst/model/LocalCache.kt` | add `is SnoObjectEvent,` to the addressable branch near `is GitRepositoryEvent,` — without it the addressable cache never holds the object and a quoted `naddr` stays empty |
| `EventFactory.isKnownKind` | free once the branch exists; it decides whether a repost of a 33331 renders at all |
| `amethyst/model/CacheSearch.kt` | only if objects should be searchable by name |
## 4. Rendering — the part with a real decision in it
Amethyst has no 3D anything today. Grepped: no OpenGL, no `drawVertices`, no
`Vertices`, no mesh code anywhere in `commonsUI/` or `amethyst/`.
### 4.1 `Canvas.drawVertices` is common Compose API but unusable below API 29
Verified by pulling `androidx.compose.ui:ui-graphics-desktop:1.7.0` and running
`javap`: `Vertices`, `VertexMode` and
`Canvas.drawVertices(Vertices, BlendMode, Paint)` are present in the **desktop/skiko**
artifact, so this is `commonMain` API, not an Android extra. It would give
Gouraud-shaded triangles for free on Android and Desktop alike.
The blocker is Android: `Canvas.drawVertices()` is hardware-accelerated **only from
API 29**, and `android-minSdk = "26"`. On Android 8.0–9.0 it silently does nothing
on a hardware canvas. A `drawVertices`-only renderer ships a blank box to those users.
### 4.2 Recommended: a CPU rasterizer in `commons/`, on the blurhash rails
`commons/` already owns exactly this shape of code — `blurhash/BlurHashDecoder.kt`
decodes to an `IntArray` of ARGB in `commonMain`, and
`blurhash/PlatformImage.kt` is an `expect class` with a
`PlatformImage.create(pixels, width, height)` companion and actuals in
`androidMain` (Bitmap), `jvmMain` (BufferedImage) and `iosMain`. `commonsUI/`
turns one into a Compose-renderable image via `service/image/CoilImageBridge.kt`
(`expect fun PlatformImage.toCoilImage(): Image`), and `BlurHashFetcher` +
`BKeyer` already wire a synthetic data class into Coil's memory cache.
An SNO thumbnail is the same pipeline with a different decoder:
```
SnoPayload → SnoRasterizer.render(payload, palette, w, h, rotation): IntArray
→ PlatformImage.create(...) (commons, exists)
→ .toCoilImage() (commonsUI, exists)
→ AsyncImage(model = SnoWrapper(eventId, rotation))
```
Why the CPU path rather than `drawVertices`:
- **It works on every target** — API 26 Android, Desktop, and iOS — with no
per-platform branch and no `expect fun supportsDrawVertices()`.
- **It gets a z-buffer.** `drawVertices` has none; painter's algorithm (sort
triangles by depth) breaks on interpenetrating triangles, which lattice-built
geometry will have the moment two objects are authored to share an edge.
- **It is unit-testable headless.** A `jvmTest` can rasterize a fixture and assert on
pixels — no device, no screenshot harness.
- **`facecolors` is trivial** (fill flat) and Gouraud is trivial (interpolate the
three resolved colors barycentrically), which §4 of the spec requires as the
default unlit reading.
Cost: about 150 lines. Project 512 vertices, cull nothing (§1.4), scanline-fill 1024
triangles into a 384×384 buffer with a `FloatArray` depth buffer. That is well under
a millisecond of real work on the JVM; the Android figure needs measuring, not
guessing, before the interactive viewer lands.
### 4.2a Why not just draw it on a Canvas — measured
The obvious objection to a CPU rasterizer is that Skia is right there. It is a
fair one and it was measured rather than argued, at 512px, on the same meshes:
| mesh | CPU rasterizer | Skia `drawPath` | Skia `drawVertices` |
| ---- | ---- | ---- | ---- |
| 8v/12f | 10.52 ms | **6.27 ms** | 6.71 ms |
| 118v/94f | 6.35 ms | **4.31 ms** | 7.15 ms |
| 512v/1004f | 41.31 ms | **15.46 ms** | 30.11 ms |
(Skia figures include whole-scene render overhead and are *software* skiko — on
Android a Canvas is GPU-accelerated and would be far better than this. The depth
sort was hoisted out of the timing loop, which flatters them further.)
**So a Canvas is faster, and it is still the wrong trade here**, for two reasons
that cost correctness rather than speed:
1. **A Canvas has no depth buffer.** Triangles have to be sorted back to front
every frame, which is exact for a convex mesh and wrong for interpenetrating
ones. Lattice geometry invites exactly that case: §1.2's whole promise is
that two objects authored to share an edge do share it.
2. **`drawPath` cannot interpolate a face's vertices.** A Path fill is one
colour, so every face would flatten to one — and two of the seven objects on
the network are smooth three-colour gradients. `drawVertices` keeps them, but
it is hardware-accelerated only from API 29 against a `minSdk` of 26, and on
skiko it measured no better than the CPU path for small meshes.
And the speed is not needed. A real object costs **0.25 ms** at thumbnail size
and **3 ms** per viewer frame, off the composition thread; the 54 ms figure is an
adversarial payload that §1.8 permits and nobody authors. Trading away depth
correctness and per-vertex colour for a 2x on numbers that already fit is a bad
deal, and §4 is explicit that the same object must not look like two objects —
which is what an API-29 split would produce.
If a real device ever shows this mattering, the move is `drawVertices` behind
the existing interface for API >= 29, accepting painter's-algorithm artifacts
there, driven by a measurement on hardware rather than one in a container.
**The other lever, unspent:** fixed per-frame overhead is 0.62 ms at 512px — two
buffer allocations plus the projection, about a fifth of a real object's frame.
A caller-owned render target would recover most of it, and is only worth doing
if the viewer needs to be tighter; the thumbnail is Coil-cached and pays it once.
### 4.2b The viewer is the point, and the first pass under-built it
The framing above — thumbnail cached, viewer secondary — had it backwards.
These objects exist to be turned and looked at; the viewer *is* the feature, and
the first pass shipped it half-built and capped:
- **It only rotated.** No zoom, no pan, on an object whose whole purpose is
being inspected from angles. One finger now turns it, two move and scale it,
a double tap resets it.
- **It rendered at under half resolution during that very interaction.** The
512px cap was justified by a 48ms measurement — of the *adversarial* mesh,
taken *before* the rasterizer got 4x faster. Re-measured after: a real object
at full 1080px costs **4.86 ms**. The cap was protecting against a number that
no longer existed, at the cost of a soft image exactly where sharpness matters.
(Re-measured on a busier machine while §4.2d's work landed: 8.04 ms before that
commit's hoist and 6.81 ms after. These runs are minutes apart on a shared box, so
read the ratios rather than the absolutes; every figure in §4.2c–d comes from one
run and is comparable within it.)
The cap is now adaptive rather than constant: 384px while a finger is down,
full resolution — scaled by how far the reader has zoomed — once it lifts. The
frames during a turn are the ones that must not stutter and the ones nobody
inspects; the frame you stop on is the one that must be sharp. That also handles
the adversarial object without a fixed ceiling: coarse while it moves, and it
pays its 177ms once when it settles.
Zoom and pan do not re-raster at all — they are a `graphicsLayer` transform of
the frame already drawn, which is free per frame; only rotation changes which
faces point at the viewer. The re-raster on settle is what sharpens a deep zoom
rather than leaving it an upscaled bitmap.
### 4.2c Measured against the author's own viewer
Two of arkin0x's repositories came up as "do we mimic the viewer?" and they are
not the same thing:
- **`arkin0x/cyberspace-viewer`** is *not* the SNO viewer. Its last commit is
2023-08-25, three years before DECK-0003; it draws v1 "Constructs" — wireframe
lattices from a hard-coded `ConstructLineData.ts` — through
`@react-three/drei`'s `OrbitControls`. Grepping it for `shard`, `33331` or
`sno` finds nothing. It is not a comparison point.
- **`arkin0x/ONOSENDAI`** is. `src/scene/ShardMesh.tsx` is the author's current
renderer, on `sno-core` v0.1.4, and it draws a shard in two distinct ways.
**The world drawing, which we already matched exactly.**
`meshBasicMaterial vertexColors side={DoubleSide} toneMapped={false}` — unlit,
per-vertex colours, two-sided, no tone curve — is §4's default reading and is
what the rasterizer and the feed thumbnail already did. Lines mode builds its
edges from the faces with each shared edge once (`sno-core/outline`), with a
single polyline through the vertices when there are no faces, which is also
what `drawEdges` already did.
**Two things it does that we did not.**
1. **`§1.5`'s "draw the vertices as points in every mode" was half-implemented.**
We drew them in `points` and `lines` mode and under 3px, but not over a
solid — the one case the sentence is actually about. The reference keeps two
point profiles (`scene/pointDisc.ts`) and now so do we: where the vertices
*are* the shape they are 0.3 units across, clamped to 1.6–10px; under a
solid or a wireframe they are 0.07 units, clamped to 0.7–2.4px and laid over
at 55% — a hint that they are there, never a second shape competing with the
faces. They read the depth buffer and do not write it, as the reference's
point material does, so a vertex behind a face stays hidden while two
overlapping dots both land. On an object whose faces carry their own colours
the dots take the face colour: the reference arrives there by splitting every
coloured face into three corners of its own before drawing, and §1.4a has in
any case already decided the vertex colours are not what that object shows.
2. **Their bench is lit and ours was not.** ONOSENDAI's *world* is unlit under
its bloom; its *workshop bench* — the mode where you turn one shard over to
understand it — uses `meshLambertMaterial vertexColors flatShading
side={FrontSide}` plus a second mesh of the same triangles wound the other
way in grey, so an open shape shows its inside and a missing face is plain.
Our viewer is a bench that was using the world's shading. §4 permits exactly
this ("A client MAY light an object instead, and many will") on two
conditions — normals per face, flat, from the triangle's own vertices, and
never smoothed across a shared vertex — and both are met.
**What lighting cost, and what it needed first.** Nothing on the wire says which
way round a face is: §1.4 makes winding meaningless and §4 draws both sides, so
an object whose triangles disagree is indistinguishable from one whose triangles
agree — until it is lit, at which point half a stamped block goes black. So
`SnoFaceWinding` ports `sno-core`'s `orient.ts` (MIT): faces sharing a clean
edge are made to agree, which groups them into patches, and each patch is then
turned as a whole by ray-casting its own normals against the rest of the object.
A face whose rays cross an odd number of faces *both* ways is buried inside a
join and is left undrawn. That pass is quadratic in the face count and is the
most expensive thing the feature does — **0.63 ms** for the largest real object
(94 faces), **27.6 ms** for an adversarial one at §1.8's ceiling (1004 faces) —
so it is computed once per object, off the composition thread, and held across a
turn. The per-frame cost is unchanged: a 512px frame of a real object is
**1.21 ms** flat and **1.62 ms** lit.
Hoisting the single-colour case out of the pixel loop while adding the light
paid for most of that: a face whose three corners agree — a stamped block, or
any face carrying its own colour — now resolves its colour once for the whole
triangle instead of testing for equality at every pixel. A real object at 1080px
went 8.04 → 6.81 ms in the same run that added the shading.
**Where we still differ, deliberately.** Their bench is a perspective camera on
`OrbitControls` (`fov 50`, dolly 0.6–60, screen-space pan); ours is orthographic
with object-rotation and a `graphicsLayer` zoom. That is a thumbnail-first
choice — no perspective distortion, no near plane to clip against, and a raster
that can be produced headless — and it is not worth revisiting for a reader who
is looking at someone else's object rather than building one. The light switch
defaults **off**, so the feed and a freshly opened viewer both show §4's unlit
reading and an object looks the same everywhere it is quoted; it is offered only
on a filled object, since points and wireframes look identical either way.
### 4.2d Following the comparison through
§4.2c ended with two gaps and a list of things ONOSENDAI does that we do not.
Five of those were ours to close, and closing the last one reversed a decision
this document had recorded twice.
**`unit` was never on screen.** The plan said to surface it and the first pass
did not, so a card read "8 vertices · 12 faces" for an object at `unit: 0` and
for the same geometry at `unit: 40` — a molecule and a mountain. `unit` is the
only field separating them. `CyberspaceScale` turns the exponent into a length
using the two facts that make it one: §1.6 says a model unit is `2^unit` base
units, and `CYBERSPACE_V2.md` §9.2 fixes the base unit, the gibson, at `2^-33`
metres. The measure table and the three-significant-figure rounding are
`sno-core/scale.ts`'s, and the test checks every value `unit` may take, 0 to 84,
against that file run over the same range — including `unit: 28`, which is
3.125 cm exactly and the one entry where a language's default rounding shows
(ECMAScript's `toPrecision` takes the larger; `roundToLong` agrees).
**An object naming an external palette was drawn in the wrong colours.**
`SnoPaletteEventReader` and `SnoParser`'s `fetchedPalette` existed from the
start and nothing ever called them with anything, so every §1.3a reference
resolved to the built-in 256 in silence. ONOSENDAI does not fetch either —
`resolvePalette` has zero call sites there — but Amethyst already has the relay
plumbing, so `WithSnoPalette` loads the named event by id (§1.3a pins a
reference to the event it names and forbids following the `e` chain forward)
and re-parses. It matters more than it sounds: every palette on the network
today is a `kind 3367` colour moment of three to six colours, so an object built
against one and drawn against 256 built-in entries is not slightly off, it is a
different object. The re-parse can also *fail* where the first succeeded — index
238 is fine against 256 entries and out of range against five — and §1.3 makes
that a defect in the payload rather than something to clamp, which is what the
reference does too.
That fetch exposed a real bug in the thumbnail cache. Coil keyed on
`sno:<event id>:<size>:<view>`, on the reasoning that an addressable object
republished under the same `d` arrives as a new id — true of geometry, false of
colour. One event id now legitimately produces two pictures a second apart, so
the key carries a hash of the resolved colours; without it the first paint would
have been served from memory forever and the fetch would have bought nothing.
**The default avatar drew nothing.** §8.10 makes an empty `kind 11333` the
default avatar — no geometry, no work owed, what everyone is before they adopt a
shape — and drawing nothing for it is correct and unhelpful: the note vanishes
out of the feed, which reads as a fault. It now gets the wireframe icosahedron
the reference puts in its place, built as an ordinary payload so it goes through
the same rasterizer and needs no second drawing path. Its face list is derived,
not remembered, because a hand-written icosahedron comes out looking almost
right; the test asserts twelve corners, thirty edges, twenty faces, every corner
on five and every edge on two, and it caught the hand-written one.
**A shard's `C` tag was parsed and dropped.** §7.6 lets an item carry its exact
coordinate so a client can "render it at a point rather than somewhere in the
region". Amethyst has no region to render it in, but the tag still carries one
thing a reader understands unaided: the plane (§2.4), which says whether the
shard was hidden at a place on Earth or at one with no physical counterpart.
That is bit 0 of the 256-bit coordinate and it is where the decoding stops, on
purpose. Pulling X, Y and Z out is §2.3 plus an 85-bit decimal printer, and what
it would put on screen is three twenty-six-digit integers that tell a reader
with no world exactly nothing. The reading that *would* mean something — the GPS
position under a dataspace coordinate — is §9.7: 96-digit decimal arithmetic
with a hand-rolled deterministic trig series, carried so every client agrees on
where a place on Earth is. Amethyst has no Earth to agree about.
**The ±64-unit rejection is gone, and should never have shipped.** §1.8 states
the position bound as an obligation on publishers and gives a reader a choice in
as many words: "A reader MAY reject such a payload and MAY instead repair it by
growing the extent." Both references repair. The argument recorded here for
rejecting — that we draw strangers' events in a feed — was a defence that was
never load-bearing: a far-off vertex costs the rasterizer nothing, because the
projection fits whatever it is handed to the frame, and the cost that does scale
is in the vertex and face counts, which §1.8 bounds and rule 3 enforces. What
was real was arithmetic: `whole * 120` overflows an Int, and `abs(Int.MIN_VALUE)`
is its own negative, so a coordinate could be silently rewritten to the origin.
That is now bounded where it actually lives — the whole is read as a Long, the
product is checked against `Int.MAX_VALUE` ticks, and past that there is no
coordinate to repair, only one that would wrap. An object at 65 units is drawn,
and rule 9 grows the extent past `MAX_EXTENT` to hold it, exactly as
`neededExtent` does.
### 4.3 Two surfaces, and only one of them needs to be fast
| Surface | Renderer | Cache |
| ---- | ---- | ---- |
| Feed / thread thumbnail — a static 3/4 view, no interaction | CPU raster once at a fixed rotation | Coil memory cache keyed by event id, exactly like `BlurHashFetcher`'s `BKeyer` |
| Full-screen viewer — drag to rotate, pinch to zoom | CPU raster per frame at reduced resolution while dragging; `drawVertices` on API ≥ 29 / skiko is a later optimization behind one interface, not a prerequisite | none |
Shading follows §4: unlit by default, both sides drawn, flat fill for a face carrying
`facecolors`, barycentric interpolation otherwise, **never** averaged smooth normals,
never invented geometry. A client SHOULD also draw the vertices as points in every
mode so a tiny object stays visible — cheap, do it.
`v: 1` negates Z on read (§2). Do it in the parser, so nothing downstream knows there
are two versions.
`unit`, `up` and `spin` have no meaning in a client with no world frame. Parse and
validate them (rule 10 rejects a bad `spin` whether or not `up` is present), surface
`unit` as a caption ("1 unit = 2^12 gibsons"), and ignore them for layout. §1.6 says
so explicitly: an application with no natural base unit "SHOULD render an object at a
size that suits its context rather than refusing it."
## 5. Security review items
- **Enforce the limits before allocating** (§6). Count the arrays; never size a
buffer from a declared length. The format has no length fields for this reason.
- **Face indices are the crash** (§1.4). Every index `0 ≤ i < vertexCount` and the
three distinct, validated before a single triangle reaches the rasterizer.
- **The position bound the spec leaves open.** ~~Reject beyond ±64 units.~~ Reversed,
see §4.2d: §1.8 offers a reader reject *or* repair and both references repair, so
rejecting made Amethyst the only client that refused an object the rest of the
network drew. The reach a payload can claim is not a denial of service on its own —
the projection fits whatever it is given to the frame — and the one genuine hazard,
`whole * 120` overflowing an Int, is now checked as what it is: a representation
limit at `Int.MAX_VALUE` ticks, computed in a Long so the multiply cannot wrap.
- **A palette reference is a network fetch driven by a stranger's event.** Fetch it
through the existing relay accessories with the account's relay set, never from a
URL, never blocking a frame, and cap it at one fetch per object.
- **`kind 3330` items may be unsigned** (§6), in which case `pubkey` is a claim. If
3330 is ever implemented, do not attribute.
- **Screen real estate is a resource** (§6). An object from a stranger draws in the
feed. Gate the thumbnail behind the same content-warning / follow-based rules the
feed already applies to images, rather than inventing a new one.
## 6. Sequencing — what landed
Built in this order, each step verified before the next:
1. `quartz/…/cyberspace/deck0003Sno/` — payload, §1.9 validator, built-in
palette, palette-event reader, kind 33331, kind 11333 + the §8.10 work gate.
**50 tests**: the 25-case rejection table generated from `sno-reference.py`,
the avatar ladder computed from §8.10's pseudocode, all seven live objects as
fixtures.
2. `commons/…/sno/SnoRasterizer` — CPU rasterizer, **15 tests**, one per rule §4
decides, plus the depth ordering a painter's algorithm gets wrong. Checked by
eye too: the object named Triforce comes out as a Triforce.
3. `commonsUI/…/sno/` — Coil fetcher + `SnoThumbnail` for the cached static
view, `SnoObjectViewer` drawing straight to a bitmap for the drag (each drag
angle would otherwise be a half-megabyte cache entry), `SnoObjectCard` /
`SnoAvatarCard`, and a new `PlatformImage.toComposeImageBitmap()` beside the
existing Coil bridge.
4. `amethyst/…/note/types/SnoObject.kt`, `SnoAvatar.kt` + `SnoShard.kt`, dispatched from
`NoteCompose` and `ThreadFeedView`, stored addressably/replaceably in
`LocalCache`, fetcher registered in both image loaders.
**Decisions as taken:** D1 accept (parity with sno-core — all seven objects
read), D1b tolerate, D2 deferred, D3 read-only with the writer in quartz, D4
inline quote + thread, D5 ~~clamp at ±64 units~~ repair by growing the extent,
as both references do (reversed in §4.2d).
**Deliberately not wired:** kind 3330 bag items (D2), an authoring/modeling
tool, and replacing a user's NIP-01 profile picture with their cyberspace
avatar — that last one is a product decision about every feed row, not
plumbing.
### The original plan
1. **Quartz parse + validate + built-in palette + the 25-case table.** No UI. Lands
as a self-contained, fully tested module; `amy` can dump a parsed object for
interop checks.
2. **`SnoRasterizer` in `commons/`** with jvmTest pixel assertions on the four valid
live fixtures. Still no UI.
3. **`commonsUI` thumbnail** — Coil fetcher + keyer mirroring `BlurHashFetcher`, and
a `SnoThumbnail` composable.
4. **Amethyst wiring** — `ui/note/types/SnoObject.kt` with `RenderSnoObject`, a
branch in `NoteCompose.kt` (next to `is ChessGameEvent ->`) and in
`ThreadFeedView.kt` (next to `is Ps1SaveEvent`), plus the `LocalCache` addressable
branch. At this point a `nostr:naddr1…` to an object renders inline.
5. **Full-screen viewer** with drag-to-rotate, `mode` respected, palette-reference
resolution, and the `unit`/vertex-count caption.
6. **`kind 11333` avatars** — same payload, replaceable container, plus the §8.10
proof-of-work gate on `nip13Pow/`. Small, self-contained, and the only part of
this surface where drawing is conditional on verification. Worth doing once
steps 1–2 exist, because avatars are the thing a Cyberspace user actually has.
7. **Optional later:** NIP-89 handler advertisement, an authoring/publishing path.
Steps 1–2 are pure logic with no Android surface and no dependency on the rest; they
are where the risk is, and they are testable without a device.
## 7. Decisions to make before step 1
- **D1 — legacy `v: 2` triples. Settled upstream; take it.** `sno-core` v0.1.4
accepts them deliberately (§2.6), so this is parity with the reader both clients
run, not leniency we invent. Read the triples literally, keep the declared
version's frame — a `v: 2`-with-triples object is **not** Z-flipped — and put the
reasoning in the KDoc, because `sno-reference.py` still rejects and a future reader
of our code will check the wrong arbiter first.
- **D1b — `name`.** Required in §1.1's table, enforced by neither reference
implementation (§2.6). Treat as optional with a fallback.
- **D2 — `kind 3330` bag items. Settled twice: the container shipped first, the bag
followed.** `SnoShardEvent` reads a shard handed over directly — quoted in a note,
or fetched by id — through the same parser and the same card as a standalone
object, because §3.2 is two containers for one format rather than two formats.
Opening the `kind 33330` bag around it was deferred here and is now built; see
`quartz/plans/2026-09-22-cyberspace-region-bags.md`. The deferral's *reason* was
restated correctly before it was lifted, and the restatement is what made the work
tractable: "we have no position in cyberspace" was never the obstacle — §7.7 is
explicit that a seeker's position never enters the cost of a sweep — the obstacle
was the compute and the protocol surface behind it. Both turned out to be
affordable in the corner anyone uses: a region key is 1.2 ms at height 8 against
the spec's own 1.3 ms, and a bag's own hint prices its search before a tree is
built. A bag whose hint prices it out of reach still says so and shows nothing,
which is §7.6 working as written: a failed decryption "MUST NOT be treated as an
error in the bag".
**The v1 tag form was built and then removed, deliberately.** Every `kind 3330`
publicly reachable on a relay predates the deck: 21 events, one pubkey, all inside
one minute on 2025-01-16, empty content, geometry in `vertices`/`colors`/`indices`
tags as flat comma-separated decimals. A reader for it took about 200 lines and
worked — and then rendering the whole corpus showed what it buys: **one black
triangle and one black dot**, two distinct shapes between the 21 events, every
colour `0,0,0`. The cyberspace readme settles it: *"Cyberspace v1 drafts are
deprecated and archived. They are not a valid basis for new implementations."* A
reverse-engineered decoder for a deprecated encoding that no current spec
describes is a maintenance liability priced against two black shapes, so it went.
The consequence, stated plainly: **no publicly reachable 3330 renders today.** The
current ones are sealed in bags and the old ones are v1. The code is right for the
shards the current client writes, and is exercised by tests rather than by the
network.
- **D3 — publishing.** Recommend **read-only first**. Appendix B.4 is right that the
deliverable is software that emits the format, but a modeling tool is a screen, not
a feature, and the reader is what makes objects visible at all. Build the writer
(`SnoObjectEvent.build`) in quartz anyway — it is twenty lines and the tests want
round-tripping.
- **D4 — where the object surfaces.** Inline quote + thread view is the minimum.
A dedicated feed of `kind 33331` is not obviously wanted; there are seven objects
on the network.
- **D5 — the ±64 unit clamp** (§5). **Reversed.** It shipped as a deliberate
divergence and it was the wrong call. Neither `sno-reference.py` nor `sno-core`'s
`fromPayload` bounds how far a vertex may lie from the origin — both grow the
extent, which is what §8's third open question admits and what §1.8 names as one
of a reader's two choices. sno-core even has a `validPoint(p, extent)` helper and
deliberately does not call it on the read path. "We render strangers' events in a
feed" argued for a defence that was never load-bearing: a far-off vertex costs the
rasterizer nothing, because the projection fits whatever it is handed to the frame
and the cost is in the vertex and face counts, which §1.8 *does* bound and rule 3
*does* enforce. The only real hazard was arithmetic, and that is now bounded where
it actually lives. See §4.2d.
## 8. Size, for the record
Nothing here threatens a relay. The largest live object is 3,248 bytes for the whole
event. The spec's own ceiling is 18.9 KB for a typical 512-vertex/1024-face object
and 33.5 KB worst case with a color on every face, against strfry's stock 65,536-byte
`events.maxEventSize`. The built-in palette adds 3.3 KB only when carried inline,
which Amethyst never does as a reader.
+3 -3
View File
@@ -240,6 +240,6 @@
# The enum FIELDS are already pinned by the blanket `-keepclassmembers enum *`
# above; that rule deliberately lets the CLASS be renamed, which is exactly what
# breaks here. Every enum used as a route argument needs its name too.
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab { *; }
-keep class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType { *; }
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.GeocacheTab { *; }
-keep class com.vitorpamplona.amethyst.commons.model.navigation.DiscoverTab { *; }
-keep class com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType { *; }
-keep class com.vitorpamplona.amethyst.commons.model.navigation.GeocacheTab { *; }
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
@@ -30,7 +30,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -57,8 +57,10 @@ class DeferredAnimationTest {
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
// enabled = true is the deferred path (DeferredCrossfade, internal to commonsUI).
CrossfadeIfEnabled(
targetState = state.value,
enabled = true,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
@@ -67,10 +67,13 @@ import com.vitorpamplona.amethyst.commons.resources.push_server_title
import com.vitorpamplona.amethyst.commons.resources.push_server_uses_app_explainer
import com.vitorpamplona.amethyst.commons.resources.quick_action_dont_show_again_button
import com.vitorpamplona.amethyst.commons.resources.select_push_server
import com.vitorpamplona.amethyst.commons.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -27,9 +27,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
@Composable
fun TranslatableRichTextViewer(
@@ -24765,9 +24765,9 @@ Lcom/vitorpamplona/amethyst/ui/StringResourceCacheKt$StringResSetup$lambda$0$0$$
SPLcom/vitorpamplona/amethyst/ui/StringResourceCacheKt$StringResSetup$lambda$0$0$$inlined$onPauseOrDispose$1;-><init>(Landroidx/lifecycle/compose/LifecycleResumePauseEffectScope;)V
Lcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;
SPLcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;->getActivityOrNull(Landroid/content/Context;)Landroidx/activity/ComponentActivity;
Lcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
@@ -25489,14 +25489,14 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$Key
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;->getLambda$-1905508794$amethyst()Lkotlin/jvm/functions/Function3;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;->lambda__1905508794$lambda$0(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;-><init>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;->getLambda$-1905508794$amethyst()Lkotlin/jvm/functions/Function3;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;->lambda__1905508794$lambda$0(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;-><init>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;->$r8$lambda$EB2Pyswic2JGUT6jyNtb4e7r5z8(Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;->$r8$lambda$raCAC-FTNT20_jVOcW9Lmx1V5zI(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Lkotlin/Unit;
@@ -25529,12 +25529,12 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$External
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;-><init>(Landroidx/compose/runtime/MutableState;)V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;->LoginButton$lambda$0(ZLandroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;->LoginButton(ZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;-><init>(Z)V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;->LoginButton$lambda$0(ZLandroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;->LoginButton(ZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;-><init>(Z)V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><init>()V
@@ -25613,26 +25613,28 @@ Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$$Ex
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$$ExternalSyntheticLambda3;-><init>(Lkotlin/jvm/functions/Function2;I)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$OpenURIIfNotLoggedIn$lambda$5$0$$inlined$onDispose$1;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$OpenURIIfNotLoggedIn$lambda$5$0$$inlined$onDispose$1;-><init>(Landroidx/activity/ComponentActivity;Landroidx/core/util/Consumer;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButtonKt;->SignUpButton(Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/theme/ColorKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getPurple200()J
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getPurple500()J
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getTeal200()J
Lcom/vitorpamplona/amethyst/ui/theme/ShapeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getQuoteBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getShapes()Landroidx/compose/material3/Shapes;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize10dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize13dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize20dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize35dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/SignUpButtonKt;->SignUpButton(Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getPurple200()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getPurple500()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getTeal200()J
Lcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getQuoteBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getShapes()Landroidx/compose/material3/Shapes;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize10dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize13dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize20dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize35dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->$r8$lambda$7JAdMf4levt4F3mr6nvsppu__xY(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->$r8$lambda$GsT95FSXMVwriWjqAt3KW-oAPpo(Landroid/view/View;ZLandroidx/compose/material3/ColorScheme;)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;-><clinit>()V
@@ -25645,16 +25647,16 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9$0(Landro
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lcom/vitorpamplona/amethyst/model/ThemeType;Lcom/vitorpamplona/amethyst/model/AccentColorType;Lcom/vitorpamplona/amethyst/model/FontFamilyType;Lcom/vitorpamplona/amethyst/model/FontSizeType;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->darkColors(Lcom/vitorpamplona/amethyst/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->lightColors(Lcom/vitorpamplona/amethyst/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->onAccentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccentContainer-DxMtmZc(JZ)J
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;-><init>(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
@@ -25676,19 +25678,19 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$MarkDownStyleOnLight$1;-><clinit>
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$MarkDownStyleOnLight$1;-><init>()V
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getDefaultHeadingStyle()Lkotlin/jvm/functions/Function2;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getDefaultParagraphSpacing()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;->getDefaultHeadingStyle()Lkotlin/jvm/functions/Function2;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getDefaultParagraphSpacing()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;
SPLcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;-><init>()V
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
@@ -458,13 +459,14 @@ class AppModules(
scope = applicationIOScope,
dns = surgeDns,
// Transparently rewrites sha256-keyed HTTP requests to the local
// Blossom cache when the master toggle is on, the profile-pictures-only
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
shouldBridgeBlossomCache = {
// Blossom cache when the master toggle is on, the probe sees
// 127.0.0.1:24242 as available, and either the profile-pictures-only
// restriction is off or the request is a profile picture.
shouldBridgeBlossomCache = { profilePicture ->
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
master && (profilePicture || !profileOnly) && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
@@ -477,6 +479,7 @@ class AppModules(
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
onLocalBlossomCacheUnreachable = { localBlossomCacheProbe.markUnavailable() },
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -1035,14 +1038,16 @@ class AppModules(
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
// Same gate as the interceptor: the profile-pictures-only restriction keeps note
// media (including native `blossom:` URIs) off the local cache.
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly
},
localCacheProbe = localBlossomCacheProbe,
scope = applicationIOScope,
)
}
@@ -1148,7 +1153,8 @@ class AppModules(
blossomServerResolver = { blossomResolver },
// Through the role builder (not raw getHttpClient) so Coil's image
// traffic carries the "image" ledger tag. Same Tor decision inside.
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
// Marked as media so the local Blossom cache may serve these downloads.
callFactory = { LocalBlossomMediaCallFactory(roleBasedHttpClientBuilder.okHttpClientForImage(it)) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
@@ -1341,8 +1347,7 @@ class AppModules(
state.account.settings.localBlossomCacheProfilePicturesOnly
.drop(1),
).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
@@ -1358,8 +1363,14 @@ class AppModules(
}
applicationIOScope.launch {
localBlossomCacheProbe.available.drop(1).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
}
}
// A resolution to the local cache made while a media type was not Tor-routed must not
// survive the user switching it to Tor: the cache would keep fetching it outside Tor.
applicationIOScope.launch {
torPrefs.value.propertyWatchFlow.drop(1).collect {
blossomResolver.clearCaches()
}
}
// Warm the local-cache probe so the very first image load doesn't pay
@@ -1367,6 +1378,22 @@ class AppModules(
applicationIOScope.launch {
localBlossomCacheProbe.isAvailable()
}
// Re-checks the local cache about once a minute while the feature is on, so the bridge
// comes back when the cache app is restarted and turns off when it is closed, even while
// no `blossom:` URI is being resolved. A loopback HEAD never wakes the radio.
applicationIOScope.launch {
while (true) {
delay(LOCAL_BLOSSOM_CACHE_RECHECK_MS)
if (sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value == true
) {
localBlossomCacheProbe.isAvailable()
}
}
}
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
@@ -1505,5 +1532,8 @@ class AppModules(
* check and burn CPU on a heap that has nothing left to give.
*/
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
/** How often the local Blossom cache is re-probed while the feature is enabled. */
private const val LOCAL_BLOSSOM_CACHE_RECHECK_MS = 60_000L
}
}
@@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_connect_requests_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class SignerConnectActivity : ComponentActivity() {
@@ -95,12 +95,12 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_no
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
@@ -118,9 +118,6 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.AccountMarmotActions
import com.vitorpamplona.amethyst.model.AccountRelayGroupActions
import com.vitorpamplona.amethyst.model.EventBroadcaster
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
@@ -1431,6 +1428,9 @@ class Account(
// wraps still register with the delivery-ticks tracker at publish time.
// Null for restart-restored jobs, whose rumor id wasn't persisted.
displayedNoteId: HexKey? = null,
// Runs once the mined wraps are handed to the relays (e.g. to drop the
// composer's draft). Not persisted: a restart-restored job skips it.
onPublished: suspend () -> Unit = {},
): Boolean {
val queue = powQueue() ?: return false
if (seals.isEmpty()) return true
@@ -1465,7 +1465,10 @@ class Account(
}
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
},
publish = { wraps -> broadcastPrivately(wraps, displayedNoteId) },
publish = { wraps ->
broadcastPrivately(wraps, displayedNoteId)
onPublished()
},
)
return true
}
@@ -2947,13 +2950,24 @@ class Account(
broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer))
}
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) = sendNip17PrivateMessage(template) {}
/**
* [onSent] runs once the wraps are handed to the relays — right away when
* the message is not mined, after the nonce search when it is — so the
* composer can keep its draft for as long as the message only exists in
* the mining queue.
*/
suspend fun sendNip17PrivateMessage(
template: EventTemplate<ChatMessageEvent>,
onSent: suspend () -> Unit,
) {
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
if (powDifficulty != null) {
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id, onPublished = onSent)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
@@ -2962,6 +2976,7 @@ class Account(
}
broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer))
onSent()
}
/**
@@ -2973,10 +2988,13 @@ class Account(
*
* [powOverrideDifficulty] is the composer chip's per-post override:
* null follows the account's gift-wrap setting, 0 disables mining.
* [onSent] runs once the wraps are handed to the relays (after mining,
* when mined).
*/
suspend fun sendPrivateNote(
template: EventTemplate<TextNoteEvent>,
powOverrideDifficulty: Int? = null,
onSent: suspend () -> Unit = {},
) {
if (!isWriteable()) return
@@ -2986,10 +3004,11 @@ class Account(
val senderNote = signer.sign(template)
val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet()
val seals = NIP17Factory().createSeals(senderNote, recipients, signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id, onPublished = onSent)) return
}
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
onSent()
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
@@ -70,8 +70,8 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_deny_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_hide_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_not_now
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_show_event
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -48,7 +48,7 @@ import java.util.concurrent.TimeUnit
* to as ACCEPTED.
*
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* consults [CalendarReminderLogStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*
@@ -43,12 +43,12 @@ import com.vitorpamplona.amethyst.commons.resources.crashreport_found
import com.vitorpamplona.amethyst.commons.resources.crashreport_found_send
import com.vitorpamplona.amethyst.commons.resources.would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -42,6 +42,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
@@ -94,6 +95,18 @@ abstract class FlowProgressForegroundService<T> : Service() {
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
protected open val refreshMs: Long? = null
/**
* How long to keep the service (and whatever it holds up) alive after [isActive] turns
* false, before stopping. Lets work that was just handed off asynchronously — e.g. a
* broadcast still leaving through the relay pool — finish before the process loses its
* foreground protection and gets frozen. A new active emission during the grace keeps
* the service running. Asked each time the work drains, so it can depend on how it did.
*/
protected open fun stopGraceMs(): Long = 0L
/** The card shown during [stopGraceMs]; null keeps the last one. */
protected open fun renderDraining(): Content? = null
protected abstract fun state(): StateFlow<T>
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
@@ -222,9 +235,15 @@ abstract class FlowProgressForegroundService<T> : Service() {
onStarted()
watchJob =
scope.launch {
state().collect { value ->
state().collectLatest { value ->
onEmission(value)
if (!isActive(value)) {
// collectLatest: a new active emission cancels this pending stop.
val grace = stopGraceMs()
if (grace > 0) {
renderDraining()?.let { notify(buildNotification(it)) }
delay(grace)
}
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
@@ -246,7 +265,7 @@ abstract class FlowProgressForegroundService<T> : Service() {
private fun startForegroundCompat(value: T) {
ensureChannel()
val notification = buildNotification(value)
val notification = buildNotification(render(value))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, fgsType)
} else {
@@ -254,18 +273,19 @@ abstract class FlowProgressForegroundService<T> : Service() {
}
}
private fun updateNotification(value: T) {
private fun updateNotification(value: T) = notify(buildNotification(render(value)))
private fun notify(notification: Notification) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(notificationId, buildNotification(value))
manager.notify(notificationId, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(value: T): Notification {
val content = render(value)
private fun buildNotification(content: Content): Notification {
val style =
when (val bar = content.bar) {
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@@ -44,18 +45,49 @@ import kotlin.coroutines.cancellation.CancellationException
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val imageLoader: ImageLoader,
private val blossomServerResolver: () -> BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
private val networkFetcher: (url: String, diskCacheKey: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
try {
val urlResult = blossomServerResolver().findServers(data.toString())
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
val uri = data.toString()
val key = options.diskCacheKey ?: diskCacheKey(uri)
// A blob already on disk is served without resolving: resolution may HEAD
// servers and wait on relays, and fails outright offline, where the bytes are
// still sitting in the cache. NetworkFetcher reads the snapshot under [key]
// before it ever looks at the url.
val onDisk = options.diskCachePolicy.readEnabled && imageLoader.diskCache?.openSnapshot(key)?.use { true } ?: false
val fromDisk =
if (onDisk) {
// Evicted between the check and the read: the fetcher would try the
// network with the unresolvable `blossom:` uri, so resolve after all.
try {
networkFetcher(uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} else {
null
}
fromDisk ?: networkFetcher(blossomServerResolver().findServers(uri)?.serverUrl ?: uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
/**
* Blossom blobs are content-addressed, so the sha256 names the bytes wherever they
* were served from. Keying on it (instead of the resolved server URL) finds the
* blob again after a restart, a probe flip or a different server answering.
*/
fun diskCacheKey(uri: String): String = BlossomUri.parse(uri)?.let { "blossom:${it.sha256}" } ?: uri
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: () -> BlossomServerResolver,
@@ -78,20 +110,19 @@ class BlossomFetcher(
if (!isApplicable(data)) return null
// Wrapped per resolved url (not per Factory) because the server the
// blob actually lives on is only known once the resolver has run.
return BlossomFetcher(options, data, blossomServerResolver) { url ->
return BlossomFetcher(options, data, imageLoader, blossomServerResolver) { url, diskCacheKey ->
val keyedOptions = options.copy(diskCacheKey = diskCacheKey)
readAuthAware(url, readAuth) { authHeader ->
NetworkFetcher(
url = url,
options = options.withAuthHeader(authHeader),
options = keyedOptions.withAuthHeader(authHeader),
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
// Keyed on the resolved server url, which is what the NetworkFetcher above
// caches under -- not on the `blossom:` uri the request came in as.
}.onSystemFileSystem(options.diskCacheKey ?: url)
}.onSystemFileSystem(diskCacheKey)
}
}
@@ -48,9 +48,8 @@ import com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher
import com.vitorpamplona.amethyst.commons.service.image.ThumbHashFetcher
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.commons.sno.SnoFetcher
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
import com.vitorpamplona.amethyst.service.images.ProfilePictureFetcher
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -87,9 +86,10 @@ class ImageLoaderSetup {
// coordinates through a map of in-flight fetches that it owns, so it only
// works when every fetcher shares the same instance -- a fresh one per
// request can never see anybody else's fetch and the de-dupe silently
// no-ops. Shared across all three network-backed factories so a feed
// image and the same blob reached through `blossom:` (or a profile
// picture) still collapse onto one download.
// no-ops. Shared across all three network-backed factories so the same
// key requested through any of them (e.g. a feed image and a profile
// picture with the same URL) collapses onto one download. `blossom:`
// blobs are keyed by their sha256 instead (see BlossomFetcher.diskCacheKey).
val concurrentRequests = DeDupeConcurrentRequestStrategy()
SingletonImageLoader.setUnsafe(
@@ -108,11 +108,13 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(SnoFetcher.Factory)
add(ThumbHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory, concurrentRequests, readAuth))
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope, concurrentRequests, readAuth))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(SnoFetcher.SKeyer)
add(ThumbHashFetcher.TKeyer)
add(ProfilePictureFetcher.BKeyer)
add(OkHttpFactory(callFactory, concurrentRequests, readAuth))
@@ -36,6 +36,8 @@ import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor.Companion.PROFILE_PICTURE
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
@@ -121,7 +123,8 @@ class ProfilePictureFetcher(
NetworkFetcher(
url = data.url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(data.url).asNetworkClient() },
// Marked so the local Blossom cache bridge can honour "profile pictures only".
networkClient = lazy { LocalBlossomMediaCallFactory(networkClient(data.url), PROFILE_PICTURE).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -38,14 +38,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.commons.ui.theme.Size75dp
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.ui.theme.videoGalleryModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -43,7 +43,7 @@ import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.captions_off
import com.vitorpamplona.amethyst.commons.resources.captions_unnamed_track
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
@@ -41,11 +41,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.enter_picture_in_picture
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -43,12 +43,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.delay
@Preview
@@ -50,16 +50,16 @@ import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.playback_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_fullscreen
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -40,12 +40,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.note.EnablePiP
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -44,11 +44,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pause
import com.vitorpamplona.amethyst.commons.resources.play
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PlayIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -45,9 +45,9 @@ import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -33,8 +33,8 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@OptIn(UnstableApi::class)
@Preview
@@ -64,6 +64,12 @@ import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.service.cast.CastRequest
@@ -78,12 +84,6 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -52,12 +52,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.video_download_has_started_toast
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.launch
@Preview
@@ -42,12 +42,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.skip_back
import com.vitorpamplona.amethyst.commons.resources.skip_forward
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
private val FadeIn = fadeIn()
private val FadeOut = fadeOut()
@@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_settings_video_quality
import com.vitorpamplona.amethyst.commons.resources.video_quality_auto
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
@@ -42,8 +42,8 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
@@ -40,6 +40,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.state.rememberMuteButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.commons.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.constrain
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@OptIn(UnstableApi::class)
@Composable
@@ -39,6 +39,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.http.DynamicCallFactory
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
@@ -59,7 +60,11 @@ class PlaybackService : MediaSessionService() {
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
// Marked as media so the local Blossom cache may serve these downloads.
val dataSourceFactory =
OkHttpDataSource
.Factory(okHttpClient)
.setDefaultRequestProperties(mapOf(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER to LocalBlossomCacheRedirectInterceptor.MEDIA))
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
@@ -22,15 +22,20 @@ package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.PowerManager
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPhase
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -44,6 +49,17 @@ import kotlinx.coroutines.launch
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* Staying schedulable is not enough on its own once the user also locks the
* screen or leaves the app, so while it runs the service also:
* - holds a partial wake lock, or the CPU suspends with the screen off and the
* nonce search stalls until the phone is unlocked;
* - keeps the relay pool (and Tor) up. The UI stops holding them ~30 s after
* the app is backgrounded, so a post mined after that would only reach the
* in-memory outbox — while its checkpoint and draft were already deleted —
* and die with the process;
* - lingers [PUBLISH_GRACE_MS] after the queue drains, so the event that was
* just handed to the pool actually leaves before the process is frozen.
*
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
* to that card.
@@ -68,17 +84,60 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private var sessionTotal = 0
private var lastQueueSize = 0
// Whether the last non-empty queue still had a job publishing: a queue that drains by
// publishing needs the grace for the event to leave; one drained by cancel does not.
private var drainedByPublishing = false
override fun stopGraceMs() = if (drainedByPublishing) PUBLISH_GRACE_MS else 0L
override fun renderDraining() = Content(stringRes(this, R.string.pow_notification_sending), null, Bar.Indeterminate)
// Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
running = true
wakeLock =
runCatching {
(getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:pow-mining")
.apply {
setReferenceCounted(false)
acquire(WAKE_LOCK_TIMEOUT_MS)
}
}.onFailure { Log.w(TAG, "Could not acquire the mining wake lock", it) }
.getOrNull()
// Re-arm the timeout while the service lives: before Android 14 there is no FGS budget
// and a hard nonce can mine for hours, but a lock with no timeout could outlive a
// service that died without onDestroy.
scope.launch {
while (true) {
delay(WAKE_LOCK_TIMEOUT_MS / 2)
wakeLock?.let { if (it.isHeld) it.acquire(WAKE_LOCK_TIMEOUT_MS) }
}
}
// Mirrors what the resumed UI collects (AccountScreen's ManageRelayServices +
// ManageWebOkHttp): subscribed, they keep the relay pool connected and, through
// the connector's combine and the proxy-port provider, Tor up.
val app = Amethyst.instance
scope.launch(Dispatchers.IO) {
launch { app.relayProxyClientConnector.relayServices.collect {} }
launch { app.okHttpClients.defaultHttpClient.collect {} }
launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} }
}
}
override fun onDestroy() {
running = false
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
@@ -97,6 +156,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
}
override fun onEmission(value: ImmutableList<PoWJobState>) {
if (value.isNotEmpty()) drainedByPublishing = value.any { it.phase == PoWJobPhase.PUBLISHING }
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
@@ -155,6 +215,13 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private const val PROGRESS_REFRESH_MS = 30_000L
// Time for a just-mined post to connect, send and get its OK before the service
// lets go of the relays and the process becomes freezable.
private const val PUBLISH_GRACE_MS = 20_000L
// Safety net only: onDestroy releases it, and the service re-arms it while alive.
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
@@ -97,15 +97,15 @@ import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_read_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_send_dm
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread_with
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -25,9 +25,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.payment_required_title
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@Composable
@@ -42,13 +42,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable
fun NotifyRequestDialog(
@@ -45,11 +45,11 @@ import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_conn_t
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_reconnects
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_wakelock
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
@@ -97,24 +99,29 @@ class UsageCountingInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
// hits to 127.0.0.1) never touches the radio: counting it would inflate
// the network numbers — and could trip the background-data alert — for
// exactly the users who cache aggressively to SAVE data.
// Loopback traffic never touches the radio: counting it would inflate the
// network numbers — and could trip the background-data alert — for exactly
// the users who cache aggressively to SAVE data. This interceptor is the
// outermost one, so it sees URLs BEFORE LocalBlossomCacheRedirectInterceptor
// rewrites them to 127.0.0.1; the host that was actually contacted is only
// known from the response's request.
if (isLoopback(request.url.host)) return chain.proceed(request)
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
val startedAtMs = nowMs()
val response = chain.proceed(request)
val response =
try {
chain.proceed(request)
} catch (e: IOException) {
countRequest(request, role)
throw e
}
if (isLoopback(response.request.url.host)) return response
countRequest(request, role)
return response
.newBuilder()
.body(
@@ -131,6 +138,19 @@ class UsageCountingInterceptor(
).build()
}
private fun countRequest(
request: Request,
role: String,
) {
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
}
companion object {
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
@@ -37,9 +37,9 @@ import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_sent_title
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.commons.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
/**
* Android [ScheduledPostNotifier]: posts a user-visible system notification when a
@@ -27,7 +27,6 @@ import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.davotoula.lightcompressor.video.GifToMp4Converter
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
import id.zelory.compressor.Compressor
@@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.service.image.BlurhashWrapper
import com.vitorpamplona.amethyst.commons.service.image.ThumbhashWrapper
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
import java.nio.ByteBuffer
@@ -28,17 +28,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
@@ -46,10 +52,20 @@ class BlossomServerResolver(
val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
// Unresolvable URIs, with when they failed. Without this every retry — each ExoPlayer
// load attempt blocks a loader thread on it — paid the full resolution timeout again.
private val missCache = LruCache<String, Long>(200)
// One resolution per URI at a time: the feed preview, Coil and the player asking for the
// same `blossom:` URI together share it instead of each firing its own HEADs and relay
// subscriptions. Runs in [scope] so a caller that goes away doesn't cancel it for the rest.
private val inFlight = ConcurrentHashMap<String, Deferred<BlossomUriServer?>>()
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
@@ -57,41 +73,71 @@ class BlossomServerResolver(
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
// Bumped by [clearCaches]. A resolution that started before a clear (e.g. it picked the
// local cache just before the cache went down) must not write its stale answer back.
private val generation = AtomicInteger(0)
/** Forgets every resolution, e.g. when the local cache comes up or goes down. */
fun clearCaches() {
generation.incrementAndGet()
uriToUrlCache.evictAll()
missCache.evictAll()
blossomHitCache.cache.evictAll()
}
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
missCache[uriStr]?.let { failedAt ->
if (TimeUtils.nowMillis() - failedAt < MISS_TTL_MS) return null
}
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
val result =
withContext(Dispatchers.IO) {
withTimeoutOrNull(10000) {
findServersInner(uriStr)
val resolution =
inFlight.computeIfAbsent(uriStr) {
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
scope.async(Dispatchers.IO) {
val startedAt = generation.get()
try {
val result = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { findServersInner(uriStr) }
// Cleared while resolving: answer the waiting callers, remember nothing.
if (startedAt == generation.get()) {
if (result != null) {
uriToUrlCache.put(uriStr, result)
} else {
missCache.put(uriStr, TimeUtils.nowMillis())
}
}
result
} finally {
inFlight.remove(uriStr)
}
}
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
return resolution.await()
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
val expectedMimeType = mimeTypeMap[uri.extension]
// Same rule as the HTTP-level bridge, which Tor-routed clients don't carry: media the
// user sends through Tor must not be handed to the local cache, which would fetch it
// from the origin outside Tor.
if (useLocalBlossomCache() && !isTorRouted(uri, expectedMimeType) && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
// Bounded well under RESOLVE_TIMEOUT_MS so a hint server that drops packets
// leaves time for the author's own server list below.
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size, XS_TIMEOUT_MS)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
@@ -135,13 +181,20 @@ class BlossomServerResolver(
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
timeoutMs: Long = RESOLVE_TIMEOUT_MS,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
firstNotNullOrNullAsync(servers, timeoutMs) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
/** Whether this blob's media type would be fetched through Tor from a regular (clearnet) server. */
private fun isTorRouted(
uri: BlossomUri,
mimeType: String?,
): Boolean = client(uri.toServerUrl() ?: "https://blossom.invalid/${uri.filename()}", mimeType).proxy != null
fun client(
url: String,
mimeType: String?,
@@ -153,9 +206,13 @@ class BlossomServerResolver(
else -> httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
fun canResolve(scheme: String) = scheme.equals(SCHEME, ignoreCase = true)
companion object {
const val SCHEME = "blossom"
private const val RESOLVE_TIMEOUT_MS = 10_000L
private const val XS_TIMEOUT_MS = 4_000L
private const val MISS_TTL_MS = 30_000L
}
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
@@ -48,6 +49,9 @@ class LocalBlossomCacheProbe(
@Volatile
private var cachedAtMs: Long = 0L
// Bumped by [markUnavailable]; lets a probe that raced it discard its stale positive result.
private val unavailableMarks = AtomicInteger(0)
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available
@@ -66,7 +70,11 @@ class LocalBlossomCacheProbe(
return@withLock _available.value
}
val startedAt = unavailableMarks.get()
val newResult = probe()
// A refused connection reported while this probe was in flight is newer evidence
// than a HEAD that may have succeeded just before the cache went away.
if (newResult && startedAt != unavailableMarks.get()) return@withLock false
_available.value = newResult
cachedAtMs = currentTimeMs()
newResult
@@ -80,6 +88,17 @@ class LocalBlossomCacheProbe(
cachedAtMs = 0L
}
/**
* Records that the cache just refused a connection. Flipping [available] right away turns
* the bridge off everywhere it is read, instead of letting every sha256 URL keep failing
* against the dead loopback port until the positive TTL runs out and someone re-probes.
*/
fun markUnavailable() {
unavailableMarks.incrementAndGet()
cachedAtMs = currentTimeMs()
_available.value = false
}
// Confined to Dispatchers.IO because callers reach this through suspend
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
// on the main dispatcher: building the OkHttp client and issuing the HEAD
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
@@ -56,13 +56,18 @@ class ServerHeadCache {
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
// Only a definitive answer is remembered. A 5xx, 429 or auth challenge is
// transient: caching it would hide this server for the app's lifetime.
if (response.code == 404 || response.code == 410) {
cache.put(url, HasFile.NoFile)
}
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
// type/subtype only: a `; charset=binary` parameter must not fail the match below.
val mimeType = response.header("Content-Type")?.toMediaTypeOrNull()?.let { "${it.type}/${it.subtype}" }
return if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
@@ -75,7 +80,8 @@ class ServerHeadCache {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
// Offline, DNS failure, timeout: says nothing about the server having the file,
// so it is not cached — a brief outage must not hide the blob until eviction.
return HasFile.NoFile
}
}
@@ -104,7 +110,7 @@ class ServerHeadCache {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
if (expectedSize == null && result.size > 0 && result.mimeType.equals(expectedMimeType, ignoreCase = true)) {
return url
}
}
@@ -29,6 +29,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
@@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETT
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
@@ -20,25 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -50,116 +37,12 @@ fun <T> CrossfadeIfEnabled(
label: String = "Crossfade",
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (accountViewModel.settings.isPerformanceMode()) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
}
}
/** Latches the first time a crossfade's target moves off the value it was composed with. */
private class ChangeLatch {
var changed = false
}
/**
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
* is something to animate.
*
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
* composition*, even though first composition has nothing to cross-fade — target and initial state
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
* animated element, and during a scroll essentially none of them run, because the underlying counts
* and icons do not change in the second a card is on screen.
*
* So the plain content renders until the target actually moves. At that point the transition is
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
* the new one, so the first real change still animates exactly as before; every later change
* animates through the now-live transition normally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { ChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> MyCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.MyCrossfade(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
contentKey: (targetState: T) -> Any? = { it },
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap = remember { mutableScatterMapOf<T, @Composable () -> Unit>() }
// If not animating, just display the current state
if (currentState == targetState && (currentlyVisible.size != 1 || currentlyVisible[0] != targetState)) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
if (targetState !in contentMap) {
// Replace target with the same key if any
val replacementId =
currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) }
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.fastForEach { stateForContent ->
contentMap[stateForContent] = {
val alpha by
animateFloat(transitionSpec = { animationSpec }) {
if (it == stateForContent) 1f else 0f
}
Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) }
}
}
}
Box(modifier, contentAlignment) {
currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } }
}
}
) = CrossfadeIfEnabled(
targetState = targetState,
enabled = !accountViewModel.settings.isPerformanceMode(),
modifier = modifier,
contentAlignment = contentAlignment,
animationSpec = animationSpec,
label = label,
content = content,
)
@@ -78,6 +78,19 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_inv
import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -86,25 +99,12 @@ import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -45,12 +45,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.copy_stack_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg2
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import kotlinx.coroutines.launch
import java.io.PrintWriter
import java.io.StringWriter
@@ -71,20 +71,20 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -75,13 +75,13 @@ import com.vitorpamplona.amethyst.commons.resources.twitter
import com.vitorpamplona.amethyst.commons.resources.twitter_proof_url_template
import com.vitorpamplona.amethyst.commons.resources.username
import com.vitorpamplona.amethyst.commons.resources.website_url
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -29,9 +29,9 @@ import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_body
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun StrippingFailureDialog(confirmation: SuspendableConfirmation) {
@@ -63,20 +63,20 @@ import com.vitorpamplona.amethyst.commons.resources.bolt12_offers_section_explai
import com.vitorpamplona.amethyst.commons.resources.delete_bolt12_offer
import com.vitorpamplona.amethyst.commons.resources.invalid_bolt12_offer
import com.vitorpamplona.amethyst.commons.resources.no_bolt12_offers_message
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun Bolt12OffersScreen(
@@ -61,9 +61,15 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.mediaServers.ui.MediaServerEditField
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState
import com.vitorpamplona.amethyst.commons.relays.ui.draggableRelayItem
import com.vitorpamplona.amethyst.commons.relays.ui.relayDragHandle
import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_a_blossom_server
import com.vitorpamplona.amethyst.commons.resources.add_media_server
@@ -88,20 +94,15 @@ import com.vitorpamplona.amethyst.commons.resources.media_servers_upload_section
import com.vitorpamplona.amethyst.commons.resources.my_blossom_data
import com.vitorpamplona.amethyst.commons.resources.no_blossom_server_message
import com.vitorpamplona.amethyst.commons.resources.use_default_servers
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.utils.Rfc3986
import org.jetbrains.compose.resources.StringResource
@@ -57,13 +57,13 @@ import com.vitorpamplona.amethyst.commons.resources.local_blossom_cache_profile_
import com.vitorpamplona.amethyst.commons.resources.media_servers
import com.vitorpamplona.amethyst.commons.resources.use_local_blossom_cache
import com.vitorpamplona.amethyst.commons.resources.use_local_blossom_cache_caption
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
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
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun AllMediaServersScreen(
@@ -212,7 +212,7 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) {
*/
@Composable
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
val probeAvailable by accountViewModel.localBlossomCacheDetected
.collectAsStateWithLifecycle()
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
@@ -98,6 +98,7 @@ import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.blossom_delete_from_host
@@ -127,16 +128,15 @@ import com.vitorpamplona.amethyst.commons.resources.manage_stored_files_empty
import com.vitorpamplona.amethyst.commons.resources.my_blossom_data
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.resources.retry
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
@@ -62,6 +62,8 @@ 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.mediaServers.ui.MediaServerEditField
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_a_blossom_server
import com.vitorpamplona.amethyst.commons.resources.blossom_import_add_url_label
@@ -81,14 +83,13 @@ import com.vitorpamplona.amethyst.commons.resources.blossom_import_sources_secti
import com.vitorpamplona.amethyst.commons.resources.blossom_import_start_button
import com.vitorpamplona.amethyst.commons.resources.blossom_import_title
import com.vitorpamplona.amethyst.commons.resources.delete_media_server
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.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/** Vibrant palette for server monograms; picked deterministically from the host name. */
private val ImportMonogramColors =
@@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.blossom_sync_done
import com.vitorpamplona.amethyst.commons.resources.blossom_syncing
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import kotlinx.coroutines.delay
/** How long the finished "Sync complete" banner lingers before it auto-dismisses. */
@@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.file_server
import com.vitorpamplona.amethyst.commons.resources.file_server_description
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import kotlinx.collections.immutable.toImmutableList
@@ -69,23 +69,23 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_relay_label
import com.vitorpamplona.amethyst.commons.resources.nests_servers_remove
import com.vitorpamplona.amethyst.commons.resources.nests_servers_title
import com.vitorpamplona.amethyst.commons.resources.nests_servers_use_defaults
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
@@ -61,19 +61,19 @@ import com.vitorpamplona.amethyst.commons.resources.payment_target_type
import com.vitorpamplona.amethyst.commons.resources.payment_targets
import com.vitorpamplona.amethyst.commons.resources.payment_targets_explainer
import com.vitorpamplona.amethyst.commons.resources.payment_targets_section_explainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
@Composable
@@ -38,8 +38,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.record_a_message_description
import com.vitorpamplona.amethyst.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.record_a_message
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@Composable
fun RecordVoiceButton(
@@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.recording_indicator_description
import com.vitorpamplona.amethyst.commons.resources.recording_indicator_with_time
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Animated expanding circles that pulse outward from the recording button
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.upload_file
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@@ -42,8 +42,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@@ -72,17 +72,17 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_hashing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_ready
import com.vitorpamplona.amethyst.commons.resources.uploading_state_server_processing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
import com.vitorpamplona.amethyst.commons.ui.note.CloseIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.no_camera_app_found
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.take_a_picture
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -48,9 +48,9 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_hashing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_ready
import com.vitorpamplona.amethyst.commons.resources.uploading_state_server_processing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun UploadProgressIndicator(
@@ -35,9 +35,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.voice_anonymize_description
import com.vitorpamplona.amethyst.commons.resources.voice_anonymize_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
@Composable
fun VoiceAnonymizationSection(
@@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.commons.resources.re_record
import com.vitorpamplona.amethyst.commons.resources.record_a_message
import com.vitorpamplona.amethyst.commons.resources.recording_indicator_description
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
@@ -34,7 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@Composable
fun VoicePresetSelector(
@@ -100,14 +100,14 @@ import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatTimeLeft
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -84,10 +84,10 @@ import com.vitorpamplona.amethyst.commons.resources.timeout
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.commons.resources.call_permission_denied_open_
import com.vitorpamplona.amethyst.commons.resources.call_permission_denied_title
import com.vitorpamplona.amethyst.commons.resources.call_permission_denied_video
import com.vitorpamplona.amethyst.commons.resources.call_permission_denied_voice
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
fun hasPermission(
context: Context,
@@ -71,9 +71,9 @@ import com.vitorpamplona.amethyst.commons.resources.call_incoming
import com.vitorpamplona.amethyst.commons.resources.call_incoming_video
import com.vitorpamplona.amethyst.commons.resources.call_incoming_voice
import com.vitorpamplona.amethyst.commons.resources.call_reject
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.call.session.CallSession
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -52,12 +52,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_calling
import com.vitorpamplona.amethyst.commons.resources.call_duration_hours_minutes_seconds
import com.vitorpamplona.amethyst.commons.resources.call_duration_minutes_seconds
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@@ -74,12 +74,12 @@ import com.vitorpamplona.amethyst.commons.resources.call_search_users
import com.vitorpamplona.amethyst.commons.resources.call_speaker
import com.vitorpamplona.amethyst.commons.resources.call_switch_camera
import com.vitorpamplona.amethyst.commons.resources.call_unmute
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.ui.call.session.CallSession
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import org.webrtc.VideoTrack
@@ -33,13 +33,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cast_searching_for_devices
import com.vitorpamplona.amethyst.commons.resources.cast_to_device_dialog_title
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
@@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.BuzzInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
@@ -51,17 +52,17 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRenderer
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview
import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextViewer as CommonsRichTextViewer
/**
@@ -59,17 +59,17 @@ import com.vitorpamplona.amethyst.commons.resources.cashu_redeem
import com.vitorpamplona.amethyst.commons.resources.sats
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
@@ -29,9 +29,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import kotlinx.coroutines.launch
@@ -30,9 +30,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import kotlinx.coroutines.launch
/**
@@ -29,9 +29,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import kotlinx.coroutines.launch
@@ -29,9 +29,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import kotlinx.coroutines.launch
@Composable
@@ -58,17 +58,17 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_invite_naddr_label
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.njumpLink
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -34,11 +34,11 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_withdraw_error
import com.vitorpamplona.amethyst.commons.resources.no_wallet_found
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,14 +44,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_join
import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_subtitle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -43,13 +43,13 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.show_more
import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
object ShowFullTextCache {
val cache = LruCache<String, Boolean>(10)
@@ -39,13 +39,13 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
/**
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
@@ -76,12 +76,12 @@ import com.vitorpamplona.amethyst.commons.resources.media_download_has_started_t
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size15dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@@ -48,14 +48,14 @@ import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.gif
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font10SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size40dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size6dp
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@Composable
@@ -44,10 +44,10 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@@ -26,11 +26,11 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
/**
* Strategy for rendering a note quoted inline in another note's text (a

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