Merge remote-tracking branch 'origin/main' into claude/kind-keller-itzy63

# Conflicts:
#	commonsUI/src/commonMain/composeResources/values/strings.xml
#	quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt
#	quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt
#	quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AuthenticatedDataTest.kt
#	quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupContextExtensionsSupportTest.kt
#	quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupCreateOptionsTest.kt
#	quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/KeyPackageLifetimeTest.kt
#	quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt
This commit is contained in:
Claude
2026-09-23 23:05:57 +00:00
152 changed files with 15872 additions and 1738 deletions
+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.
@@ -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),
@@ -48,6 +48,7 @@ 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.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
@@ -107,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))
@@ -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,
)
@@ -177,6 +177,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame
import com.vitorpamplona.amethyst.ui.note.types.RenderCitation
import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating
import com.vitorpamplona.amethyst.ui.note.types.RenderExternalReaction
@@ -235,6 +236,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootNappletEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoShard
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
@@ -281,6 +285,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1412,6 +1420,22 @@ private fun RenderNoteRow(
)
}
is SnoObjectEvent -> {
RenderSnoObject(baseNote, accountViewModel)
}
is SnoAvatarEvent -> {
RenderSnoAvatar(baseNote, accountViewModel)
}
is SnoShardEvent -> {
RenderSnoShard(baseNote, accountViewModel)
}
is CyberspaceBagEvent -> {
RenderCyberspaceBag(baseNote, accountViewModel)
}
is ChessGameEvent -> {
RenderChessGame(
baseNote,
@@ -20,29 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip32Labeling.ui.HashtagLabelDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,53 +36,8 @@ fun AddHashtagLabelDialog(
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
var hashtag by remember { mutableStateOf("") }
// Strip the leading '#', drop whitespace and lowercase so the stored label matches the
// hashtag-feed convention. A blank result disables the confirm button.
val sanitized =
hashtag
.trim()
.removePrefix("#")
.filterNot { it.isWhitespace() }
.lowercase()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.add_hashtag_label_title)) },
text = {
Column {
Text(stringRes(Res.string.add_hashtag_label_explainer))
OutlinedTextField(
value = hashtag,
onValueChange = { hashtag = it },
singleLine = true,
label = { Text(stringRes(Res.string.add_hashtag_label_field)) },
prefix = { Text("#") },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = sanitized.isNotEmpty(),
onClick = {
accountViewModel.labelWithHashtag(note, sanitized)
onDismiss()
},
) {
Text(stringRes(Res.string.add_hashtag_label_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
HashtagLabelDialog(
onConfirm = { hashtag -> accountViewModel.labelWithHashtag(note, hashtag) },
onDismiss = onDismiss,
)
}
@@ -20,24 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.commons.ui.note.FollowedHashtagLabel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -68,25 +61,7 @@ fun DisplayFollowingHashtagsInPost(
}
}
firstTag?.let {
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) }
}
}
}
@Composable
private fun DisplayTagList(
firstTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ClickableTextColor(
"#$firstTag",
linkColor = MaterialTheme.colorScheme.lessImportantLink,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) {
nav.nav(Route.Hashtag(firstTag.lowercase()))
firstTag?.let { tag ->
FollowedHashtagLabel(tag) { nav.nav(Route.Hashtag(tag.lowercase())) }
}
}
@@ -21,45 +21,23 @@
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pow_info_description
import com.vitorpamplona.amethyst.commons.resources.pow_settings_title
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.note.ProofOfWorkPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@Composable
@Preview
fun DisplayPoWPreview() {
ThemeComparisonColumn(
toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) },
)
}
/**
* Compact pill showing the proof of work a received note carries: a gear plus
* the difficulty in leading zero bits. Tapping it explains what the number
* means. Sits inline in note headers, so it stays at text height.
*/
/** Entry: the shared [ProofOfWorkPill], explaining the number in a toast when tapped. */
@Composable
fun DisplayPoW(
pow: Int,
accountViewModel: AccountViewModel,
) {
HeaderPill(
symbol = MaterialSymbols.Manufacturing,
text = pow.toString(),
contentDescription = stringRes(Res.string.pow_settings_title),
onClick = {
accountViewModel.toastManager.toast(
Res.string.pow_settings_title,
Res.string.pow_info_description,
pow.toString(),
)
},
)
ProofOfWorkPill(pow) {
accountViewModel.toastManager.toast(
Res.string.pow_settings_title,
Res.string.pow_info_description,
pow.toString(),
)
}
}
@@ -20,31 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
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.hashtag_exclusive
import com.vitorpamplona.amethyst.commons.nip73ExternalIds.ui.HashtagExternalIdCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
@@ -54,40 +33,7 @@ fun DisplayHashtagExternalId(
accountViewModel: AccountViewModel,
nav: INav,
) {
DisplayHashtagExternalId(externalId.topic) {
HashtagExternalIdCard(externalId.topic) {
nav.nav(Route.Hashtag(externalId.topic.lowercase()))
}
}
@Composable
fun DisplayHashtagExternalId(
topic: String,
linkInteractionListener: LinkInteractionListener,
) {
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = stringRes(id = Res.string.hashtag_exclusive),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("hashtag", null, linkInteractionListener),
) {
append(topic)
}
},
style =
LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
),
maxLines = 1,
)
}
}
@@ -20,30 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarRsvpButtons
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -90,88 +72,9 @@ fun CalendarRsvpRow(
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
}
CalendarRsvpButtons(currentStatus, onTap)
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
RsvpButtonLabel(label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
) {
RsvpButtonLabel(label)
}
}
}
private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
private fun RsvpButtonLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
/**
* Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable.
* The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923:<pubkey>:<dtag>`).
@@ -0,0 +1,340 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweep
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepQuote
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_box
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_cancel
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_damaged
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_destination
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped_many
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_empty
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_measuring
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_no_hint
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_not_found
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_opaque
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_out_of_reach
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_progress
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_search
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_title
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_hours
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_minutes
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unknown_kind
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unsigned
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagItem
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
/**
* Entry for a region bag (`CYBERSPACE_V2.md` §7.6, kind 33330) — content
* somebody hid at a place, addressed by a hash of a hash.
*
* The card's job is to say what the search costs and then not start it. §7.7's
* hint is the hider's difficulty knob, which makes the price part of the
* content: a box of 4,096 regions is a minute of a phone, and a box of 2^30 is
* a day of it, chosen by a stranger. So the gap is read off two tags while the
* row composes — arithmetic, no trees — and the card either offers a button or
* says the box is out of reach.
*
* **Every sweep is a tap, including a gap-0 hint.** §7.7 calls that one "a
* destination the seeker can compute or walk to directly, not a search", and it
* costs about as long as a frame. It still waits for the tap, because a reader
* who learns that some bags open themselves has learned an expectation a
* hostile bag can hide inside.
*
* What comes out renders through the cards that already exist: a `3330` shard
* through [SnoObjectCard], a `kind 1` as its text, anything else named and
* skipped. §7.6 decides the attribution and it is not the obvious one —
* *placement* belongs to the bag's author, *authorship* only to a signed item's
* own pubkey — so an unsigned item says so under it rather than borrowing
* either name.
*/
@Composable
fun RenderCyberspaceBag(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? CyberspaceBagEvent ?: return
val quote = remember(noteEvent) { BagSweep.quote(noteEvent) }
var state by remember(noteEvent) { mutableStateOf<BagSweepState?>(null) }
var job by remember(noteEvent) { mutableStateOf<Job?>(null) }
// Scrolling the row away is a cancel: this scope dies with the composition,
// and the sweep is a cold flow, so the work stops at the next candidate.
// Nothing here should outlive the card that asked for it.
val scope = rememberCoroutineScope()
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = stringResource(Res.string.cyberspace_bag_title),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
when (quote) {
is BagSweepQuote.Hidden -> stringResource(Res.string.cyberspace_bag_no_hint)
is BagSweepQuote.OutOfReach -> stringResource(Res.string.cyberspace_bag_out_of_reach, quote.gapBits)
is BagSweepQuote.Searchable ->
if (quote.destination) {
stringResource(Res.string.cyberspace_bag_destination)
} else {
stringResource(Res.string.cyberspace_bag_box, quote.candidates.toString())
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
if (quote is BagSweepQuote.Searchable) {
Sweeper(
state = state,
onStart = {
job =
scope.launch {
BagSweep
.sweep(noteEvent)
.flowOn(Dispatchers.Default)
.collect { state = it }
}
},
onCancel = {
job?.cancel()
job = null
state = null
},
)
}
when (val settled = state) {
is BagSweepState.Opened -> Contents(settled.contents, accountViewModel)
is BagSweepState.NotFound -> Footnote(stringResource(Res.string.cyberspace_bag_not_found))
is BagSweepState.OutOfReach -> Footnote(outOfReach(settled.estimateMillis))
else -> {}
}
}
}
/** The button, or the progress and the stop that replace it while a sweep runs. */
@Composable
private fun Sweeper(
state: BagSweepState?,
onStart: () -> Unit,
onCancel: () -> Unit,
) {
when (state) {
null -> TextButton(onClick = onStart) { Text(stringResource(Res.string.cyberspace_bag_search)) }
is BagSweepState.Measuring -> Progress(null, stringResource(Res.string.cyberspace_bag_measuring), onCancel)
is BagSweepState.Running ->
Progress(
fraction = if (state.candidates > 0) state.examined.toFloat() / state.candidates else null,
label = stringResource(Res.string.cyberspace_bag_progress, state.examined.toString(), state.candidates.toString()),
onCancel = onCancel,
)
// Settled. A cancel resets the state to null and the button comes back;
// an outcome does not, because sweeping the same box again derives the
// same keys and reaches the same answer.
else -> {}
}
}
@Composable
private fun Progress(
fraction: Float?,
label: String,
onCancel: () -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(top = 6.dp)) {
Column(Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(4.dp))
if (fraction == null) {
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
}
}
Spacer(Modifier.width(8.dp))
TextButton(onClick = onCancel) { Text(stringResource(Res.string.cyberspace_bag_cancel)) }
}
}
/**
* What the bag held (§7.6's two plaintext shapes).
*
* A null [contents] is the one case that is genuinely a broken bag: the
* `lookup_id` matched, and §7.2 makes that a hash of the very key we tried, so
* a GCM tag that then fails is damage rather than a wrong reader.
*/
@Composable
private fun Contents(
contents: CyberspaceBagContents?,
accountViewModel: AccountViewModel,
) {
when (contents) {
null -> Footnote(stringResource(Res.string.cyberspace_bag_damaged))
is CyberspaceBagContents.Opaque ->
Footnote(asText(contents.bytes) ?: stringResource(Res.string.cyberspace_bag_opaque, contents.bytes.size))
is CyberspaceBagContents.Items -> {
if (contents.items.isEmpty() && contents.dropped == 0) {
Footnote(stringResource(Res.string.cyberspace_bag_empty))
}
contents.items.forEach { item ->
Spacer(Modifier.height(6.dp))
Item(item, accountViewModel)
}
// §7.6: a forged item costs itself and nothing else, but it is
// still worth saying that something was thrown away.
if (contents.dropped > 0) {
Spacer(Modifier.height(6.dp))
Footnote(
if (contents.dropped == 1) {
stringResource(Res.string.cyberspace_bag_dropped, contents.dropped)
} else {
stringResource(Res.string.cyberspace_bag_dropped_many, contents.dropped)
},
)
}
}
}
}
/**
* One item, through whichever card already draws its kind.
*
* The `3330` case is what a bag is usually for: DECK-0003 §3.2's shard, whose
* payload is the same format a standalone object carries, which is why
* [RenderSnoShard] and this end at the same card. An item that came out of a
* bag is never in [com.vitorpamplona.amethyst.commons.model.cache.LocalCache] —
* it was ciphertext a moment ago — so there is no `Note` to hand the usual
* renderers, and these draw from the event directly.
*/
@Composable
private fun Item(
item: CyberspaceBagItem,
accountViewModel: AccountViewModel,
) {
when (val event = item.event) {
is SnoShardEvent -> {
val first = remember(event) { event.shard() }
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
when (val parsed = remember(event, palette) { if (palette == null) first else event.shard(palette) }) {
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
is SnoResult.Valid -> SnoObjectCard(payload = parsed.payload, eventId = event.id)
}
}
}
is TextNoteEvent -> Text(text = event.content, style = MaterialTheme.typography.bodyMedium)
// §7.6: "a reader that does not understand an item's kind skips it and
// renders the rest". Named rather than silent, so the count adds up.
else -> Footnote(stringResource(Res.string.cyberspace_bag_unknown_kind, event.kind))
}
// §7.6: an item without a `sig` is allowed, "its `pubkey` is then a claim,
// and readers MUST NOT present it as verified". Placement still belongs to
// the bag's author either way, which is why this only disclaims authorship.
if (!item.verified) Footnote(stringResource(Res.string.cyberspace_bag_unsigned))
}
/** A small grey line: this card's only other voice. */
@Composable
private fun Footnote(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
/** The refusal, in whichever unit does not read as a wall of minutes. */
@Composable
private fun outOfReach(estimateMillis: Long): String {
val minutes = estimateMillis / 60_000L
return if (minutes >= MINUTES_PER_HOUR) {
stringResource(Res.string.cyberspace_bag_too_slow_hours, (minutes / MINUTES_PER_HOUR).toInt())
} else {
stringResource(Res.string.cyberspace_bag_too_slow_minutes, minutes.toInt())
}
}
/**
* §7.6's other shape as text, or null when it is not UTF-8 — "a text note or a
* file", and a file should not be shown as a wall of replacement characters.
*/
private fun asText(bytes: ByteArray): String? {
val text = bytes.decodeToString()
return if (text.encodeToByteArray().contentEquals(bytes)) text else null
}
private const val MINUTES_PER_HOUR = 60L
@@ -41,6 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitCardButtonHeight
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitCardButtonPadding
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_pr_changes_retry
import com.vitorpamplona.amethyst.commons.resources.git_pr_loading_changes
@@ -106,8 +108,8 @@ fun GitPullRequestChanges(
ChangesState.Idle ->
FilledTonalButton(
onClick = { load() },
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
contentPadding = CompactButtonPadding,
modifier = Modifier.padding(top = 8.dp).then(GitCardButtonHeight),
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_pr_view_changes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -140,8 +142,8 @@ fun GitPullRequestChanges(
ChangesState.Failed ->
FilledTonalButton(
onClick = { load() },
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
contentPadding = CompactButtonPadding,
modifier = Modifier.padding(top = 8.dp).then(GitCardButtonHeight),
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_pr_changes_retry), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
@@ -20,33 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.model.GitStatusIndex
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_status_close
import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged
import com.vitorpamplona.amethyst.commons.resources.git_status_reopen
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitStatusButtons
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -60,10 +41,6 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
private enum class StatusTarget { OPEN, CLOSED, APPLIED }
/** Compact sizing shared by the small action buttons on git cards. */
internal val CompactButtonHeight = Modifier.height(32.dp)
internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
/**
* NIP-34 status controls for an issue, patch or pull request. Visible only to the
* people allowed to moderate the thread — the item's author and the repository
@@ -88,42 +65,13 @@ fun GitStatusActions(
val current = index?.get(note.idHex)
val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (closedOrApplied) {
FilledTonalButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) },
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
} else {
if (isPatchOrPr) {
FilledTonalButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) },
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
OutlinedButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
}
GitStatusButtons(
closedOrApplied = closedOrApplied,
isPatchOrPr = isPatchOrPr,
onReopen = { sendStatus(accountViewModel, note, StatusTarget.OPEN) },
onMarkMerged = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) },
onClose = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
)
}
private fun repositoryAddressOf(event: Event): Address? =
@@ -20,40 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.kinds
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.nipsOnNostr.ui.NipCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
@Composable
fun RenderNipContent(
@@ -63,133 +35,5 @@ fun RenderNipContent(
) {
val noteEvent = note.event as? NipTextEvent ?: return
NipNoteHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
private fun NipNoteHeader(
noteEvent: NipTextEvent,
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(noteEvent) { noteEvent.title() }
val kinds = remember(noteEvent) { noteEvent.kinds() }
Column(
modifier =
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
verticalArrangement = SpacedBy5dp,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
Text(
text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content },
style = MaterialTheme.typography.bodySmall,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (kinds.isNotEmpty()) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
horizontalArrangement = SpacedBy5dp,
verticalArrangement = SpacedBy5dp,
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(Res.string.kinds),
)
kinds.forEach {
NoPaddingSuggestionChip(
label = it.toString(),
)
}
}
}
}
}
@Preview
@Composable
fun NipNoteHeaderPreview() {
val event =
NipTextEvent(
id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0",
pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
createdAt = 1767978398,
content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.",
sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21",
tags =
arrayOf(
arrayOf("d", "trusted-translations"),
arrayOf("title", "Trusted Translations"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("client", "nostrhub.io"),
),
)
LocalCache.justConsume(event, null, true)
val note = LocalCache.getOrCreateNote(event.id)
ThemeComparisonColumn(
toPreview = {
NipNoteHeader(
noteEvent = event,
note = note,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look
color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color
modifier = modifier,
) {
Text(
text = label,
// Apply desired internal padding to the Text itself
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
NipCard(noteEvent)
}
@@ -20,30 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_public_bookmarks
import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.note.BookmarkToggleButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,10 +42,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* Bookmarked state is read from the public bookmark id/address **sets** (not `List<Note>`
* containment) so it reflects reliably for addressable notes and updates the moment the list
* changes — the icon flipping filled/outline is the feedback, no toast needed.
*
* Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton],
* whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize]
* defaults to a single title line so it aligns when placed next to a show/episode title.
*/
@Composable
fun PodcastBookmarkButton(
@@ -82,30 +64,16 @@ fun PodcastBookmarkButton(
}
}
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
BookmarkToggleButton(
isBookmarked = isBookmarked,
onToggle = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
modifier = modifier,
iconSize = iconSize,
)
}
@@ -20,42 +20,22 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_join_request
import com.vitorpamplona.amethyst.commons.resources.relay_leave_request
import com.vitorpamplona.amethyst.commons.resources.relay_member_added
import com.vitorpamplona.amethyst.commons.resources.relay_member_removed
import com.vitorpamplona.amethyst.commons.resources.relay_members_added
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_removed
import com.vitorpamplona.amethyst.commons.resources.relay_membership_list
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayAddMemberCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayJoinRequestCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayLeaveRequestCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayMembershipListCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRemoveMemberCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
// Dispatcher entries for the NIP-43 relay-membership kinds. The cards draw only from the event,
// so the entries just decode the note; the rendering is in commonsUI nip43RelayMembers/ui.
@Composable
fun RenderRelayMembershipList(
baseNote: Note,
@@ -63,15 +43,7 @@ fun RenderRelayMembershipList(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayMembershipListEvent ?: return
val memberCount = remember(noteEvent) { noteEvent.members().size }
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = stringRes(Res.string.relay_membership_list),
subtitle = stringRes(Res.string.relay_members_count, memberCount),
nav = nav,
relayPubKey = noteEvent.pubKey,
)
RelayMembershipListCard(noteEvent)
}
@Composable
@@ -81,21 +53,7 @@ fun RenderRelayAddMember(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayAddMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_added)
} else {
stringRes(Res.string.relay_members_added, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
RelayAddMemberCard(noteEvent)
}
@Composable
@@ -105,21 +63,7 @@ fun RenderRelayRemoveMember(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_removed)
} else {
stringRes(Res.string.relay_members_removed, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
RelayRemoveMemberCard(noteEvent)
}
@Composable
@@ -127,129 +71,11 @@ fun RenderRelayJoinRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = stringRes(Res.string.relay_join_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
) = RelayJoinRequestCard()
@Composable
fun RenderRelayLeaveRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = stringRes(Res.string.relay_leave_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
@Composable
private fun RelayMemberEventCard(
icon: MaterialSymbol,
title: String,
subtitle: String?,
nav: INav? = null,
relayPubKey: String? = null,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Preview
@Composable
private fun RelayMembershipListCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = "Relay membership list",
subtitle = "42 members",
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Member added to relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayRemoveMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = "Member removed from relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayJoinRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Relay join request",
subtitle = null,
)
}
}
@Preview
@Composable
private fun RelayLeaveRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = "Relay leave request",
subtitle = null,
)
}
}
) = RelayLeaveRequestCard()
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarDefaultCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarUnpaidCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
private val VIEWER_HEIGHT = 360.dp
/**
* Entry for a cyberspace avatar (kind 11333).
*
* §8.10 gates the drawing rather than the parsing: an avatar that has not paid
* for its reach and its detail, or whose content cannot be read, MUST NOT be
* drawn.
*
* Empty content is the default avatar: it owes no work, and it is what every
* identity is until it adopts a shape. Drawing nothing for it is correct and
* unhelpful — the note disappears out of the feed, which reads as a fault — so
* it gets the wireframe icosahedron the reference puts in its place.
*/
@Composable
fun RenderSnoAvatar(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SnoAvatarEvent ?: return
if (noteEvent.isDefaultAvatar()) {
SnoAvatarDefaultCard(noteEvent.id)
return
}
// One parse: pricing an avatar reads its payload, and payment() hands that
// back, so nothing here parses the same content twice on the way to a frame.
// The price cannot change with the palette — §8.10 charges for reach and
// detail and never for colour — but whether the payload reads at all can,
// so the verdict is taken again with whichever palette arrives.
val first = remember(noteEvent) { noteEvent.payment() }
WithSnoPalette(first.payload?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
val payment = remember(noteEvent, palette) { if (palette == null) first else noteEvent.payment(palette) }
val shape: SnoPayload? = if (payment.ok) payment.payload else null
if (shape == null) {
SnoAvatarUnpaidCard()
} else {
var turning by remember(noteEvent) { mutableStateOf(false) }
SnoAvatarCard(
payload = shape,
eventId = noteEvent.id,
name = noteEvent.nameTag(),
onClick = { turning = true },
)
if (turning) {
Dialog(onDismissRequest = { turning = false }) {
SnoObjectViewer(
payload = shape,
eventId = noteEvent.id,
contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null },
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
)
}
}
}
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
private val VIEWER_HEIGHT = 360.dp
/**
* Entry for a Simple Nostr Object (DECK-0003 kind 33331).
*
* The geometry travels in the event's content, so the object itself is drawn
* from what the note already carries. The one thing that can be elsewhere is
* its palette: §1.3a lets `colors` index a palette another event holds, and
* [WithSnoPalette] fetches that one. Until it arrives the object is drawn
* against the built-in, which is what §1.3b says an unresolved reference means.
*
* A payload that fails §1.9 is not drawn — the deck requires that — and the
* rule it broke is shown instead of nothing.
*/
@Composable
fun RenderSnoObject(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SnoObjectEvent ?: return
// Parsed once against the built-in to learn which palette it wants, then
// again with that palette once it is in hand. The second parse can fail
// where the first did not — an index of 238 is fine against 256 entries and
// out of range against a five-colour moment — and that is the object being
// wrong rather than the palette.
val first = remember(noteEvent) { noteEvent.sno() }
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.sno(palette) }
when (parsed) {
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
is SnoResult.Valid -> {
var turning by remember(noteEvent) { mutableStateOf(false) }
SnoObjectCard(
payload = parsed.payload,
eventId = noteEvent.id,
onClick = { turning = true },
)
if (turning) {
Dialog(onDismissRequest = { turning = false }) {
SnoObjectViewer(
payload = parsed.payload,
eventId = noteEvent.id,
contentDescription = parsed.payload.name.ifBlank { null },
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
)
}
}
}
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPalette
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteEventReader
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
/**
* The 256 colours an object's palette indices name, fetched when the object
* names them in another event (DECK-0003 §1.3a).
*
* `colors` is an index into a palette and the payload says which one: absent
* means the built-in, a list means the object carries its own, and an `nevent`
* or `naddr` means somebody else's event holds it. Until that event is in hand,
* an object that names one is drawn against the built-in — §1.3b is explicit
* that a reference which has not resolved "counts as a failed fetch, which
* means the built-in", never a refusal to draw. So this hands back null first
* and the palette after, and the object appears immediately and repaints once.
*
* **Pinned to the event named, and never to a newer one.** §1.3a: "A reader
* MUST NOT follow the `e` chain forward to a newer version, and MUST render the
* object with the event the object names." That is why an `nevent` is loaded by
* its own id. An `naddr` is accepted too, for a palette somebody publishes as an
* addressable event of their own, and there the address *is* what was named.
*
* Fetching 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 the built-in 256 is not slightly off — it is a
* different object, in colours its author never chose. It can also stop being
* drawable: an index of 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. Both are the format working as written.
*/
@Composable
fun WithSnoPalette(
ref: SnoPaletteRef,
accountViewModel: AccountViewModel,
content: @Composable (SnoPalette?) -> Unit,
) {
if (ref !is SnoPaletteRef.Event) {
// Inline and built-in are already resolved inside the payload; there is
// nothing to wait for and nothing to fetch.
content(null)
return
}
when (val entity = remember(ref.bech32) { Nip19Parser.uriToRoute(ref.bech32)?.entity }) {
is NEvent -> LoadPaletteNote(entity.hex, accountViewModel, content)
is NNote -> LoadPaletteNote(entity.hex, accountViewModel, content)
is NAddress ->
LoadAddressableNote(entity.address(), accountViewModel) { note ->
content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } })
}
// A reference the parser accepted as well-formed but that names nothing
// this client can look up. The built-in, as an unresolved one always is.
else -> content(null)
}
}
@Composable
private fun LoadPaletteNote(
hex: String,
accountViewModel: AccountViewModel,
content: @Composable (SnoPalette?) -> Unit,
) {
LoadNote(hex, accountViewModel) { note ->
// The kind is deliberately not checked: §1.3b says this format "does
// not define a palette kind and does not want one", and a reader that
// accepts the shape reads whatever convention wins. An event that is
// not one reads as null, which is the built-in.
content(remember(note?.event) { note?.event?.let { SnoPaletteEventReader.read(it) } })
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.sno_shard_dataspace
import com.vitorpamplona.amethyst.commons.resources.sno_shard_ideaspace
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate
import com.vitorpamplona.quartz.cyberspace.CyberspacePlane
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import org.jetbrains.compose.resources.stringResource
private val VIEWER_HEIGHT = 360.dp
/**
* Entry for a shard: an object someone hid at a place (DECK-0003 §3.2, kind
* 3330).
*
* The same payload and the same rules as a standalone object — only the
* container differs — so this renders through the same card.
*
* This is the shard that arrived **on its own**: quoted in a note, or fetched
* by id. One still inside its `kind 33330` bag is ciphertext until that
* region's key is derived, and deriving it belongs to [RenderCyberspaceBag],
* which sweeps the box the bag's hint names (§7.7) and renders what comes out
* through this same card. So a sealed shard reaching here is one nobody has
* opened, not one nobody can.
*/
@Composable
fun RenderSnoShard(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SnoShardEvent ?: return
// A shard whose geometry is still inside its bag is not a broken payload
// and §7.6 says so outright, so there is nothing to report and nothing to
// draw. Both 3330s reachable on a relay today are this.
if (noteEvent.isSealed()) return
val first = remember(noteEvent) { noteEvent.shard() }
val place = placeOf(noteEvent)
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.shard(palette) }
when (parsed) {
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
is SnoResult.Valid -> {
var turning by remember(noteEvent) { mutableStateOf(false) }
SnoObjectCard(
payload = parsed.payload,
eventId = noteEvent.id,
onClick = { turning = true },
footnote = place,
)
if (turning) {
Dialog(onDismissRequest = { turning = false }) {
SnoObjectViewer(
payload = parsed.payload,
eventId = noteEvent.id,
contentDescription = parsed.payload.name.ifBlank { null },
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
)
}
}
}
}
}
}
/**
* Where this shard says it is, in the little a client with no world can say.
*
* §7.6 lets an item carry a `C` tag holding its exact coordinate, "which lets a
* client render it at a point rather than somewhere in the region". Amethyst has
* no region and no point to render it at, but the tag still carries one thing a
* reader understands on its own: the plane, which says whether the shard was
* hidden at a place on Earth or at one with no physical counterpart (§2.4). The
* coordinate itself is abbreviated beside it — enough to tell two shards apart,
* and the whole of it is in the event for a client that can go there. See
* [CyberspaceCoordinate] for why the axes are not decoded.
*
* Null when there is no `C` tag, which §7.6 allows: such an item "is located no
* more precisely than the region".
*/
@Composable
private fun placeOf(noteEvent: SnoShardEvent): String? {
val coordinate = noteEvent.coordinate() ?: return null
val plane = CyberspaceCoordinate.planeOf(coordinate) ?: return null
val short = coordinate.take(SHORT_COORDINATE) + "\u2026" + coordinate.takeLast(SHORT_COORDINATE)
return when (plane) {
CyberspacePlane.DATASPACE -> stringResource(Res.string.sno_shard_dataspace, short)
CyberspacePlane.IDEASPACE -> stringResource(Res.string.sno_shard_ideaspace, short)
}
}
/** How much of a 32-byte coordinate to show at each end. */
private const val SHORT_COORDINATE = 6
@@ -20,42 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder
import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -67,103 +35,27 @@ fun NewChatroomSubjectDialog(
accountViewModel: AccountViewModel,
room: ChatroomKey,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName =
remember {
mutableStateOf(
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: "",
)
}
val message = remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
ChatroomSubjectDialog(
initialSubject =
remember(room) {
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: ""
},
onPost = { subject, message ->
accountViewModel.launchSigner {
val template =
ChatMessageEvent.build(
message,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
subject.ifBlank { null }?.let { changeSubject(it) }
}
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
accountViewModel.launchSigner {
val template =
ChatMessageEvent.build(
message.value,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
groupName.value.ifBlank { null }?.let { changeSubject(it) }
}
accountViewModel.account.sendNip17PrivateMessage(template)
}
onClose()
},
true,
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
accountViewModel.account.sendNip17PrivateMessage(template)
}
}
}
},
onClose = onClose,
)
}
@@ -35,6 +35,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton
import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
@@ -47,8 +49,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton
@Composable
fun ShortEphemeralChatChannelHeader(
@@ -118,20 +118,19 @@ fun ShortEphemeralChatActionOptions(
accountViewModel: AccountViewModel,
nav: INav,
) {
JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel)
}
@Composable
fun JoinEphemeralChatButtonIfNotAlreadyJoined(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
JoinChannelButton { accountViewModel.follow(channel) }
} else {
LeaveChatButton(channel, accountViewModel, nav)
LeaveChannelButton { accountViewModel.unfollow(channel) }
}
}
@@ -41,7 +41,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.nip28PublicChat.ui.EditChannelButton
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.channel_image
import com.vitorpamplona.amethyst.commons.resources.created_at
@@ -64,8 +67,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.OpenChatButton
@@ -197,7 +198,7 @@ fun LongChannelActionOptions(
EditButtonIfIamCreator(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel)
}
@Composable
@@ -212,7 +213,7 @@ fun EditButtonIfIamCreator(
}
if (isMe) {
EditButton(channel, accountViewModel, nav)
EditChannelButton { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) }
}
}
@@ -220,11 +221,10 @@ fun EditButtonIfIamCreator(
fun LeaveButtonIfFollowing(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (isFollowing) {
LeaveChatButton(channel, accountViewModel, nav)
LeaveChannelButton { accountViewModel.unfollow(channel) }
}
}
@@ -37,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.JoinChatButton
@Composable
fun ShortPublicChatChannelHeader(
@@ -135,18 +135,17 @@ fun ShortChannelActionOptions(
}
}
JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel)
}
@Composable
fun JoinChatButtonIfNotAlreadyJoined(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
JoinChannelButton { accountViewModel.follow(channel) }
}
}
@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LeaveChatButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.unfollow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
}
}
@@ -20,23 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.chats.ui.MuteChannelButton
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_notifications
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -47,23 +35,5 @@ fun MuteChatButton(
val mutedChats by accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
val isMuted = channel.idHex in mutedChats
val label =
stringRes(
if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications,
)
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { accountViewModel.toggleMutedPublicChat(channel.idHex) },
contentPadding = ZeroPadding,
) {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = label,
modifier = Size20Modifier,
)
}
MuteChannelButton(isMuted) { accountViewModel.toggleMutedPublicChat(channel.idHex) }
}
@@ -20,25 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzPinDropdownItem
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_messages
import com.vitorpamplona.amethyst.commons.resources.buzz_pin
import com.vitorpamplona.amethyst.commons.resources.buzz_unpin
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupMessagesDropdownItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -55,21 +43,11 @@ fun BuzzPinDropdownItem(
) {
val stars = accountViewModel.account.buzzChannelStars
val starred by stars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isStarred) Res.string.buzz_unpin else Res.string.buzz_pin)) },
onClick = {
closeMenu()
stars.toggle(groupId.id)
},
BuzzPinDropdownItem(
isPinned = groupId.id in starred,
closeMenu = closeMenu,
onToggle = { stars.toggle(groupId.id) },
)
}
@@ -88,18 +66,11 @@ fun RelayGroupMessagesDropdownItem(
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) },
onClick = {
closeMenu()
RelayGroupMessagesDropdownItem(
onMyList = onMyList,
closeMenu = closeMenu,
onToggle = {
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
@@ -20,28 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupInviteCodeDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,37 +37,11 @@ fun JoinRelayGroupDialog(
onJoined: () -> Unit,
onDismiss: () -> Unit,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.relay_group_join_title)) },
text = {
Column {
Text(stringRes(Res.string.relay_group_join_code_hint))
OutlinedTextField(
value = code,
onValueChange = { code = it },
singleLine = true,
label = { Text(stringRes(Res.string.relay_group_join_code_label)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(
enabled = code.isNotBlank(),
onClick = {
accountViewModel.joinRelayGroup(channel, code.trim())
onJoined()
onDismiss()
},
) {
Text(stringRes(Res.string.relay_group_join_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
RelayGroupInviteCodeDialog(
onJoin = { code ->
accountViewModel.joinRelayGroup(channel, code)
onJoined()
},
onDismiss = onDismiss,
)
}
@@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryScrollableTabRow
@@ -52,10 +50,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.DiscoverTab
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.nip23LongContent.ui.NewArticleButton
import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.discover_chat
import com.vitorpamplona.amethyst.commons.resources.discover_community_v2
@@ -64,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows
import com.vitorpamplona.amethyst.commons.resources.discover_live_v2
import com.vitorpamplona.amethyst.commons.resources.discover_marketplace
import com.vitorpamplona.amethyst.commons.resources.discover_reads
import com.vitorpamplona.amethyst.commons.resources.new_long_form_post
import com.vitorpamplona.amethyst.commons.resources.new_product
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
@@ -79,8 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia
@@ -273,11 +267,11 @@ private fun DiscoverPages(
if (currentPage >= 0 && currentPage < feedTabs.size) {
FabBottomBarPadded(nav) {
if (feedTabs[currentPage].resource == Res.string.discover_marketplace) {
NewProductButton(accountViewModel, nav)
NewProductButton(nav)
}
if (feedTabs[currentPage].resource == Res.string.discover_reads) {
NewLongFormMarkdownButton(accountViewModel, nav)
NewArticleButton(nav)
}
}
}
@@ -372,50 +366,6 @@ private fun RenderDiscoverFeed(
}
}
@Composable
fun NewProductButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewProduct())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(id = Res.string.new_product),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@Composable
fun NewLongFormMarkdownButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewLongFormPost())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(id = Res.string.new_long_form_post),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@Composable
private fun RenderDiscoverFeed(
feedContentState: FeedContentState,
@@ -22,39 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.nipCCGeocaching.ui.rememberFoundCacheIds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
/**
* The addresses the signed-in user has already logged a find on, as `37516:pubkey:d` strings.
*
* Derived from the Finds feed rather than scanning the cache directly, so it tracks a new log
* the moment the feed does — which is what makes a hunt's progress bar move as you walk it.
*/
/** The addresses the signed-in user has already logged a find on, from their Finds feed. */
@Composable
fun rememberMyFoundCacheIds(accountViewModel: AccountViewModel): Set<String> {
val finds by accountViewModel.feedStates.geocacheFindsFeed.feedContent
.collectAsStateWithLifecycle()
return when (val state = finds) {
is FeedState.Loaded -> rememberFoundCacheIds(state)
else -> emptySet()
}
}
/**
* Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated
* through its inner flow, so reading `.value` here would freeze the set at whatever the feed held
* when the hunt screen first composed — the progress bar would never move.
*/
@Composable
private fun rememberFoundCacheIds(state: FeedState.Loaded): Set<String> {
val loaded by state.feed.collectAsStateWithLifecycle()
return remember(loaded) {
loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() }
}
return rememberFoundCacheIds(finds)
}
@@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.music.ui.NewMusicTrackButton
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
@@ -87,7 +88,7 @@ fun MusicTracksScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewMusicTrackButton(accountViewModel, nav)
NewMusicTrackButton(nav)
}
},
accountViewModel = accountViewModel,
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
@@ -75,7 +76,7 @@ fun ProductsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewProductButton(accountViewModel, nav)
NewProductButton(nav)
}
},
accountViewModel = accountViewModel,
@@ -67,6 +67,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.profile.ui.RelaysTabHeader
import com.vitorpamplona.amethyst.commons.relayClient.profile.UserProfileFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.gallery
@@ -109,7 +110,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports
@@ -697,7 +697,7 @@ private fun CreateAndRenderTabs(
ProfileTab.Bookmarks -> BookmarkTabHeader(baseUser, accountViewModel)
ProfileTab.FollowedTags -> FollowedTagsTabHeader(baseUser, accountViewModel)
ProfileTab.Reports -> ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel)
ProfileTab.Relays -> RelaysTabHeader(baseUser, accountViewModel)
ProfileTab.Relays -> RelaysTabHeader()
}
},
)
@@ -20,210 +20,23 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.report
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.block_only
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user
import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal
import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation
import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public
import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam
import com.vitorpamplona.amethyst.commons.resources.report_dialog_title
import com.vitorpamplona.amethyst.commons.resources.report_malware
import com.vitorpamplona.amethyst.commons.resources.violence
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.commons.nip56Reports.ui.ReportDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportNoteDialog(
note: Note,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val reportTypes =
listOf(
Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)),
Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)),
Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)),
Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)),
Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)),
Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)),
Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)),
)
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableIntStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringRes(id = Res.string.report_dialog_title)) },
navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } },
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround,
) {
SpacerH16()
SectionHeader(text = stringRes(id = Res.string.block_only))
SpacerH16()
Text(
text = stringRes(Res.string.report_dialog_blocking_a_user),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_block_hide_user_btn),
icon = MaterialSymbols.Block,
onClick = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
)
SpacerH16()
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness)
SpacerH16()
SectionHeader(text = stringRes(Res.string.report_dialog_report_btn))
SpacerH16()
Text(stringRes(Res.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringRes(Res.string.report_dialog_select_reason_label),
placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = { selectedReason = it },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = {
Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder))
},
label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_post_report_btn),
icon = MaterialSymbols.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
accountViewModel.report(
note,
reportTypes[selectedReason].first,
additionalReason,
)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
)
}
}
}
}
@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) =
Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp,
ReportDialog(
onBlock = { note.author?.let { accountViewModel.hide(it) } },
onReport = { reason, additionalReason ->
accountViewModel.report(note, reason, additionalReason)
note.author?.let { accountViewModel.hide(it) }
},
onDismiss = onDismiss,
)
@Composable
private fun ActionButton(
text: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(containerColor = LightRedColor),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}
@@ -205,6 +205,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
import com.vitorpamplona.amethyst.ui.note.types.RenderCitation
import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
@@ -250,6 +251,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayReview
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoShard
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
@@ -269,6 +273,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -1069,6 +1077,14 @@ private fun FullBleedNoteCompose(
RenderBirdex(baseNote)
} else if (noteEvent is BirdDetectionEvent) {
RenderBirdDetection(baseNote)
} else if (noteEvent is SnoObjectEvent) {
RenderSnoObject(baseNote, accountViewModel)
} else if (noteEvent is SnoAvatarEvent) {
RenderSnoAvatar(baseNote, accountViewModel)
} else if (noteEvent is SnoShardEvent) {
RenderSnoShard(baseNote, accountViewModel)
} else if (noteEvent is CyberspaceBagEvent) {
RenderCyberspaceBag(baseNote, accountViewModel)
} else if (noteEvent is Ps1SaveEvent) {
RenderPs1Save(baseNote)
} else if (noteEvent is GeocacheListingEvent) {
@@ -544,4 +544,73 @@
<item quantity="many">Usunięto %1$d prywatnych transmiterów</item>
<item quantity="other">Usunięto %1$d prywatne transmitery</item>
</plurals>
<string name="backup_conflict_keep_new">Użyj nowej wersji</string>
<string name="backup_conflict_relay_read_only">tylko do odczytu</string>
<string name="backup_conflict_relay_write_only">tylko do zapisu</string>
<string name="backup_conflict_relay_read_write">odczyt i zapis</string>
<string name="backup_type_profile">profil</string>
<string name="backup_type_profile_explainer">Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy.</string>
<string name="backup_type_follow_list">lista obserwowanych</string>
<string name="backup_type_follow_list_explainer">Określa, czyje posty pojawiają się w Twoim kanale głównym.</string>
<string name="backup_type_mute_list">lista zablokowanych</string>
<string name="backup_type_mute_list_explainer">Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach.</string>
<string name="backup_type_outbox_inbox_relays">lista publicznych transmiterów</string>
<string name="backup_type_outbox_inbox_relays_explainer">Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki.</string>
<string name="backup_type_dm_relays">Lista transmiterów DM</string>
<string name="backup_type_dm_relays_explainer">Gdzie inni dostarczają Twoje prywatne wiadomości.</string>
<string name="backup_type_key_package_relays">transmitery kluczy czatu grupowego</string>
<string name="backup_type_key_package_relays_explainer">Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych.</string>
<string name="backup_type_search_relays">lista transmiterów wyszukiwania</string>
<string name="backup_type_search_relays_explainer">Transmitery używane podczas wyszukiwania.</string>
<string name="backup_type_indexer_relays">lista transmiterów indeksujących</string>
<string name="backup_type_indexer_relays_explainer">Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów.</string>
<string name="backup_type_relay_feeds">ulubione kanały transmitera</string>
<string name="backup_type_relay_feeds_explainer">Transmitery, które przeglądasz jako kanały informacyjne.</string>
<string name="backup_type_blocked_relays">lista zablokowanych przekaźników</string>
<string name="backup_type_geohashes">obserwowane lokalizacje</string>
<string name="backup_type_concord_communities_explainer">Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia.</string>
<string name="backup_type_trust_providers">lista zaufanych dostawców</string>
<string name="backup_type_trust_providers_explainer">Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu.</string>
<string name="backup_type_cashu_wallet">Portfel Cashu</string>
<string name="backup_type_cashu_wallet_explainer">Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia.</string>
<string name="backup_type_nutzap_info">ustawienia odbioru nutzap</string>
<string name="backup_type_nutzap_info_explainer">Minty i klucz, które inni używają do wysyłania ci „nutzapów”.</string>
<string name="backup_type_payment_targets">adresy płatności</string>
<string name="backup_type_payment_targets_explainer">Sposoby, w jakie inni mogą Ci zapłacić.</string>
<string name="backup_type_bolt12_offers">Oferta BOLT12</string>
<string name="backup_type_bolt12_offers_explainer">Ta możliwość pozwala innym płacić Ci za pomocą Lightning.</string>
<string name="backup_type_other">lista</string>
<string name="backup_type_other_explainer">Lista zapisana na tym urządzeniu.</string>
<string name="backup_entry_profile_field">Pola profilu</string>
<string name="backup_entry_person">Osoby</string>
<string name="backup_entry_hashtag">Hashtagi</string>
<string name="backup_entry_word">Słowa</string>
<string name="backup_entry_thread">Wątki</string>
<string name="backup_entry_public_chat">Czaty publiczne</string>
<string name="backup_entry_community">Społeczności</string>
<string name="backup_entry_algo_feed">Kanały</string>
<string name="backup_entry_location">Lokalizacje</string>
<string name="backup_entry_relay">Transmitery</string>
<string name="backup_entry_relay_group">Grupy</string>
<string name="backup_entry_chat_room">Czat pokoje</string>
<string name="backup_entry_trust_provider">Zaufani dostawcy</string>
<string name="backup_entry_mint">Minty</string>
<string name="backup_entry_nutzap_key">Klucze Nutzap</string>
<string name="backup_entry_payment_target">Adresy płatności</string>
<string name="backup_entry_offer">Oferty</string>
<string name="backup_entry_identity_claim">Powiązane tożsamości</string>
<string name="backup_entry_other">Inne wpisy</string>
<string name="backup_profile_field_name">Nazwa użytkownika</string>
<string name="backup_profile_field_display_name">Nazwa wyświetlana</string>
<string name="backup_profile_field_about">O profilu</string>
<string name="backup_profile_field_picture">Zdjęcie</string>
<string name="backup_profile_field_banner">Baner</string>
<string name="backup_profile_field_website">Strona www</string>
<string name="backup_profile_field_nip05">Adres Nostr</string>
<string name="backup_profile_field_lud16">Lightning Adres</string>
<string name="backup_profile_field_lud06">LNURL</string>
<string name="backup_profile_field_pronouns">Zaimki</string>
<string name="backup_profile_field_birthday">Data urodzin</string>
<string name="backup_profile_field_clink_offer">Oferta CLINK</string>
<string name="backup_profile_field_bot">Flaga bota</string>
</resources>
+1
View File
@@ -229,6 +229,7 @@ Army-knife verbs that operate purely on their arguments. They never touch
| `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. |
| `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. |
| `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. |
| `amy sno <parse\|work\|verify>` | Simple Nostr Objects (DECK-0003): validate a payload against §1.9, price the proof of work an avatar owes for it (§8.10), or check whether a kind:11333 avatar event has paid. Local and accountless; every rule lives in quartz. |
| `amy namecoin resolve IDENT [--server HOST:PORT[:tcp][,…]] [--timeout SECS]` | Resolve a Namecoin identifier (`.bit`, `d/`, `id/`, `alice@example.bit`) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS (`:tcp` for plaintext), no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. |
| `amy namecoin servers` | Print the default ElectrumX server list (host, port, TLS flag). |
| `amy relay info URL` | Fetch and print a relay's NIP-11 information document. |
+1
View File
@@ -76,6 +76,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-78 app-specific data (settings sync) | 🆕 | |
| Long-form (NIP-23) publish / read | 🆕 | |
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
| Simple Nostr Objects (DECK-0003) | ✅ read-only | `SnoCommands` — `sno parse` (§1.9 verdict + the rule a refusal broke), `sno work` (the §8.10 proof of work an avatar owes), `sno verify` (whether a kind:11333 avatar has paid). Thin over quartz `cyberspace/deck0003Sno/`. Conformance harness at `cli/tests/sno/` diffs all three against the cyberspace project's own `sno-reference.py` and `cyberspace-cli`. Publishing/authoring 🆕. |
| Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `sync`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Reads project the local store; `cashu sync` (or `--sync`) is what fills it, paging every relay to exhaustion so a cap can't truncate the proof set. Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-47 Wallet Connect | 🆕 | |
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
import com.vitorpamplona.amethyst.cli.commands.CordnCommands
import com.vitorpamplona.amethyst.cli.commands.CountCommand
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
import com.vitorpamplona.amethyst.cli.commands.CyberspaceCommands
import com.vitorpamplona.amethyst.cli.commands.DebitCommands
import com.vitorpamplona.amethyst.cli.commands.DecodeCommand
import com.vitorpamplona.amethyst.cli.commands.DecryptCommand
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands
import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
import com.vitorpamplona.amethyst.cli.commands.SnoCommands
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
import com.vitorpamplona.amethyst.cli.commands.StreamCommands
@@ -243,6 +245,8 @@ private suspend fun dispatch(argv: Array<String>): Int {
"filter" -> return FilterCommand.run(tail)
"nip" -> return NipCommand.run(tail)
"kind" -> return KindCommand.run(tail)
"sno" -> return SnoCommands.dispatch(tail)
"cyberspace" -> return CyberspaceCommands.dispatch(tail)
"namecoin" -> return NamecoinCommand.dispatch(tail)
}
@@ -514,6 +518,9 @@ private fun printUsage() {
| nip N show a NIP (repo first, then a Nostr wiki/long-form fallback)
| nip list fetch the NIP index (README) from the repo
| kind N|NAME look up an event kind's label + NIP (number, or search by name)
| sno <parse|work|verify> Simple Nostr Objects (DECK-0003): validate, price, verify an avatar
| cyberspace <coord|region> Cyberspace places (§2), region keys (§7.2), hint boxes
| <hint|open|sweep> (§7.7), and opening or sweeping for a bag (§7.6)
| namecoin resolve IDENT resolve a Namecoin identifier (.bit, d/, id/, alice@x.bit)
| [--server URL[,URL]] to a Nostr pubkey + relays via the Namecoin blockchain
| [--timeout SECS] (no account, talks to ElectrumX over TLS)
@@ -0,0 +1,459 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.cyberspace.CantorTree
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate
import com.vitorpamplona.quartz.cyberspace.CyberspaceHint
import com.vitorpamplona.quartz.cyberspace.CyberspacePlane
import com.vitorpamplona.quartz.cyberspace.RegionKey
import com.vitorpamplona.quartz.cyberspace.RegionKeyMaterial
import com.vitorpamplona.quartz.cyberspace.RegionSweep
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlin.io.encoding.Base64
/**
* `amy cyberspace …` — places and the keys they derive, local and accountless.
*
* Thin assembly only: §2's interleave, §4's Cantor trees and §7.2's derivation
* all live in quartz's `cyberspace/`. These verbs exist so the Kotlin
* implementation can be diffed against `cyberspace-cli` without a device, a
* relay or an account — the same arrangement `amy sno` has, and for the same
* reason. A region key is a consensus value: if ours differs from theirs by a
* byte, a bag they hid is one we cannot open, and that is a difference worth
* catching in a shell script rather than in a feed.
*/
object CyberspaceCommands {
val USAGE: String =
"""
|amy cyberspace — places and region keys (local, accountless)
|
| cyberspace coord COORD_HEX decode a coordinate: axes, plane, sectors (§2.2)
| cyberspace region COORD_HEX --height H the region key at that height (§7.2)
| cyberspace hint [EVENT|-] read a bag's hint and price its sweep (§7.7)
| cyberspace open [EVENT|-] --key HEX open a bag with a region key (§7.6)
| cyberspace sweep [EVENT|-] sweep a bag's hint box for its key (§7.7)
|
|COORD_HEX is 32 bytes of lowercase hex, as a `C` or `hint` tag carries it.
|
| --height H the aligned subtree height, 0..20 (default 0)
| --max-height H raise the refusal ceiling; the cost doubles and then
| some with every height, so this is deliberate work
|
|open:
| --key HEX the 32-byte region key (§7.2)
| --coord COORD_HEX derive the key from a coordinate instead of naming it
| --height H the height to derive at; defaults to the bag's `h` tag
|
|sweep:
| --max-gap G refuse a hint whose gap exceeds G bits (default 20).
| The gap is the exponent: 2^G region keys to derive.
| --limit N stop after N candidates, found or not
| --ids print every candidate's lookup_id, not only the match
""".trimMargin()
suspend fun dispatch(tail: Array<String>): Int =
route(
"cyberspace",
tail,
"cyberspace <coord|region|hint|open|sweep>",
mapOf(
"coord" to { rest -> coord(rest) },
"region" to { rest -> region(rest) },
"hint" to { rest -> hint(rest) },
"open" to { rest -> open(rest) },
"sweep" to { rest -> sweep(rest) },
),
USAGE,
)
/**
* Decode a coordinate. The axes are reported as decimal strings because an
* 85-bit value does not fit any JSON number a reader can trust.
*/
private fun coord(rest: Array<String>): Int {
val args = Args(rest)
val hex = args.positional.firstOrNull() ?: return Output.error("bad_args", "cyberspace coord COORD_HEX")
args.rejectUnknown()
val point = CyberspaceCoordinate.decode(hex) ?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex")
Output.emit(
mapOf(
"coord" to hex,
"plane" to if (point.plane == CyberspacePlane.DATASPACE) "dataspace" else "ideaspace",
"x" to decimal(point.x.high, point.x.low),
"y" to decimal(point.y.high, point.y.low),
"z" to decimal(point.z.high, point.z.low),
"sector" to point.sector(),
),
)
return 0
}
/**
* The region key at a height: the same three fields `cyberspace-cli`'s
* `derive_region_key_material_for_height` returns, minus `region_n`, which
* is eleven kilobytes of hex by height 10 and is pinned exactly by the key
* that hashes it.
*/
private fun region(rest: Array<String>): Int {
val args = Args(rest)
val hex = args.positional.firstOrNull() ?: return Output.error("bad_args", "cyberspace region COORD_HEX --height H")
val height = args.intFlag("height", 0)
val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT)
args.rejectUnknown()
val point = CyberspaceCoordinate.decode(hex) ?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex")
if (height < 0) return Output.error("bad_args", "--height must be >= 0")
if (height > maxHeight) {
// The same refusal both references make, and for the same reason:
// one height further is twice the leaves and a root twice as wide.
return Output.error("too_big", "height $height exceeds max-height $maxHeight")
}
val material = RegionKey.at(point, height, maxHeight)
Output.emit(
mapOf(
"coord" to hex,
"height" to height,
"region_bits" to material.regionN.bitLength,
"key" to material.decryptionKey.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') },
"lookup_id" to material.lookupId,
),
)
return 0
}
/**
* Read a bag's `hint` tag and price the search it describes (§7.7).
*
* A hint is the hider's difficulty knob, so the number that matters is the
* gap: `(Hx - h) + (Hy - h) + (Hz - h)`, the exponent of the candidate
* count. §7.7's own table reads in those terms — 12 is seconds, 24 is
* hours, 30 or more is "days to never" — and a client that means to offer a
* sweep has to know which of those it is offering *before* it starts.
*
* A malformed hint reports `hint: false` rather than an error, because
* §7.7 says a bad hint is an absent one and "never invalidates the bag".
*/
private fun hint(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
args.rejectUnknown()
val event =
try {
Event.fromJson(json)
} catch (_: Exception) {
return Output.error("bad_event", "not a nostr event")
}
// §8.6's `h` tag: the height of the region the content is keyed to. A
// hint is read against it, since a box smaller than the region it
// claims to hold is one of §7.7's malformed cases.
val bagHeight =
event.tags
.firstOrNull { it.size > 1 && it[0] == "h" }
?.get(1)
?.toIntOrNull()
val hint = CyberspaceHint.read(event.tags, bagHeight)
if (hint == null) {
Output.emit(mapOf("hint" to false, "height" to bagHeight))
return 0
}
val height = bagHeight ?: 0
Output.emit(
mapOf(
"hint" to true,
"height" to bagHeight,
"box" to CyberspaceCoordinate.encode(hint.base),
"heights" to listOf(hint.heightX, hint.heightY, hint.heightZ),
"gap_bits" to hint.gapBits(height),
"candidates" to hint.candidates(height),
"axis_trees" to hint.axisTrees(height),
"destination" to hint.isDestination(height),
"sector_tags" to hint.sectorTags().map { it.toList() },
),
)
return 0
}
/**
* Open a bag with a region key (§7.6).
*
* The key can be named outright (`--key`) or derived here from a
* coordinate (`--coord`), which is the shape that proves the whole chain in
* one line: our §2.2 decode, our Cantor roots and our §7.2 derivation
* against a ciphertext somebody else produced.
*
* **A bag that will not open exits 0.** §7.6: "A failed decryption
* therefore means only that the reader does not hold this region's key; it
* MUST NOT be treated as an error in the bag." So the wrong key is a
* verdict — `opened: false` — the same way `sno validate` reports an
* invalid payload rather than failing. What does fail is a bag this cannot
* attempt at all: an unknown `version`, or no `aes-256-gcm` payload to try.
*/
private fun open(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
val keyHex = args.flag("key")
val coordHex = args.flag("coord")
val heightRaw = args.flag("height")
val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT)
args.rejectUnknown()
if ((keyHex == null) == (coordHex == null)) {
return Output.error("bad_args", "give exactly one of --key HEX or --coord COORD_HEX")
}
val bag = bagOf(json) ?: return Output.error("bad_event", "not a kind ${CyberspaceBagEvent.KIND} bag")
if (!bag.isKnownVersion()) {
// §8.6: "A reader MUST ignore a bag whose version it does not know."
return Output.error("unsupported_version", "not a version ${CyberspaceBagEvent.VERSION} bag (§8.6)")
}
if (bag.payload() == null) {
return Output.error("bad_event", "no [\"encrypted\", \"${CyberspaceBagEvent.ALGORITHM}\", …] tag to open")
}
val height =
if (heightRaw == null) {
bag.height() ?: 0
} else {
heightRaw.toIntOrNull() ?: return Output.error("bad_args", "--height expects a number, got '$heightRaw'")
}
val key =
if (keyHex != null) {
keyHex.hexToByteArrayOrNull()?.takeIf { it.size == CyberspaceBagEvent.KEY_BYTES }
?: return Output.error("bad_args", "--key must be ${CyberspaceBagEvent.KEY_BYTES} bytes of hex")
} else {
val point =
CyberspaceCoordinate.decode(coordHex!!)
?: return Output.error("bad_args", "not a coordinate: 32 bytes of lowercase hex")
if (height < 0) return Output.error("bad_args", "--height must be >= 0")
if (height > maxHeight) return Output.error("too_big", "height $height exceeds max-height $maxHeight")
RegionKey.at(point, height, maxHeight).decryptionKey
}
val contents = bag.open(key)
if (contents == null) {
Output.emit(mapOf("opened" to false, "height" to height, "lookup_id" to bag.lookupId()))
return 0
}
val common = mapOf("opened" to true, "height" to height, "lookup_id" to bag.lookupId())
when (contents) {
is CyberspaceBagContents.Items ->
Output.emit(
common +
mapOf(
"shape" to "items",
"dropped" to contents.dropped,
"items" to
contents.items.map { item ->
mapOf(
"kind" to item.event.kind,
"id" to item.event.id,
"pubkey" to item.event.pubKey,
// §7.6: authorship only when this is true.
"verified" to item.verified,
"coord" to item.coordinate(),
"event" to Output.mapper.readTree(item.event.toJson()),
)
},
),
)
is CyberspaceBagContents.Opaque ->
Output.emit(
common +
mapOf(
"shape" to "opaque",
"bytes" to contents.bytes.size,
"base64" to Base64.encode(contents.bytes),
"text" to asText(contents.bytes),
),
)
}
return 0
}
/**
* Sweep the box a bag's hint names until its own `lookup_id` comes up
* (§7.7) — the position-free search, priced before it starts.
*
* The price is the point. A hint is a stranger's choice of difficulty, and
* the gap it declares is the exponent of the work: 2^gap region keys. So
* the gap is read and checked against `--max-gap` before the first tree is
* built, and a hint asking for more says so and stops rather than running
* for a day. Raising the ceiling is how a caller spends it on purpose.
*/
private fun sweep(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
val maxGap = args.intFlag("max-gap", DEFAULT_MAX_GAP)
val limit = args.longFlag("limit", Long.MAX_VALUE)
val wantIds = args.bool("ids")
val maxHeight = args.intFlag("max-height", CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT)
args.rejectUnknown()
val bag = bagOf(json) ?: return Output.error("bad_event", "not a kind ${CyberspaceBagEvent.KIND} bag")
val height = bag.height() ?: return Output.error("no_hint", "a sweep needs the bag's `h` tag (§8.6)")
val hint = bag.hint() ?: return Output.error("no_hint", "no usable `hint` tag to sweep (§7.7)")
val gap = hint.gapBits(height)
if (gap > maxGap) {
return Output.error(
"too_big",
"a gap of $gap bits is 2^$gap region keys; pass --max-gap $gap to spend it",
extra = mapOf("gap_bits" to gap, "max_gap" to maxGap),
)
}
if (limit < 1) return Output.error("bad_args", "--limit must be >= 1")
// What the hider published as the address, and therefore the only thing
// a sweep can recognise when it walks past the right region.
val target = bag.lookupId()
val ids = if (wantIds) mutableListOf<String>() else null
var examined = 0L
var exhausted = true
var found: RegionKeyMaterial? = null
val candidates =
try {
RegionSweep.of(hint, height, maxHeight)
} catch (e: IllegalArgumentException) {
return Output.error("too_big", e.message)
}
for (material in candidates) {
examined++
ids?.add(material.lookupId)
if (target != null && material.lookupId == target) {
found = material
exhausted = false
break
}
if (examined >= limit) {
exhausted = false
break
}
}
Output.emit(
mapOf(
"height" to height,
"gap_bits" to gap,
"candidates" to hint.candidates(height),
"axis_trees" to hint.axisTrees(height),
"target" to target,
"examined" to examined,
"exhausted" to exhausted,
"found" to (found != null),
"key" to found?.decryptionKey?.toHexKey(),
"lookup_id" to found?.lookupId,
"lookup_ids" to ids,
),
)
return 0
}
/**
* Read a kind-33330 bag out of raw JSON.
*
* [Event.fromJson] already answers with a [CyberspaceBagEvent] for a
* registered kind; the rebuild is for the caller that hands us an event
* from a factory that did not, so the verb behaves the same either way.
*/
private fun bagOf(json: String): CyberspaceBagEvent? {
val event =
try {
Event.fromJson(json)
} catch (_: Exception) {
return null
}
if (event is CyberspaceBagEvent) return event
if (event.kind != CyberspaceBagEvent.KIND) return null
return CyberspaceBagEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
}
/**
* These bytes as text, or null when they are not UTF-8 — §7.6's opaque
* shape is "a text note or a file", and a file should not be printed as a
* field of replacement characters.
*/
private fun asText(bytes: ByteArray): String? {
val text = bytes.decodeToString()
return if (text.encodeToByteArray().contentEquals(bytes)) text else null
}
/**
* The gap a sweep spends without being told to.
*
* 2^20 is about a million region keys — a minute or so of a laptop, and the
* scale §7.7's own table calls a reasonable search. Everything past it is
* the caller's decision to make in the command line, because §7.7's larger
* boxes run from "hours" to "days to never" and nothing about a bag tells
* you which one a stranger meant.
*/
private const val DEFAULT_MAX_GAP = 20
/** An 85-bit axis as decimal, from its two halves, without a big integer. */
private fun decimal(
high: Long,
low: Long,
): String {
// high * 2^64 + low, with low unsigned. Done in base 10^9 chunks so the
// CLI does not need arbitrary precision for a number it only prints.
var result = "0"
for (bit in 84 downTo 0) {
result = addDecimal(result, result)
val set = if (bit >= 64) (high ushr (bit - 64)) and 1L else (low ushr bit) and 1L
if (set == 1L) result = addDecimal(result, "1")
}
return result
}
private fun addDecimal(
a: String,
b: String,
): String {
val out = StringBuilder()
var carry = 0
var i = a.length - 1
var j = b.length - 1
while (i >= 0 || j >= 0 || carry > 0) {
val sum = (if (i >= 0) a[i--] - '0' else 0) + (if (j >= 0) b[j--] - '0' else 0) + carry
out.append(('0' + sum % 10))
carry = sum / 10
}
return out.reverse().toString()
}
}
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarWork
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.math.abs
/**
* `amy sno …` — read Simple Nostr Objects (DECK-0003), local and accountless.
*
* Thin assembly only: every rule lives in quartz's `cyberspace/deck0003Sno/`.
* These verbs exist so the Kotlin reader can be diffed against the deck's own
* `sno-reference.py` and against cyberspace-cli without a device or a relay —
* see `cli/tests/sno/`.
*/
object SnoCommands {
val USAGE: String =
"""
|amy sno — Simple Nostr Objects, DECK-0003 (local, accountless)
|
| sno parse [PAYLOAD|-] validate a payload against §1.9
| sno work [PAYLOAD|-] the proof of work an avatar owes for it (§8.10)
| sno verify [EVENT|-] whether a kind 11333 avatar event has paid
|
|Each reads its argument as JSON, or from stdin when it is omitted or `-`.
""".trimMargin()
suspend fun dispatch(tail: Array<String>): Int =
route(
"sno",
tail,
"sno <parse|work|verify>",
mapOf(
"parse" to { rest -> parse(rest) },
"work" to { rest -> work(rest) },
"verify" to { rest -> verify(rest) },
),
USAGE,
)
/**
* Validate a payload. `valid` is the answer; on a refusal `rule` names the
* numbered rule of §1.9 that failed, which is what makes a verdict
* comparable with `sno-reference.py`'s.
*/
private fun parse(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
args.rejectUnknown()
return when (val result = SnoParser.parse(json)) {
is SnoResult.Invalid -> {
Output.emit(mapOf("valid" to false, "rule" to result.rule, "reason" to result.reason))
0
}
is SnoResult.Valid -> {
Output.emit(mapOf("valid" to true) + facts(result.payload))
0
}
}
}
private fun work(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
args.rejectUnknown()
val payload =
SnoParser.parse(json).payloadOrNull()
?: return Output.error("bad_payload", "not a valid SNO payload")
Output.emit(
mapOf(
"required" to SnoAvatarWork.required(payload),
"reach_ticks" to reachTicks(payload),
"unit" to payload.unit,
"detail" to maxOf(SnoAvatarWork.DETAIL_FREE, payload.vertexCount + payload.faceCount),
),
)
return 0
}
/**
* Whether an avatar event may be drawn. The keys mirror
* `verify_avatar_work` in the reference implementations so the two can be
* diffed directly.
*/
private fun verify(rest: Array<String>): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
args.rejectUnknown()
val event =
try {
Event.fromJson(json)
} catch (_: Exception) {
return Output.error("bad_event", "not a nostr event")
}
val avatar =
event as? SnoAvatarEvent
?: return Output
.emit(
mapOf("ok" to false, "required" to 0, "committed" to null, "zeros" to 0, "reason" to "not-an-avatar"),
).let { 0 }
val payment = avatar.payment()
Output.emit(
mapOf(
"ok" to payment.ok,
"required" to payment.required,
"committed" to payment.committed,
"zeros" to payment.zeros,
"reason" to payment.reason.code,
),
)
return 0
}
/** The facts a conformance diff cares about, and nothing else. */
private fun facts(payload: SnoPayload): Map<String, Any?> =
mapOf(
"version" to payload.version,
"name" to payload.name,
"unit" to payload.unit,
"extent" to payload.extent,
"mode" to payload.mode.code,
"vertices" to payload.vertexCount,
"faces" to payload.faceCount,
"has_face_colors" to (payload.faceColors != null),
"palette" to
when (payload.paletteRef) {
SnoPaletteRef.BuiltIn -> "built-in"
is SnoPaletteRef.Inline -> "inline"
is SnoPaletteRef.Event -> "event"
},
"up" to payload.up,
"spin" to payload.spin,
"reach_ticks" to reachTicks(payload),
)
/**
* The farthest any vertex lies from the build origin, in ticks — the
* integer half of §8.10's reach, before the scale exponent is applied.
* Reported as an integer so a diff never argues about float formatting.
*/
private fun reachTicks(payload: SnoPayload): Int {
var farthest = 0
for (tick in payload.positions) {
val magnitude = abs(tick)
if (magnitude > farthest) farthest = magnitude
}
return farthest
}
}
+11
View File
@@ -38,6 +38,9 @@ cli/tests/
│ └── README.md # operator brief + per-test matrix
├── pow/ # NIP-13 primitives (bench/mine/check) — no relay
│ └── pow-headless.sh
├── sno/ # DECK-0003 conformance vs the cyberspace project's
│ ├── sno-conformance.sh # own reference implementations — no relay
│ └── refdriver.py
├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode)
│ └── relaygroup-headless.sh
└── sync/ # NIP-77 deletion propagation vs `amy serve`
@@ -58,6 +61,14 @@ Suite notes:
(the same fixtures quartz's `ClinkInteropTest` uses) to the right fields,
plus the argument-error paths. The round-trip verbs (`offer request`,
`debit pay/budget`) need a live CLINK service and aren't covered here.
- **`sno/sno-conformance.sh`** is relay-free and diffs `amy sno` against the
cyberspace project's own references: the deck's `sno-reference.py` for §1.9
verdicts (the rejection table drives the run, so the fixtures are theirs and
not ours) and cyberspace-cli's `avatar.py` for the §8.10 work ladder and its
golden vectors. The two places our reader knowingly differs are asserted as
divergences rather than skipped, so neither can drift quietly. Reference
checkouts are cloned into `state/` unless `CYBERSPACE_DIR` /
`CYBERSPACE_CLI_DIR` already point at them.
- **`pow/pow-headless.sh`** is also relay-free: `pow bench` sanity,
`pow mine` hitting its target (and exiting 124 on an impossible one),
and mined-nonce round-trips through `pow check`.
+1
View File
@@ -0,0 +1 @@
state-sno-conformance/
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Adapter over the cyberspace project's own reference implementations.
Speaks one JSON object per line on stdout so `sno-conformance.sh` can diff it
against `amy sno … --json` with jq. Nothing here reimplements anything: it
imports `decks/sno-reference.py` from the cyberspace spec repo (the §1.9
arbiter) and `cyberspace_core/avatar.py` from cyberspace-cli (the §8.10 work
and payment reference), and just re-emits what they answer.
refdriver.py cases the deck's own rejection table, + Appendix A
refdriver.py vectors cyberspace-cli's avatar-work golden vectors
refdriver.py verdict < payload {"valid":…, "rule":…}
refdriver.py work < payload {"required":…}
refdriver.py verify < event {"ok":…, "required":…, "committed":…, "zeros":…, "reason":…}
refdriver.py region COORD HEIGHT {"key":…, "lookup_id":…, "x":…, "y":…, "z":…, "plane":…}
refdriver.py hints §7.7's golden hint vectors, one per line
refdriver.py encrypt COORD HEIGHT seal stdin into a kind-33330 bag at that region
refdriver.py box COORD HX HY HZ the §7.7 hint tag for the box around that coord
Paths come from $CYBERSPACE_DIR and $CYBERSPACE_CLI_DIR.
"""
import importlib.util
import json
import os
import signal
import sys
# Used in pipelines (`| head`), where dying on SIGPIPE is the right answer
# rather than a traceback.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def _load(path, name):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None:
raise SystemExit(f"cannot load {path}")
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
def _sno():
root = os.environ.get("CYBERSPACE_DIR")
if not root:
raise SystemExit("CYBERSPACE_DIR is not set")
return _load(os.path.join(root, "decks", "sno-reference.py"), "sno_reference")
def _avatar():
root = os.environ.get("CYBERSPACE_CLI_DIR")
if not root:
raise SystemExit("CYBERSPACE_CLI_DIR is not set")
sys.path.insert(0, os.path.join(root, "src"))
return _load(os.path.join(root, "src", "cyberspace_core", "avatar.py"), "ref_avatar")
def emit(obj):
print(json.dumps(obj, separators=(",", ":"), sort_keys=True))
def cmd_cases():
sno = _sno()
emit({"name": "appendix A", "rule": None, "payload": dict(sno.APPENDIX_A)})
for label, payload in sno._rejections():
emit({"name": label, "rule": label.replace("rule ", ""), "payload": payload})
def cmd_vectors():
root = os.environ.get("CYBERSPACE_CLI_DIR")
if not root:
raise SystemExit("CYBERSPACE_CLI_DIR is not set")
with open(os.path.join(root, "tests", "fixtures", "avatar_work.json")) as fh:
for case in json.load(fh):
emit({"name": case["name"], "required": case["required"], "payload": case["payload"]})
def cmd_verdict():
sno = _sno()
payload = json.load(sys.stdin)
try:
sno.validate(dict(payload))
emit({"valid": True, "rule": None})
except sno.SnoError as err:
text = str(err)
rule = text.split(":")[0].replace("rule ", "").strip() if text.startswith("rule ") else "?"
emit({"valid": False, "rule": rule})
def cmd_work():
avatar = _avatar()
emit({"required": avatar.avatar_work(json.load(sys.stdin))})
def cmd_verify():
avatar = _avatar()
result = avatar.verify_avatar_work(json.load(sys.stdin))
emit({k: result[k] for k in ("ok", "required", "committed", "zeros", "reason")})
def cmd_region():
"""§2.2 decode plus §7.2 derivation, from cyberspace-cli's own modules.
`location_encryption.py` imports AESGCM at module scope and that binding is
not always present; `cantor` and `movement` are the whole of what a region
key needs, and going through them keeps this an adapter rather than a
reimplementation.
"""
sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src"))
from cyberspace_core.coords import coord_to_xyz
from cyberspace_core.cantor import cantor_pair, int_to_bytes_be_min, sha256
from cyberspace_core.movement import compute_subtree_cantor
coord_hex, height = sys.argv[2], int(sys.argv[3])
x, y, z, plane = coord_to_xyz(int(coord_hex, 16))
base = lambda v: (v >> height) << height if height > 0 else v
region_n = cantor_pair(
cantor_pair(
compute_subtree_cantor(base(x), height),
compute_subtree_cantor(base(y), height),
),
compute_subtree_cantor(base(z), height),
)
key = sha256(int_to_bytes_be_min(region_n))
emit({
"key": key.hex(),
"lookup_id": sha256(key).hex(),
"x": str(x), "y": str(y), "z": str(z),
"plane": "ideaspace" if plane else "dataspace",
})
def cmd_hints():
"""§7.7's golden vectors, straight out of the spec's own hint-reference.py."""
root = os.environ["CYBERSPACE_DIR"]
# It sits at the repo root, beside CYBERSPACE_V2.md, rather than under
# decks/ where the DECK references live; accept either in case it moves.
path = next(
(p for p in (os.path.join(root, "hint-reference.py"), os.path.join(root, "decks", "hint-reference.py")) if os.path.exists(p)),
None,
)
if path is None:
raise SystemExit("hint-reference.py not found under $CYBERSPACE_DIR")
hint_ref = _load(path, "hint_reference")
for name, vector in hint_ref.vectors().items():
emit({"name": name, **vector})
def cmd_encrypt():
"""Seal stdin into a §8.6 bag, using cyberspace-cli's own cipher and event builder.
The point of the round trip is that nothing on this side is ours: the key
comes from `location_encryption`, the sealing from `encrypt_with_location_key`
and the tags from `make_encrypted_content_event`. What `amy cyberspace open`
then has to do is derive the same key from the same coordinate and read a
ciphertext it never saw made.
A fixed nonce keeps the fixture reproducible; §7.6 wants a fresh one per
bag, which matters for a hider and not for a test that seals once.
"""
sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src"))
import base64
from cyberspace_core.coords import coord_to_xyz
from cyberspace_cli.nostr_event import make_encrypted_content_event
from cyberspace_core.location_encryption import (
derive_region_key_material_for_height,
encrypt_with_location_key,
)
coord_hex, height = sys.argv[2], int(sys.argv[3])
x, y, z, _plane = coord_to_xyz(int(coord_hex, 16))
material = derive_region_key_material_for_height(x=x, y=y, z=z, height=height)
payload = encrypt_with_location_key(
sys.stdin.buffer.read(),
location_decryption_key=material.location_decryption_key,
nonce=bytes(range(12)),
)
event = make_encrypted_content_event(
pubkey_hex="b" * 64,
created_at=1,
lookup_id_hex=material.lookup_id_hex,
algorithm="aes-256-gcm",
ciphertext_b64=base64.b64encode(payload).decode("ascii"),
height_hint=height,
content="",
kind=33330,
)
emit({
"event": event,
"key": material.location_decryption_key.hex(),
"lookup_id": material.lookup_id_hex,
})
def cmd_box():
"""The §7.7 `hint` tag naming the aligned box around a coordinate.
Built from the reference's own interleave so a sweep test is not marking
its own homework: the box comes from their `coord_to_xyz`/`xyz_to_coord`,
and if our alignment disagreed the bag would simply not be in the box we
were handed.
"""
sys.path.insert(0, os.path.join(os.environ["CYBERSPACE_CLI_DIR"], "src"))
from cyberspace_core.coords import coord_to_xyz, xyz_to_coord
coord_hex = sys.argv[2]
hx, hy, hz = (int(v) for v in sys.argv[3:6])
x, y, z, plane = coord_to_xyz(int(coord_hex, 16))
base = xyz_to_coord((x >> hx) << hx, (y >> hy) << hy, (z >> hz) << hz, plane)
emit({"tag": ["hint", f"{base:064x}", str(hx), str(hy), str(hz)]})
COMMANDS = {
"box": cmd_box,
"cases": cmd_cases,
"encrypt": cmd_encrypt,
"region": cmd_region,
"hints": cmd_hints,
"vectors": cmd_vectors,
"verdict": cmd_verdict,
"work": cmd_work,
"verify": cmd_verify,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
raise SystemExit(f"usage: refdriver.py <{'|'.join(COMMANDS)}>")
COMMANDS[sys.argv[1]]()
+510
View File
@@ -0,0 +1,510 @@
#!/usr/bin/env bash
#
# sno-conformance.sh — diffs amy's DECK-0003 reader against the cyberspace
# project's own reference implementations. No relay, no account, no device.
#
# Eight comparisons, each driven by the reference's own fixtures rather than
# by anything we wrote:
#
# 1. §1.9 verdicts — the deck's rejection table (`_rejections()` in
# decks/sno-reference.py) plus Appendix A, through
# `amy sno parse` and through the reference. The
# valid/invalid answer AND the rule number must agree.
# 2. Avatar work — the golden vectors in cyberspace-cli's
# tests/fixtures/avatar_work.json, which §8.10 says
# both reference implementations are pinned to,
# through `amy sno work` and `avatar_work()`.
# 3. Avatar payment — synthesised events through `amy sno verify` and
# `verify_avatar_work()`.
# 4. Reader divergence — where we knowingly differ from the §1.9 arbiter,
# and one place we used to and no longer do, pinned so
# neither can drift quietly.
# 5. Region keys — §2.2 decodes and §7.2 keys against cyberspace-cli.
# 6. Hint boxes — §7.7's golden vectors through `amy cyberspace hint`.
# 7. Bags — a ciphertext cyberspace-cli sealed, opened by
# `amy cyberspace open` from the coordinate alone.
# 8. Sweeps — the same bag found by `amy cyberspace sweep` from
# its hint box, with no coordinate at all.
#
# Divergences we already know about are asserted as divergences, not ignored:
# a reader that silently stopped diverging would be just as interesting as one
# that started.
#
# Usage: ./sno-conformance.sh [--no-build]
#
# Reference checkouts are cloned into state/ unless CYBERSPACE_DIR and
# CYBERSPACE_CLI_DIR already point at them.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-sno-conformance"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
DRIVER="$SCRIPT_DIR/refdriver.py"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) NO_BUILD=1 ;;
-h|--help) sed -n '3,33p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$SCRIPT_DIR/../lib.sh"
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
command -v python3 >/dev/null || { fail_msg "python3 is required"; exit 1; }
# ---- the reference checkouts ------------------------------------------------
clone_ref() {
local url="$1" dest="$2"
if [[ -d "$dest/.git" ]]; then info "reusing $dest"; return 0; fi
step "cloning $url"
GIT_LFS_SKIP_SMUDGE=1 git clone --quiet --depth 1 "$url" "$dest" >>"$LOG_FILE" 2>&1
}
: "${CYBERSPACE_DIR:=$STATE_DIR/cyberspace}"
: "${CYBERSPACE_CLI_DIR:=$STATE_DIR/cyberspace-cli}"
export CYBERSPACE_DIR CYBERSPACE_CLI_DIR
if [[ ! -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]]; then
clone_ref https://github.com/arkin0x/cyberspace "$CYBERSPACE_DIR" || true
fi
if [[ ! -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]]; then
clone_ref https://github.com/arkin0x/cyberspace-cli "$CYBERSPACE_CLI_DIR" || true
fi
HAVE_SNO_REF=0; [[ -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]] && HAVE_SNO_REF=1
HAVE_AVATAR_REF=0; [[ -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]] && HAVE_AVATAR_REF=1
if [[ $NO_BUILD -eq 0 ]]; then
step "building amy (installDist)"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not built at $AMY_BIN"; exit 1; }
amy() { "$AMY_BIN" --json "$@" 2>>"$LOG_FILE"; }
ref() { python3 "$DRIVER" "$@" 2>>"$LOG_FILE"; }
# ---- 1. §1.9 verdicts -------------------------------------------------------
banner "1. §1.9 verdicts — the deck's own rejection table"
if [[ $HAVE_SNO_REF -eq 0 ]]; then
skip_msg "sno-reference.py unavailable"
record_result "sno-verdicts" skip "no cyberspace checkout"
else
AGREE=0; DISAGREE=0; DIVERGENCES=""
while IFS= read -r line; do
NAME="$(jq -r .name <<<"$line")"
PAYLOAD="$(jq -c .payload <<<"$line")"
OURS="$(printf '%s' "$PAYLOAD" | amy sno parse -)"
THEIRS="$(printf '%s' "$PAYLOAD" | ref verdict)"
OUR_VALID="$(jq -r '.valid' <<<"$OURS")"
OUR_RULE="$(jq -r '.rule // "null"' <<<"$OURS")"
THEIR_VALID="$(jq -r '.valid' <<<"$THEIRS")"
THEIR_RULE="$(jq -r '.rule // "null"' <<<"$THEIRS")"
if [[ "$OUR_VALID" == "$THEIR_VALID" && "$OUR_RULE" == "$THEIR_RULE" ]]; then
AGREE=$((AGREE+1))
else
DISAGREE=$((DISAGREE+1))
DIVERGENCES+=" $NAME: amy(valid=$OUR_VALID rule=$OUR_RULE) ref(valid=$THEIR_VALID rule=$THEIR_RULE)"$'\n'
fi
done < <(ref cases)
info "$AGREE agreed, $DISAGREE diverged"
[[ -n "$DIVERGENCES" ]] && printf '%s' "$DIVERGENCES" >&2
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "sno-verdicts" pass "$AGREE cases agree"
else
record_result "sno-verdicts" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 2. avatar work ---------------------------------------------------------
banner "2. avatar work — cyberspace-cli's golden vectors"
if [[ $HAVE_AVATAR_REF -eq 0 ]]; then
skip_msg "cyberspace-cli unavailable"
record_result "sno-avatar-work" skip "no cyberspace-cli checkout"
else
AGREE=0; DISAGREE=0
while IFS= read -r line; do
NAME="$(jq -r .name <<<"$line")"
EXPECTED="$(jq -r .required <<<"$line")"
PAYLOAD="$(jq -c .payload <<<"$line")"
THEIRS="$(printf '%s' "$PAYLOAD" | ref work | jq -r .required)"
# The fixtures carry only the fields the formula reads, and some index
# faces past the vertex list because it never looks at them — so dress
# each one as a payload §1.9 accepts, keeping the counts and the reach.
FULL="$(jq -c --argjson p "$PAYLOAD" -n '
($p.vertices | length) as $nv
| {v:2, name:"vector", unit:($p.unit // 0),
mode:(if (($p.faces // []) | length) > 0 then "solid" else "points" end),
vertices:$p.vertices,
colors:[range($nv) | 225],
faces:(if $nv >= 3 then [range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]] else [] end)}
+ (if $p.ticks then {ticks:$p.ticks} else {} end)')"
NV="$(jq -r '.vertices | length' <<<"$PAYLOAD")"
NF="$(jq -r '(.faces // []) | length' <<<"$PAYLOAD")"
if [[ "$NV" -lt 3 && "$NF" -gt 0 ]]; then
fail_msg "$NAME: $NF faces over $NV vertices cannot be expressed as a §1.9 payload"
DISAGREE=$((DISAGREE+1))
continue
fi
OURS="$(printf '%s' "$FULL" | amy sno work | jq -r .required)"
if [[ "$OURS" == "$THEIRS" && "$OURS" == "$EXPECTED" ]]; then
AGREE=$((AGREE+1))
info "$NAME: $OURS bits"
else
DISAGREE=$((DISAGREE+1))
fail_msg "$NAME: amy=$OURS ref=$THEIRS fixture=$EXPECTED"
fi
done < <(ref vectors)
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "sno-avatar-work" pass "$AGREE golden vectors agree"
else
record_result "sno-avatar-work" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 3. avatar payment, and the divergences we expect -----------------------
banner "3. avatar payment — and the divergences we know about"
SHAPE='{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}'
# id with exactly N leading zero bits, padded to 64 hex chars
id_with_bits() {
python3 - "$1" <<'PY'
import sys
bits = int(sys.argv[1])
head = "0" * (bits // 4) + {0: "f", 1: "4", 2: "2", 3: "1"}[bits % 4]
print(head + "f" * (64 - len(head)))
PY
}
avatar_event() { # kind, id-bits, committed|none, content
local kind="$1" bits="$2" committed="$3" content="$4"
local tags="[]"
[[ "$committed" != "none" ]] && tags="[[\"nonce\",\"1\",\"$committed\"]]"
jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \
--argjson tags "$tags" --arg content "$content" \
'{id:$id, pubkey:("11"*32), kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}'
}
check_verify() { # label, event, expected-amy-reason, expected-ref-reason
local label="$1" event="$2" want_ours="$3" want_theirs="$4"
local ours theirs
ours="$(printf '%s' "$event" | amy sno verify - | jq -r .reason)"
if [[ $HAVE_AVATAR_REF -eq 1 ]]; then
theirs="$(printf '%s' "$event" | ref verify | jq -r .reason)"
else
theirs="$want_theirs"
fi
if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then
record_result "$label" pass "amy=$ours ref=$theirs"
else
record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)"
fi
}
# A paid avatar. The reference wants kind 33331 — it predates the move to
# 11333 that §8.10 documents — so it refuses a conformant one outright.
check_verify "verify-paid" "$(avatar_event 11333 16 16 "$SHAPE")" ok not-an-avatar
check_verify "verify-no-nonce" "$(avatar_event 11333 32 none "$SHAPE")" no-nonce not-an-avatar
check_verify "verify-under-committed" "$(avatar_event 11333 32 8 "$SHAPE")" under-committed not-an-avatar
# The trap: committed 30, owes 16, id carries 20. min(20,30)=20 clears 16, so
# a reader comparing the wrong number calls this paid. Both of us refuse it.
check_verify "verify-short-of-commitment" "$(avatar_event 11333 20 30 "$SHAPE")" unpaid not-an-avatar
# Empty content is §8.10's default avatar and owes no work; the reference
# calls it not-an-avatar because empty is not JSON. Same outcome — the client
# draws its default — different word for it.
check_verify "verify-default-avatar" "$(avatar_event 11333 0 none "")" default-avatar not-an-avatar
# The reference's own kind. We refuse it as an avatar because DECK-0003 gave
# 33331 to standalone objects; it accepts it and prices it.
check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an-avatar ok
# ---- 4. the two places our reader knowingly differs ------------------------
banner "4. reader divergence and agreement — pinned, not ignored"
# None of these are in the deck's rejection table, so section 1 never sees
# them. Each is deliberate and documented; a change in either direction is
# news, including a divergence that quietly reappears after being repealed.
check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, note
local label="$1" payload="$2" want_ours="$3" want_theirs="$4" note="$5"
local ours theirs
ours="$(printf '%s' "$payload" | amy sno parse - | jq -r .valid)"
if [[ $HAVE_SNO_REF -eq 1 ]]; then
theirs="$(printf '%s' "$payload" | ref verdict | jq -r .valid)"
else
theirs="$want_theirs"
fi
if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then
record_result "$label" pass "amy=$ours ref=$theirs — $note"
else
record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)"
fi
}
# D1. A v:2 payload whose colours are still literal triples. Version 2 shipped
# as two changes that did not land together, so these exist and are right in
# every other respect; §5 permits a reader to be generous and sno-core is.
# Three of the seven objects on the network are this shape.
TRIPLES='{"v":2,"name":"legacy","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]],"faces":[[0,1,2]]}'
check_verdict "divergence-v2-triples" "$TRIPLES" true false "we read the legacy colour cohort; the arbiter refuses it"
# D5, repealed. A vertex past 64 units. §1.8 puts that bound on publishers only
# and gives a reader the choice — "MAY reject such a payload and MAY instead
# repair it by growing the extent" — and neither reference bounds it: both grow
# the extent. Amethyst rejected, which made it the only reader that refused an
# object the rest of the network drew, so it repairs now and this is pinned as
# agreement. A reader that started refusing again would be news.
FAR='{"v":2,"name":"far","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[9999,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}'
check_verdict "agreement-position-repair" "$FAR" true true "both repair a vertex past the grid rather than refusing it"
# D5b. What is left of that bound: the lattice itself. A position is a total
# tick count in an Int, so past Int.MAX_VALUE ticks — 17,895,697 units — there
# is no coordinate to repair, only one that would wrap. The reference keeps its
# positions in a double and carries this one fine, which is the divergence.
WRAP='{"v":2,"name":"wrap","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}'
check_verdict "divergence-lattice-limit" "$WRAP" false true "past what an Int lattice holds we refuse; the arbiter keeps it in a float"
# ---- 5. cyberspace region keys ---------------------------------------------
banner "5. region keys — CYBERSPACE_V2 §2.2 and §7.2 against cyberspace-cli"
# A region key is a consensus value: §7.2 turns it into an AES key, so a byte
# of difference is a bag they hid that we cannot open. Four coordinates from
# §9.8's golden vectors (plus §7.7's ideaspace point) at four heights each.
if [[ $HAVE_AVATAR_REF -eq 0 ]]; then
skip_msg "cyberspace-cli unavailable"
record_result "cyberspace-region-keys" skip "no cyberspace-cli checkout"
else
AGREE=0; DISAGREE=0
for COORD in \
c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940 \
c4924924924924924924921f79235dae293ada913e78294253a235239a332854 \
e000000000000000000001200041040208048040000000000000000000000000 \
a4b64924924924924924924924924924924924924924924924924d84b60d9c8f
do
for H in 0 1 4 8; do
THEIRS="$(ref region "$COORD" "$H")"
OURS="$(amy cyberspace region "$COORD" --height "$H")"
T_KEY="$(jq -r .key <<<"$THEIRS")"; O_KEY="$(jq -r .key <<<"$OURS")"
T_ID="$(jq -r .lookup_id <<<"$THEIRS")"; O_ID="$(jq -r .lookup_id <<<"$OURS")"
# And the decode underneath it, which is where a wrong bit would start.
T_XYZ="$(jq -r '"\(.x) \(.y) \(.z) \(.plane)"' <<<"$THEIRS")"
O_XYZ="$(amy cyberspace coord "$COORD" | jq -r '"\(.x) \(.y) \(.z) \(.plane)"')"
if [[ "$O_KEY" == "$T_KEY" && "$O_ID" == "$T_ID" && "$O_XYZ" == "$T_XYZ" ]]; then
AGREE=$((AGREE+1))
else
DISAGREE=$((DISAGREE+1))
fail_msg "${COORD:0:8}… h$H: key amy=${O_KEY:0:16} ref=${T_KEY:0:16}; xyz amy=[$O_XYZ] ref=[$T_XYZ]"
fi
done
done
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "cyberspace-region-keys" pass "$AGREE keys and decodes agree"
else
record_result "cyberspace-region-keys" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 6. §7.7 hint boxes -----------------------------------------------------
banner "6. hint boxes — §7.7's golden vectors through amy"
# A hint is the hider's difficulty knob: the box it names decides how many
# region keys a seeker has to derive. Getting the aligned base or the sector
# rule wrong means sweeping the wrong box, which finds nothing and says so
# only after the work is done.
if [[ $HAVE_SNO_REF -eq 0 ]]; then
skip_msg "cyberspace checkout unavailable"
record_result "cyberspace-hints" skip "no cyberspace checkout"
else
AGREE=0; DISAGREE=0
while IFS= read -r line; do
NAME="$(jq -r .name <<<"$line")"
H="$(jq -r .bag_height <<<"$line")"
WANT_LOG2="$(jq -r .candidates_log2 <<<"$line")"
WANT_HINT="$(jq -c '[.tags[] | select(.[0] == "hint")][0]' <<<"$line")"
WANT_SECTORS="$(jq -c '[.tags[] | select(.[0] != "hint")]' <<<"$line")"
# A bag carrying exactly the tags the reference says a hider writes.
BAG="$(jq -cn --argjson tags "$(jq -c .tags <<<"$line")" --arg h "$H" '
{id:("a"*64), pubkey:("b"*64), created_at:1, kind:33330,
tags:([["d",("c"*64)],["h",$h]] + $tags), content:"", sig:("0"*128)}')"
OURS="$(printf '%s' "$BAG" | amy cyberspace hint -)"
GOT_HINT="$(jq -c '["hint", .box, (.heights[0]|tostring), (.heights[1]|tostring), (.heights[2]|tostring)]' <<<"$OURS")"
GOT_SECTORS="$(jq -c '.sector_tags' <<<"$OURS")"
GOT_LOG2="$(jq -r .gap_bits <<<"$OURS")"
if [[ "$GOT_HINT" == "$WANT_HINT" && "$GOT_SECTORS" == "$WANT_SECTORS" && "$GOT_LOG2" == "$WANT_LOG2" ]]; then
AGREE=$((AGREE+1))
info "$NAME: 2^$GOT_LOG2 candidates, $(jq -r '.sector_tags | length' <<<"$OURS") sector tags"
else
DISAGREE=$((DISAGREE+1))
fail_msg "$NAME: hint amy=$GOT_HINT ref=$WANT_HINT; sectors amy=$GOT_SECTORS ref=$WANT_SECTORS; gap amy=$GOT_LOG2 ref=$WANT_LOG2"
fi
done < <(ref hints)
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "cyberspace-hints" pass "$AGREE golden vectors agree"
else
record_result "cyberspace-hints" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 7. §7.6 bags, round-tripped through the reference's cipher -------------
banner "7. bags — sealed by cyberspace-cli, opened by amy"
# The strongest statement this harness can make about §7: a ciphertext nobody
# here produced. cyberspace-cli derives the region key with its own modules,
# seals a plaintext with its own AES-GCM and writes the §8.6 tags with its own
# event builder; amy is handed nothing but that event and a coordinate, and has
# to arrive at the same 32 bytes to read it. Every link in §2.2 -> §4.7 -> §7.2
# -> §7.6 is inside that one assertion.
if [[ $HAVE_AVATAR_REF -eq 0 ]]; then
skip_msg "cyberspace-cli unavailable"
record_result "cyberspace-bags" skip "no cyberspace-cli checkout"
else
AGREE=0; DISAGREE=0
for COORD in \
c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940 \
a4b64924924924924924924924924924924924924924924924924d84b60d9c8f
do
for H in 0 4; do
PLAIN="a bag at ${COORD:0:8} height $H"
SEALED="$(printf '%s' "$PLAIN" | ref encrypt "$COORD" "$H")"
BAG="$(jq -c .event <<<"$SEALED")"
# Opened from the coordinate alone: our decode, our Cantor roots, our
# §7.2 derivation, their ciphertext.
GOT="$(printf '%s' "$BAG" | amy cyberspace open - --coord "$COORD")"
OPENED="$(jq -r .opened <<<"$GOT")"
TEXT="$(jq -r '.text // ""' <<<"$GOT")"
# And the negative §7.6 insists on: the wrong key is a verdict, not an
# error. "A failed decryption therefore means only that the reader does
# not hold this region's key; it MUST NOT be treated as an error."
WRONG="$(printf '%s' "$BAG" | amy cyberspace open - --key "$(printf 'ab%.0s' $(seq 32))")"
WRONG_EXIT=$?
WRONG_OPENED="$(jq -r '.opened' <<<"$WRONG")"
if [[ "$OPENED" == "true" && "$TEXT" == "$PLAIN" && "$WRONG_OPENED" == "false" && $WRONG_EXIT -eq 0 ]]; then
AGREE=$((AGREE+1))
info "${COORD:0:8}… h$H: opened their ciphertext from the coordinate"
else
DISAGREE=$((DISAGREE+1))
fail_msg "${COORD:0:8}… h$H: opened=$OPENED text='$TEXT' want='$PLAIN'; wrong-key opened=$WRONG_OPENED exit=$WRONG_EXIT"
fi
done
done
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "cyberspace-bags" pass "$AGREE bags sealed there, opened here"
else
record_result "cyberspace-bags" fail "$DISAGREE of $((AGREE+DISAGREE)) failed to round-trip"
fi
fi
# ---- 8. §7.7 sweeps ---------------------------------------------------------
banner "8. sweeps — finding a bag from its hint box alone"
# The same bag, this time without being told where it is. amy gets the box and
# the bag's height, derives every candidate region key inside it and looks for
# the one whose lookup_id is the `d` tag the hider published — §7.7's
# position-free search, end to end. The box itself comes from the reference's
# interleave, so a disagreement about alignment shows up as a bag that is not
# in the box rather than as a test grading its own arithmetic.
if [[ $HAVE_AVATAR_REF -eq 0 ]]; then
skip_msg "cyberspace-cli unavailable"
record_result "cyberspace-sweeps" skip "no cyberspace-cli checkout"
else
AGREE=0; DISAGREE=0
COORD=c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940
SEALED="$(printf 'found by sweeping' | ref encrypt "$COORD" 4)"
WANT_KEY="$(jq -r .key <<<"$SEALED")"
# A cube (gap 6), a slab with one axis pinned to the bag's own height (gap 4),
# and the degenerate box that is a destination rather than a search (gap 0).
for BOX in "6 6 6" "4 6 6" "4 4 4"; do
# shellcheck disable=SC2086
HINT="$(ref box "$COORD" $BOX | jq -c .tag)"
BAG="$(jq -c --argjson hint "$HINT" '.event | .tags += [$hint]' <<<"$SEALED")"
GOT="$(printf '%s' "$BAG" | amy cyberspace sweep -)"
FOUND="$(jq -r .found <<<"$GOT")"
GOT_KEY="$(jq -r '.key // ""' <<<"$GOT")"
EXAMINED="$(jq -r .examined <<<"$GOT")"
CANDIDATES="$(jq -r .candidates <<<"$GOT")"
# A sweep that found it must not have walked past the box it was given.
if [[ "$FOUND" == "true" && "$GOT_KEY" == "$WANT_KEY" && "$EXAMINED" -le "$CANDIDATES" ]]; then
AGREE=$((AGREE+1))
info "box [$BOX]: found after $EXAMINED of $CANDIDATES candidates"
else
DISAGREE=$((DISAGREE+1))
fail_msg "box [$BOX]: found=$FOUND key=${GOT_KEY:0:16} want=${WANT_KEY:0:16} examined=$EXAMINED/$CANDIDATES"
fi
done
# And the refusal §7.7 exists for: a hint is a stranger's choice of
# difficulty, so a box nobody asked to pay for is quoted and declined rather
# than swept. 2^33 keys is hours.
HUGE="$(ref box "$COORD" 15 15 15 | jq -c .tag)"
BAG="$(jq -c --argjson hint "$HUGE" '.event | .tags += [$hint]' <<<"$SEALED")"
if printf '%s' "$BAG" | amy cyberspace sweep - >/dev/null 2>&1; then
DISAGREE=$((DISAGREE+1))
fail_msg "a gap-33 hint was swept without being asked for"
else
AGREE=$((AGREE+1))
info "a gap-33 hint is quoted and declined, not swept"
fi
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "cyberspace-sweeps" pass "$AGREE boxes swept as §7.7 describes"
else
record_result "cyberspace-sweeps" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
print_summary
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
exit 0
+3 -1
View File
@@ -89,6 +89,8 @@ in `commonsUI`, under the same package.
| `account` | mixed | New-account bootstrap events; the logged-off login/sign-up buttons in `commonsUI` `account/ui/login` and `account/ui/signup`. |
| `onchain` | mixed | On-chain zap splitting/broadcasting; user-facing failure strings in `commonsUI` `onchain/ui`. |
| `marmot` | mixed | MLS group-chat event processing; group-chat composables (retention picker, agent stream banner) in `commonsUI` `marmot/ui`. |
| `cyberspace` | no | `CYBERSPACE_V2` §7.7 region-bag search: the free quote off a bag's `hint`/`h` tags, the device-measured budget, and the cold sweep flow over quartz's `RegionSweep`. The protocol itself (coordinates, Cantor trees, keys, the bag) is `quartz/.../cyberspace`. |
| `sno` | mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in `commonsUI` under `sno` and `sno/ui`. |
| `nip53LiveActivities` | mixed | Live-activity zapper aggregation (logic) + the stream card in `nip53LiveActivities/ui`. |
| `search` | no | Event search filtering/ranking, kind registry. |
| `preview` | no | OpenGraph / meta-tag link-preview parsing. |
@@ -140,7 +142,7 @@ type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`.
| `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. |
| `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. |
| `chats` | yes | Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat *models* are in `model/chats`. |
| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `ephemChat`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `<feature>/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. |
| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip32Labeling`, `nip43RelayMembers`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `nipsOnNostr`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `<feature>/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. |
### Mixed (documented debt — see §4)
| Package | UI? | Purpose |
@@ -0,0 +1,272 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.cyberspace
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceHint
import com.vitorpamplona.quartz.cyberspace.RegionSweep
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.TimeSource
/**
* What a bag's `hint` tag promises, before anything is spent on it.
*
* The whole quote is arithmetic on two tags — §7.7's three heights and §8.6's
* `h` — so a card can carry it without building a single Cantor tree. That is
* deliberate: a feed may scroll past a bag whose hider chose a gap of 75, and
* the reader has to be able to say "out of reach" without having taken a
* stranger's dare first.
*/
@Immutable
sealed class BagSweepQuote {
/** A search this reader is willing to offer, and how big it is. */
@Immutable
data class Searchable(
val gapBits: Int,
val candidates: Long,
/**
* §7.7: three heights equal to the bag's "name the region itself: the
* hint is then a destination the seeker can compute or walk to
* directly, not a search".
*
* It is still a tap. A reader who learns that some bags open themselves
* has learned something a hostile bag can hide inside, so the one
* candidate is offered exactly like the million.
*/
val destination: Boolean,
) : BagSweepQuote()
/**
* A hint whose box is past what this client sweeps, with the gap that
* decided it — so the card can say how far past, rather than only that it
* is. §7.7's own table runs from "seconds" to "days to never", and the far
* end is where a sector-only hint on a shallow bag lands: a gap of 75,
* which nobody will ever sweep. Such a hint "says where to travel, not
* where to search", and Amethyst cannot travel.
*/
@Immutable
data class OutOfReach(
val gapBits: Int,
) : BagSweepQuote()
/**
* Nothing to search from: no `hint` tag, a malformed one (§7.7 says those
* are the same thing), or no `d` tag to recognise the region by.
*
* Not an error in the bag. It is content hidden the hard way, and §7.7 is
* clear about what that means: "any given bag is equally likely to be at
* any point in the full 2^256 coordinate space".
*/
@Immutable
data object Hidden : BagSweepQuote()
}
/** Where a sweep has got to, for a card that has to show progress and a cancel. */
@Immutable
sealed class BagSweepState {
/** Pricing the first candidate on this device, before committing to the rest. */
@Immutable
data object Measuring : BagSweepState()
@Immutable
data class Running(
val examined: Long,
val candidates: Long,
) : BagSweepState()
/**
* The region key came up, and the bag opened.
*
* [contents] is null when it did not, which at this point means the
* ciphertext is damaged rather than that the key is wrong — the `lookup_id`
* already matched, and §7.2 makes that a hash of this very key.
*/
@Immutable
data class Opened(
val contents: CyberspaceBagContents?,
) : BagSweepState()
/** The whole box was swept and the bag was not in it: the hint was wrong, or it was bait. */
@Immutable
data class NotFound(
val examined: Long,
) : BagSweepState()
/**
* Measured, not guessed: the first candidate cost [millisPerCandidate] on
* *this* device, so the box would take [estimateMillis], which is past what
* this client spends without being asked again.
*/
@Immutable
data class OutOfReach(
val estimateMillis: Long,
val millisPerCandidate: Long,
) : BagSweepState()
}
/**
* §7.7's hint-and-sweep, priced for a reader with a battery.
*
* The protocol half is [RegionSweep]; this is the budget around it, and the
* budget is the entire product decision. A hint is a stranger's declaration of
* how hard they want the search to be, so everything here is arranged so the
* reader finds out the price before paying it:
*
* 1. [quote] reads the two tags and costs nothing. A box past [MAX_GAP_BITS] or
* a bag deeper than [MAX_BAG_HEIGHT] is out of reach and never becomes a
* button.
* 2. [sweep] times the first candidate on the device it is actually running on
* before committing to the rest, and stops if that measurement says the box
* is past [BUDGET_MILLIS]. The plan for this feature said to measure the
* Android cost before quoting a number in the UI; measuring it at the moment
* of the tap is the same answer without a constant that goes stale on the
* next handset.
* 3. The sweep is a cold [Flow] over a cold [Sequence], so a cancelled
* collection stops paying immediately.
*
* Nothing here talks to a relay, because the bag is already in hand: this is
* §7.7's search applied to one event someone put in front of you, not a crawl.
* And nothing starts on its own — [sweep] runs when it is collected.
*/
object BagSweep {
/**
* The largest box offered as a button, as §7.7's gap exponent.
*
* 2^20 is about a million candidate regions. §7.7's own table calls 12
* "seconds" and 24 "hours"; this sits between them, at the scale a phone
* can finish while someone watches. Past it the card says so instead, which
* is the honest answer to a hint that was chosen to be expensive.
*/
const val MAX_GAP_BITS = 20
/**
* The deepest bag this offers to search.
*
* Not about the box but about a single key: a region key is three folds of
* integers that double in width at every level, so one candidate at height
* 16 is seconds on its own and one at height 20 is minutes. ONOSENDAI draws
* the same line, capping its own discovery scan at height 12 and selling
* the deeper ones as a service.
*/
const val MAX_BAG_HEIGHT = 12
/**
* How long a sweep may be expected to take before it is refused outright.
*
* Two minutes is where §7.7's "seconds" has clearly ended, and it is
* checked against a measurement from this device rather than a table, so a
* slower phone refuses boxes a faster one accepts. That asymmetry is
* correct: the cost is the reader's, so the reader's own hardware decides.
*/
const val BUDGET_MILLIS = 120_000L
/** How many candidates pass between progress emissions. */
private const val PROGRESS_EVERY = 64L
/**
* What this bag's hint promises, from its tags alone.
*
* Costs two subtractions and an addition — safe to call while composing a
* feed row, and deliberately so, because the card has to be able to show
* the price of a search it will not run.
*/
fun quote(bag: CyberspaceBagEvent): BagSweepQuote {
if (!bag.isKnownVersion()) return BagSweepQuote.Hidden
if (bag.lookupId() == null) return BagSweepQuote.Hidden
if (bag.payload() == null) return BagSweepQuote.Hidden
val height = bag.height() ?: return BagSweepQuote.Hidden
val hint = bag.hint() ?: return BagSweepQuote.Hidden
val gap = hint.gapBits(height)
if (height > MAX_BAG_HEIGHT || gap > MAX_GAP_BITS) return BagSweepQuote.OutOfReach(gap)
val candidates = hint.candidates(height) ?: return BagSweepQuote.OutOfReach(gap)
return BagSweepQuote.Searchable(gap, candidates, hint.isDestination(height))
}
/**
* Sweep this bag's box, emitting progress, and open it if the key turns up.
*
* Cold: nothing runs until collected, and cancelling the collection stops
* the work at the next candidate. Collect it off the main thread — the
* arithmetic is arbitrary-precision and the whole point is that it is slow.
*/
fun sweep(bag: CyberspaceBagEvent): Flow<BagSweepState> =
flow {
val quote = quote(bag)
if (quote !is BagSweepQuote.Searchable) {
// Priced and declined before a tree was built. There is nothing
// to emit that the card did not already know from [quote].
return@flow
}
val height = bag.height() ?: return@flow
val hint = bag.hint() ?: return@flow
val target = bag.lookupId() ?: return@flow
emit(BagSweepState.Measuring)
// The box's own base region, priced on the way past. A gap-0 hint
// names exactly this one, so the measurement is never wasted work:
// it is the first candidate either way.
val mark = TimeSource.Monotonic.markNow()
val base = RegionSweep.of(CyberspaceHint(hint.base, height, height, height), height).first()
val perCandidate = mark.elapsedNow().inWholeMilliseconds
if (base.lookupId == target) {
emit(BagSweepState.Opened(bag.open(base.decryptionKey)))
return@flow
}
// One candidate here is three axis roots and a combine; every
// candidate after it is one combine, because §4.7's axes are reused
// across the box. So this over-quotes — by about four times at the
// shallow heights and two at the deep ones — and over-quoting is
// the safe direction for a number whose only job is to decide
// whether to spend somebody's battery.
val estimate = perCandidate * quote.candidates
if (estimate > BUDGET_MILLIS) {
emit(BagSweepState.OutOfReach(estimate, perCandidate))
return@flow
}
var examined = 0L
emit(BagSweepState.Running(examined, quote.candidates))
for (material in RegionSweep.of(hint, height)) {
currentCoroutineContext().ensureActive()
examined++
if (material.lookupId == target) {
emit(BagSweepState.Opened(bag.open(material.decryptionKey)))
return@flow
}
if (examined % PROGRESS_EVERY == 0L) emit(BagSweepState.Running(examined, quote.candidates))
}
emit(BagSweepState.NotFound(examined))
}
}
@@ -139,6 +139,10 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -3836,6 +3840,9 @@ open class EventCache :
is GeocacheListingEvent,
is GeocacheCurationListEvent,
is GeohashListEvent,
is SnoObjectEvent,
is SnoAvatarEvent,
is CyberspaceBagEvent,
is GitRepositoryEvent,
is GitRepositoryStateEvent,
is UserGraspListEvent,
@@ -3964,6 +3971,7 @@ open class EventCache :
is GitPullRequestEvent,
is GitPullRequestUpdateEvent,
is GitStatusEvent,
is SnoShardEvent,
is ChessGameEvent,
is JesterEvent,
is HighlightEvent,
@@ -0,0 +1,186 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
/**
* The shape an avatar has when its author has not given it one.
*
* `CYBERSPACE_V2.md` §8.10 makes a `kind 11333` with empty content the default
* avatar: it carries no geometry, owes no proof of work, and is what everyone
* starts as. Drawing nothing for it is technically correct and tells a reader
* nothing — the note simply vanishes from the feed, which reads as a bug. The
* reference puts a wireframe icosahedron in its place wherever an avatar is
* drawn (`scene/AvatarShape.tsx`), so this is that icosahedron, built as a
* payload so it goes through the same renderer as everything else and needs no
* second drawing path.
*
* **Not an event and not from the wire.** Nothing here was published by
* anybody, so §1.2's obligation to keep the lattice exact does not apply to how
* it was derived: the twelve corners of an icosahedron are `(0, ±1, ±φ)`
* and its rotations, `φ` is irrational, and 194 ticks is the nearest the
* lattice comes to `φ` units. What §1.2 does still govern is everything
* downstream, and once these are ticks they are as exact as any other object's.
*
* `lines`, because the reference draws the edges rather than the solid: a
* placeholder should not look like something somebody made.
*/
object SnoDefaultAvatar {
/** One unit, in ticks — the icosahedron's short radius. */
private const val ONE = SnoPayload.TICKS_PER_UNIT
/** The golden ratio in ticks, to the nearest one: `1.618034 * 120`. */
private const val PHI = 194
private const val WHITE = 0xFFFFFFFF.toInt()
val payload: SnoPayload by lazy { build() }
private fun build(): SnoPayload {
// The three mutually perpendicular golden rectangles whose corners are
// an icosahedron: (0, ±1, ±φ) and its two cyclic rotations.
val positions =
intArrayOf(
0,
ONE,
PHI,
0,
ONE,
-PHI,
0,
-ONE,
PHI,
0,
-ONE,
-PHI,
ONE,
PHI,
0,
ONE,
-PHI,
0,
-ONE,
PHI,
0,
-ONE,
-PHI,
0,
PHI,
0,
ONE,
-PHI,
0,
ONE,
PHI,
0,
-ONE,
-PHI,
0,
-ONE,
)
// The twenty faces: every triple of corners that are one edge apart,
// each wound so its normal points out of the solid. Derived rather than
// remembered — a hand-written icosahedron comes out looking almost
// right, and the test next door is what says whether it did.
val faces =
intArrayOf(
0,
2,
8,
0,
9,
2,
0,
4,
6,
0,
8,
4,
0,
6,
9,
1,
10,
3,
1,
3,
11,
1,
6,
4,
1,
4,
10,
1,
11,
6,
2,
7,
5,
2,
5,
8,
2,
9,
7,
3,
5,
7,
3,
10,
5,
3,
7,
11,
4,
8,
10,
5,
10,
8,
6,
11,
9,
7,
9,
11,
)
return SnoPayload(
version = 2,
name = "",
// One gibson: the cell an avatar occupies, which is what the
// reference draws it at.
unit = 0,
extent = 2,
mode = SnoMode.LINES,
positions = positions,
colors = IntArray(positions.size / 3) { WHITE },
faces = faces,
faceColors = null,
paletteRef = SnoPaletteRef.BuiltIn,
up = false,
spin = 0,
)
}
}
@@ -0,0 +1,450 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.math.abs
import kotlin.math.sqrt
/**
* A copy of an object's faces wound so that every one turns its outside out,
* plus which of them are buried inside a join.
*
* Nothing the wire carries says which way round a face is: DECK-0003 §1.4 makes
* winding order meaningless and §4 draws a face two-sided, so an object whose
* triangles disagree is indistinguishable from one whose triangles agree — as
* long as nothing is lit. The moment a reader lights the object (§4 permits it,
* "and many will") the disagreement becomes the whole picture: half the faces of
* a stamped block wind inward, and a lit block with half its faces dark is not a
* block. So lighting needs this pass first.
*
* The method is the one `sno-core`'s `orient.ts` arrives at (MIT; ported, not
* copied), and it is in two halves because one is not enough:
*
* 1. **Faces that share a clean edge are made to agree.** Two consistently wound
* faces run their shared edge in opposite directions, so a face reached
* across an edge it runs the *same* way is flipped. That groups the faces
* into patches without deciding which way a patch faces. An edge carrying
* three or more faces is where solids touch and carries no agreement across
* it — passing agreement through one turns a whole side of a shape inward,
* depending only on which face the walk happened to reach first.
* 2. **Each patch is then turned as a whole by what its faces can see.** A ray
* from a face's middle along its normal crosses the rest of the object an
* even number of times when the normal points out of a closed solid and an
* odd number when it points in. The patch goes whichever way most of its
* area votes; a patch whose rays cross nothing — a flat plate, an open
* shell — falls back to its signed volume, and failing that to facing up.
*
* A face whose rays cross an odd number of faces *both* ways is inside the
* solid: the square between a block stacked on a block. It is reported in
* [interior] so a lit drawing can leave it out, where it would only fight the
* face it sits against.
*
* Positions are read straight from the integer lattice into floats here, which
* is the one conversion §1.2 allows, and the object is never mirrored on the
* way, so the handedness this decides is the handedness the rasteriser draws.
*/
class SnoFaceWinding(
/** Three vertex indices per face, rewound; the payload's own array is untouched. */
val faces: IntArray,
/** One flag per face: true when it sits inside the solid with faces on both sides. */
val interior: BooleanArray,
) {
companion object {
/** Below this a triangle has no area and its normal is noise. */
private const val EPSILON = 1e-9f
/** A ray that meets a face nearer than this started on it. */
private const val SELF = 1e-5f
/**
* How small a signed volume has to be, against the patch's area times
* its reach, before the patch counts as open rather than closed.
*/
private const val FLAT = 1e-3f
/**
* A slant added to every ray, so that on the axis-aligned shapes an
* author actually builds a ray crosses faces rather than running down
* the edge between two of them, where a hit is a coin toss.
*/
private const val SLANT_X = 0.0173f
private const val SLANT_Y = 0.0311f
private const val SLANT_Z = 0.0237f
fun of(payload: SnoPayload): SnoFaceWinding {
val faceCount = payload.faceCount
val faces = payload.faces.copyOf()
val interior = BooleanArray(faceCount)
if (faceCount == 0) return SnoFaceWinding(faces, interior)
val points = FloatArray(payload.vertexCount * 3)
for (i in points.indices) points[i] = payload.positions[i].toFloat()
val normals = FloatArray(faceCount * 3)
val areas = FloatArray(faceCount)
val forward = IntArray(faceCount)
val backward = IntArray(faceCount)
for (face in 0 until faceCount) {
measure(points, faces, face, normals, areas)
val dx = normals[face * 3] + SLANT_X
val dy = normals[face * 3 + 1] + SLANT_Y
val dz = normals[face * 3 + 2] + SLANT_Z
val mx = middle(points, faces, face, 0)
val my = middle(points, faces, face, 1)
val mz = middle(points, faces, face, 2)
forward[face] = crossings(points, faces, faceCount, face, mx, my, mz, dx, dy, dz)
backward[face] = crossings(points, faces, faceCount, face, mx, my, mz, -dx, -dy, -dz)
interior[face] = forward[face] % 2 == 1 && backward[face] % 2 == 1
}
turnPatches(points, payload.faces, faces, faceCount, areas, forward, backward, interior)
return SnoFaceWinding(faces, interior)
}
/** The face's unit normal into [normals] and half its length into [areas]. */
private fun measure(
points: FloatArray,
faces: IntArray,
face: Int,
normals: FloatArray,
areas: FloatArray,
) {
val a = faces[face * 3] * 3
val b = faces[face * 3 + 1] * 3
val c = faces[face * 3 + 2] * 3
val abx = points[b] - points[a]
val aby = points[b + 1] - points[a + 1]
val abz = points[b + 2] - points[a + 2]
val acx = points[c] - points[a]
val acy = points[c + 1] - points[a + 1]
val acz = points[c + 2] - points[a + 2]
val nx = aby * acz - abz * acy
val ny = abz * acx - abx * acz
val nz = abx * acy - aby * acx
val length = sqrt(nx * nx + ny * ny + nz * nz)
areas[face] = length * 0.5f
if (length > 0f) {
normals[face * 3] = nx / length
normals[face * 3 + 1] = ny / length
normals[face * 3 + 2] = nz / length
} else {
normals[face * 3 + 1] = 1f
}
}
private fun middle(
points: FloatArray,
faces: IntArray,
face: Int,
axis: Int,
): Float =
(
points[faces[face * 3] * 3 + axis] +
points[faces[face * 3 + 1] * 3 + axis] +
points[faces[face * 3 + 2] * 3 + axis]
) / 3f
/** How many other faces a ray from this point along this direction crosses. */
@Suppress("detekt.LongParameterList")
private fun crossings(
points: FloatArray,
faces: IntArray,
faceCount: Int,
skip: Int,
ox: Float,
oy: Float,
oz: Float,
dx: Float,
dy: Float,
dz: Float,
): Int {
var hits = 0
for (face in 0 until faceCount) {
if (face == skip) continue
if (hit(points, faces, face, ox, oy, oz, dx, dy, dz)) hits++
}
return hits
}
/**
* Möller–Trumbore: whether the ray meets this face ahead of its start.
*/
@Suppress("detekt.LongParameterList")
private fun hit(
points: FloatArray,
faces: IntArray,
face: Int,
ox: Float,
oy: Float,
oz: Float,
dx: Float,
dy: Float,
dz: Float,
): Boolean {
val a = faces[face * 3] * 3
val b = faces[face * 3 + 1] * 3
val c = faces[face * 3 + 2] * 3
val e1x = points[b] - points[a]
val e1y = points[b + 1] - points[a + 1]
val e1z = points[b + 2] - points[a + 2]
val e2x = points[c] - points[a]
val e2y = points[c + 1] - points[a + 1]
val e2z = points[c + 2] - points[a + 2]
val px = dy * e2z - dz * e2y
val py = dz * e2x - dx * e2z
val pz = dx * e2y - dy * e2x
val determinant = e1x * px + e1y * py + e1z * pz
if (abs(determinant) < EPSILON) return false
val inverse = 1f / determinant
val tx = ox - points[a]
val ty = oy - points[a + 1]
val tz = oz - points[a + 2]
val u = (tx * px + ty * py + tz * pz) * inverse
if (u < 0f || u > 1f) return false
val qx = ty * e1z - tz * e1y
val qy = tz * e1x - tx * e1z
val qz = tx * e1y - ty * e1x
val v = (dx * qx + dy * qy + dz * qz) * inverse
if (v < 0f || u + v > 1f) return false
return (e2x * qx + e2y * qy + e2z * qz) * inverse > SELF
}
/**
* Group the faces into patches across their clean edges, then turn each
* patch as a whole.
*/
@Suppress("detekt.LongParameterList")
private fun turnPatches(
points: FloatArray,
original: IntArray,
faces: IntArray,
faceCount: Int,
areas: FloatArray,
forward: IntArray,
backward: IntArray,
interior: BooleanArray,
) {
val byEdge = HashMap<Long, MutableList<Int>>(faceCount * 3)
for (face in 0 until faceCount) {
forEachEdge(faces, face) { from, to ->
byEdge.getOrPut(edgeKey(from, to)) { ArrayList(2) }.add(face)
}
}
val seen = BooleanArray(faceCount)
val patch = ArrayList<Int>()
val queue = ArrayDeque<Int>()
for (start in 0 until faceCount) {
if (seen[start]) continue
seen[start] = true
patch.clear()
queue.clear()
queue.addLast(start)
while (queue.isNotEmpty()) {
val face = queue.removeFirst()
patch.add(face)
forEachEdge(faces, face) { from, to ->
val on = byEdge[edgeKey(from, to)]
// Only an edge with exactly two faces on it carries
// agreement; three or more is where solids touch.
if (on != null && on.size == 2) {
for (other in on) {
if (other == face || seen[other]) continue
// Two faces that agree run a shared edge in
// opposite directions, so one that runs it the
// same way is turned over.
if (runsEdge(faces, other, from, to)) flip(faces, other)
seen[other] = true
queue.addLast(other)
}
}
}
}
if (facesInward(points, original, faces, patch, areas, forward, backward, interior)) {
for (face in patch) flip(faces, face)
}
}
}
/**
* Whether this patch, as the walk left it, has its back to the outside.
*
* A face's crossings were counted as it was wound to begin with, so a
* face the walk turned over votes the other way from how it was
* counted; the votes are weighted by area, since a patch is decided by
* most of its surface rather than most of its triangles.
*/
@Suppress("detekt.LongParameterList")
private fun facesInward(
points: FloatArray,
original: IntArray,
faces: IntArray,
patch: List<Int>,
areas: FloatArray,
forward: IntArray,
backward: IntArray,
interior: BooleanArray,
): Boolean {
var vote = 0f
for (face in patch) {
if (interior[face]) continue
val turned = if (runsEdge(faces, face, original[face * 3], original[face * 3 + 1])) 1f else -1f
val outward =
if (forward[face] % 2 == 0 && backward[face] % 2 == 1) {
1f
} else if (forward[face] % 2 == 1 && backward[face] % 2 == 0) {
-1f
} else {
0f
}
vote += turned * outward * areas[face]
}
if (vote != 0f) return vote < 0f
// Nothing to see: closed by its own volume, or flat and facing up.
//
// The volume is summed about the patch's own middle rather than
// about the origin. A lattice runs to 7680 ticks out and the terms
// of this sum are cubes of that, so a flat plate far from the
// origin leaves a cancellation residue in a float larger than any
// absolute threshold could tell from a real volume; measured about
// the middle, the terms are the size of the patch and the residue
// goes with them. A closed surface's signed volume does not depend
// on where it is measured from, so nothing is given up.
var middleX = 0f
var middleY = 0f
var middleZ = 0f
for (face in patch) {
for (corner in 0..2) {
val v = faces[face * 3 + corner] * 3
middleX += points[v]
middleY += points[v + 1]
middleZ += points[v + 2]
}
}
val corners = patch.size * 3
middleX /= corners
middleY /= corners
middleZ /= corners
var volume = 0f
var nx = 0f
var ny = 0f
var nz = 0f
var reach = 0f
var twiceArea = 0f
for (face in patch) {
val a = faces[face * 3] * 3
val b = faces[face * 3 + 1] * 3
val c = faces[face * 3 + 2] * 3
val aX = points[a] - middleX
val aY = points[a + 1] - middleY
val aZ = points[a + 2] - middleZ
val bX = points[b] - middleX
val bY = points[b + 1] - middleY
val bZ = points[b + 2] - middleZ
val cX = points[c] - middleX
val cY = points[c + 1] - middleY
val cZ = points[c + 2] - middleZ
volume += (aX * (bY * cZ - bZ * cY) - aY * (bX * cZ - bZ * cX) + aZ * (bX * cY - bY * cX)) / 6f
val abx = bX - aX
val aby = bY - aY
val abz = bZ - aZ
val acx = cX - aX
val acy = cY - aY
val acz = cZ - aZ
val fx = aby * acz - abz * acy
val fy = abz * acx - abx * acz
val fz = abx * acy - aby * acx
nx += fx
ny += fy
nz += fz
twiceArea += sqrt(fx * fx + fy * fy + fz * fz)
reach = maxOf(reach, abs(aX), abs(aY), abs(aZ))
}
// A real volume is the patch's area times its reach; what survives
// the cancellation in an open one is that times float's own
// precision. The threshold sits between the two, far from both.
if (abs(volume) > FLAT * twiceArea * reach) return volume < 0f
val ax = abs(nx)
val ay = abs(ny)
val az = abs(nz)
return if (ay >= ax && ay >= az) {
ny < 0f
} else if (ax >= az) {
nx < 0f
} else {
nz < 0f
}
}
private inline fun forEachEdge(
faces: IntArray,
face: Int,
action: (Int, Int) -> Unit,
) {
val a = faces[face * 3]
val b = faces[face * 3 + 1]
val c = faces[face * 3 + 2]
action(a, b)
action(b, c)
action(c, a)
}
/** Whether this face runs the edge from -> to in that direction. */
private fun runsEdge(
faces: IntArray,
face: Int,
from: Int,
to: Int,
): Boolean {
val a = faces[face * 3]
val b = faces[face * 3 + 1]
val c = faces[face * 3 + 2]
return (a == from && b == to) || (b == from && c == to) || (c == from && a == to)
}
private fun flip(
faces: IntArray,
face: Int,
) {
val swap = faces[face * 3 + 1]
faces[face * 3 + 1] = faces[face * 3 + 2]
faces[face * 3 + 2] = swap
}
/** An undirected edge as one number; vertex indices are below 512. */
private fun edgeKey(
a: Int,
b: Int,
): Long {
val low = if (a < b) a else b
val high = if (a < b) b else a
return (low.toLong() shl 32) or high.toLong()
}
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.math.sqrt
/**
* How much light falls on each side of each face of an object, for the
* inspection drawing.
*
* DECK-0003 §4 makes **unlit** the default reading of an SNO and that is what a
* feed shows, so an object looks the same everywhere it is quoted. The same
* section then says a client "MAY light an object instead, and many will,
* because a lit object sits better in a lit scene", on two conditions: normals
* are per face and flat, from the triangle's own vertices, and no reader
* synthesises smooth ones by averaging across a shared vertex. Both are met
* here — there is one number per face and it comes from that face's cross
* product — and no vertex moves, so §4's "MUST NOT invent geometry" is intact.
*
* This is the shading the reference workshop's bench uses while a shard is
* being built, and it exists for the same reason: unlit, a cube of one colour
* is a flat hexagon, and an object you are turning over to understand is the
* one case where the light is the information. Two things make that legible:
*
* - **Every face is wound outward first** ([SnoFaceWinding]), because half the
* triangles of a stamped block wind inward and lighting them by the normal
* they arrived with would make a solid block half black.
* - **The inside of a face is drawn darker** ([INSIDE]), lit by its own reversed
* normal, so an open shape shows that it is open and a missing face reads as
* a hole rather than as a slightly odd colour.
*
* Nothing here depends on the viewing angle: the lights are fixed to the object
* the way the bench's are fixed in its world, so turning the object moves the
* light across it — which is the whole point — and the shade of a given face is
* computed once and reused for every frame of a turn.
*/
class SnoLighting(
/** Three vertex indices per face, wound outward. */
val faces: IntArray,
/** Faces buried inside a join, which the drawing leaves out. */
val interior: BooleanArray,
/** What to multiply a face's colour by when its outside is the side you see. */
val outside: FloatArray,
/** The same for its inside, already darkened by [INSIDE]. */
val inside: FloatArray,
) {
companion object {
/**
* The bench's three lights — `ambientLight 0.4`, a key at `0.9` and a
* dim fill from behind at `0.6` — divided by the 1.3 a face square-on
* to the key receives, so that face shows its own colour exactly and
* no face anywhere on the sphere clips to white. The brightest a normal
* can reach with both lights is 0.948, the dimmest 0.308.
*/
private const val AMBIENT = 0.4f / 1.3f
private const val KEY = 0.9f / 1.3f
private const val FILL = 0.6f / 1.3f
/**
* The reference's two light positions carried over as directions, not
* as coordinates: they are stated in the bench's render space and
* relative to *its* opening camera, so what is reproduced here is where
* they sit relative to the view — the key a little above the line of
* sight and slightly to the right, the fill high behind the left
* shoulder — planted in model space at this renderer's own opening
* angle ([SnoRasterizer.DEFAULT_YAW_DEGREES], `DEFAULT_PITCH_DEGREES`).
* Copying the raw positions instead would have put the key behind the
* object, because the two viewers do not open on the same side of it.
*/
private val KEY_DIRECTION = floatArrayOf(-0.3667f, 0.0175f, 0.9302f)
private val FILL_DIRECTION = floatArrayOf(-0.2573f, 0.7696f, -0.5844f)
/**
* How much of its colour a face keeps when you are looking at its
* inside: the `#6a6a6a` the reference multiplies the inside mesh by.
*/
private const val INSIDE = 0x6A / 255f
fun of(payload: SnoPayload): SnoLighting {
val winding = SnoFaceWinding.of(payload)
val faceCount = payload.faceCount
val outside = FloatArray(faceCount)
val inside = FloatArray(faceCount)
for (face in 0 until faceCount) {
val a = winding.faces[face * 3] * 3
val b = winding.faces[face * 3 + 1] * 3
val c = winding.faces[face * 3 + 2] * 3
// The one conversion from the exact lattice to floats (§1.2),
// and flat per face as §4 requires: this triangle's own corners
// and nothing averaged in from a neighbour.
val abx = (payload.positions[b] - payload.positions[a]).toFloat()
val aby = (payload.positions[b + 1] - payload.positions[a + 1]).toFloat()
val abz = (payload.positions[b + 2] - payload.positions[a + 2]).toFloat()
val acx = (payload.positions[c] - payload.positions[a]).toFloat()
val acy = (payload.positions[c + 1] - payload.positions[a + 1]).toFloat()
val acz = (payload.positions[c + 2] - payload.positions[a + 2]).toFloat()
var nx = aby * acz - abz * acy
var ny = abz * acx - abx * acz
var nz = abx * acy - aby * acx
val length = sqrt(nx * nx + ny * ny + nz * nz)
if (length > 0f) {
nx /= length
ny /= length
nz /= length
} else {
ny = 1f
}
outside[face] = shade(nx, ny, nz)
inside[face] = shade(-nx, -ny, -nz) * INSIDE
}
return SnoLighting(winding.faces, winding.interior, outside, inside)
}
/** Lambert: the ambient, plus each light by how squarely the face meets it. */
private fun shade(
nx: Float,
ny: Float,
nz: Float,
): Float {
val key = nx * KEY_DIRECTION[0] + ny * KEY_DIRECTION[1] + nz * KEY_DIRECTION[2]
val fill = nx * FILL_DIRECTION[0] + ny * FILL_DIRECTION[1] + nz * FILL_DIRECTION[2]
val light = AMBIENT + KEY * maxOf(key, 0f) + FILL * maxOf(fill, 0f)
return if (light > 1f) 1f else light
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
/**
* The pixel and depth buffers of a view that draws the same object over and over.
*
* [SnoRasterizer.render] needs a pixel buffer and a depth buffer the size of the
* raster, and on its own it allocates both every call. That is the right thing
* for a thumbnail, which draws once. It is the wrong thing for a turn: at 384px
* the two come to 1.2 MB, a drag asks for about thirty-five frames a second, and
* on an SM-T220 that 41 MB/s of garbage took 51% of a core and stuttered the
* turn every few seconds — while the same screen, held still, spent none.
*
* A viewer rasters at one size for the whole gesture, so it can hand the same
* two buffers back on every frame and allocate nothing. The buffers grow to
* whatever size is asked for and are kept until it changes.
*
* **Handed out, not copied, so one holder must never serve two renders at once.**
* A caller that can overlap its frames has to serialise them; `SnoObjectViewer`
* conflates its angles through a single collector for exactly this reason. Where
* a render stands alone, pass nothing and let it allocate.
*/
class SnoRasterScratch {
private var pixels: IntArray = EMPTY_PIXELS
private var depth: FloatArray = EMPTY_DEPTH
internal fun pixels(size: Int): IntArray {
if (pixels.size != size) pixels = IntArray(size)
return pixels
}
internal fun depth(size: Int): FloatArray {
if (depth.size != size) depth = FloatArray(size)
return depth
}
private companion object {
private val EMPTY_PIXELS = IntArray(0)
private val EMPTY_DEPTH = FloatArray(0)
}
}
@@ -0,0 +1,705 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Draws a Simple Nostr Object into a pixel buffer, on the CPU.
*
* Software rather than `Canvas.drawVertices`, which is common Compose API and
* would give interpolated triangles for free, because that call is only
* hardware-accelerated from API 29 and Amethyst's `minSdk` is 26 — on Android 8
* and 9 it silently draws nothing. Doing it here also buys a real depth buffer,
* which a painter's-algorithm fallback does not have and which lattice geometry
* needs the moment two objects are authored to share an edge, and it makes the
* whole renderer testable headless, with no device and no screenshot harness.
*
* The shading is what DECK-0003 §4 decides, so that the same object does not
* look like two objects:
* - **Unlit.** A face takes its colour by interpolating its three vertices and
* no light in the scene changes it. This is `KHR_materials_unlit` with a
* `COLOR_0` attribute, which is the one-sentence bridge to anyone in glTF.
* - **Two-sided.** A face has no front and no back: winding order carries no
* meaning and nothing is culled by it.
* - **A face that carries its own colour is filled with it flatly**, and there
* is nothing to interpolate or average: the seam between two such faces is
* exact, which is the whole purpose of `facecolors`.
* - **No invented geometry.** No subdivision, no smoothing that moves a vertex,
* no hole filling, and no synthesised smooth normals. The lattice is exact and
* a renderer that moves a vertex has broken the one guarantee it makes.
*
* §4 also permits a client to light an object instead, which is what the
* `lighting` argument does; see [SnoLighting] for what that changes and why it
* is offered only where a reader is inspecting one object rather than
* scrolling past many.
*
* Positions arrive as exact integers and are converted to floats here, at the
* boundary, and nowhere earlier (§1.2).
*/
object SnoRasterizer {
/** A three-quarter view, which is what shows a small object's shape best. */
const val DEFAULT_YAW_DEGREES = 30f
const val DEFAULT_PITCH_DEGREES = -20f
/** Fraction of the shorter side left empty around the object. */
private const val MARGIN = 0.08f
/**
* Below this many pixels across, an object is too small for a triangle to
* land, and its vertices go back to being the whole drawing (§1.5).
*/
private const val TOO_SMALL_FOR_TRIANGLES = 3f
/**
* §1.5: "A client SHOULD also draw the vertices as points in every mode, so
* that an object remains visible when it is smaller on screen than a
* triangle." The reference viewer keeps two profiles for that and so does
* this one (ONOSENDAI `scene/pointDisc.ts`): where the vertices *are* the
* shape they carry its size, and under a solid or a wireframe they are a
* hint that they are there and must never become a second shape competing
* with the faces. Each diameter is a length in model units, clamped in
* pixels at both ends, so a vertex is dust on a large object and still a
* dot on a tiny one.
*/
private const val SHAPE_POINT_UNITS = 0.3f
private const val SHAPE_POINT_MIN_PX = 1.6f
private const val SHAPE_POINT_MAX_PX = 10f
private const val HINT_DOT_UNITS = 0.07f
private const val HINT_DOT_MIN_PX = 0.7f
private const val HINT_DOT_MAX_PX = 2.4f
/** How solid a hint dot is over the face it sits on. */
private const val HINT_DOT_ALPHA = 0.55f
/**
* Ticks of slack a vertex dot gets against the depth buffer.
*
* A vertex lies exactly on the surface of every face that meets there, and
* whether the triangle's interpolated depth at that pixel lands a hair in
* front of the vertex or a hair behind it is a matter of float rounding. A
* 240th of a unit of bias settles it the only way that is ever wanted, and
* is far below a pixel at any raster size this draws at.
*/
private const val DOT_DEPTH_BIAS = 0.5f
private const val TRANSPARENT = 0
/** Below this a triangle has no area to fill and its reciprocal is noise. */
private const val MIN_AREA = 1e-6f
/** Light on a face when the object is drawn unlit: all of it, unchanged. */
private const val UNLIT = 1f
/**
* @param background an opaque ARGB fill, or 0 for a transparent buffer.
* @param lighting the light falling on each face, which also carries the
* faces wound outward; `null` for §4's default unlit reading.
* @param scratch buffers to draw into instead of allocating a pair, for a
* caller that draws the same object many times over; see [SnoRasterScratch]
* for why a turn wants this and why one holder serves one render at a time.
* With a scratch the returned array is the holder's own and is overwritten
* by the next render, so a caller that keeps the pixels must copy them —
* both `PlatformImage.create` implementations do.
* @return `width * height` ARGB pixels, row-major.
*/
@Suppress("detekt.LongParameterList")
fun render(
payload: SnoPayload,
width: Int,
height: Int,
yawDegrees: Float = DEFAULT_YAW_DEGREES,
pitchDegrees: Float = DEFAULT_PITCH_DEGREES,
background: Int = TRANSPARENT,
lighting: SnoLighting? = null,
scratch: SnoRasterScratch? = null,
): IntArray {
require(width > 0 && height > 0) { "a raster needs a positive size" }
val area = width * height
// A fresh array is cheaper to fill than a reused one — allocation hands
// back zeroed pages — so the lambda form stays where nothing is reused.
val pixels = scratch?.pixels(area)?.also { it.fill(background) } ?: IntArray(area) { background }
if (payload.vertexCount == 0) return pixels
val depth =
scratch?.depth(area)?.also { it.fill(Float.NEGATIVE_INFINITY) }
?: FloatArray(area) { Float.NEGATIVE_INFINITY }
val screen = project(payload, width, height, yawDegrees, pitchDegrees)
val drawTriangles = payload.mode == SnoMode.SOLID && payload.faceCount > 0
if (drawTriangles) {
drawFaces(payload, screen, pixels, depth, width, height, lighting)
}
val drawLines = payload.mode == SnoMode.LINES && payload.vertexCount > 1
if (drawLines) {
drawEdges(payload, screen, pixels, depth, width, height)
}
// The vertices, in every mode (§1.5). They are the drawing itself
// wherever nothing else got drawn — `points` mode, a `solid` with no
// faces, a `lines` with a single vertex — and also when what was drawn
// came out too small for a triangle to land on a pixel. Everywhere else
// they are the hint.
val pointsAreTheShape = !(drawTriangles || drawLines) || screen.spanPixels < TOO_SMALL_FOR_TRIANGLES
drawPoints(payload, screen, pixels, depth, width, height, pointsAreTheShape)
return pixels
}
/**
* Every vertex in pixel space, plus how much of the raster the object fills.
*
* Orthographic, looking down `-Z`: `+X` is right, `+Y` is up and `+Z` is
* toward the viewer (§2), so a larger Z is nearer and screen Y runs the
* other way from model Y. A thumbnail wants no perspective distortion and
* no near plane to clip against.
*/
private fun project(
payload: SnoPayload,
width: Int,
height: Int,
yawDegrees: Float,
pitchDegrees: Float,
): Projection {
val count = payload.vertexCount
val xs = FloatArray(count)
val ys = FloatArray(count)
val zs = FloatArray(count)
val yaw = yawDegrees * DEG_TO_RAD
val pitch = pitchDegrees * DEG_TO_RAD
val cosYaw = cos(yaw)
val sinYaw = sin(yaw)
val cosPitch = cos(pitch)
val sinPitch = sin(pitch)
var minX = Float.MAX_VALUE
var maxX = -Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxY = -Float.MAX_VALUE
for (i in 0 until count) {
// The one conversion from the exact lattice to floats.
val x = payload.tickAt(i, 0).toFloat()
val y = payload.tickAt(i, 1).toFloat()
val z = payload.tickAt(i, 2).toFloat()
val x1 = x * cosYaw + z * sinYaw
val z1 = -x * sinYaw + z * cosYaw
val y2 = y * cosPitch - z1 * sinPitch
val z2 = y * sinPitch + z1 * cosPitch
xs[i] = x1
ys[i] = y2
zs[i] = z2
if (x1 < minX) minX = x1
if (x1 > maxX) maxX = x1
if (y2 < minY) minY = y2
if (y2 > maxY) maxY = y2
}
val spanX = maxX - minX
val spanY = maxY - minY
val usable = min(width, height) * (1f - 2f * MARGIN)
val largest = max(spanX, spanY)
val scale = if (largest <= 0f) 1f else usable / largest
val centreX = (minX + maxX) * 0.5f
val centreY = (minY + maxY) * 0.5f
val halfWidth = width * 0.5f
val halfHeight = height * 0.5f
for (i in 0 until count) {
xs[i] = halfWidth + (xs[i] - centreX) * scale
// Screen Y grows downward; model +Y is up.
ys[i] = halfHeight - (ys[i] - centreY) * scale
}
return Projection(xs, ys, zs, largest * scale, scale * SnoPayload.TICKS_PER_UNIT)
}
private class Projection(
val xs: FloatArray,
val ys: FloatArray,
val zs: FloatArray,
/** How many pixels across the object turned out to be. */
val spanPixels: Float,
/** How many pixels one model unit came out as, for sizing the dots. */
val pixelsPerUnit: Float,
)
@Suppress("detekt.LongParameterList")
private fun drawFaces(
payload: SnoPayload,
screen: Projection,
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
lighting: SnoLighting?,
) {
val faceColors = payload.faceColors
// Lit, the faces are the ones wound outward, since which side of a face
// you are looking at is the question the light answers.
val faces = lighting?.faces ?: payload.faces
for (face in 0 until payload.faceCount) {
// A face buried inside a join has faces on both sides of it: lit, it
// would only fight the one it sits against.
if (lighting != null && lighting.interior[face]) continue
val a = faces[face * 3]
val b = faces[face * 3 + 1]
val c = faces[face * 3 + 2]
// A face that carries its own colour fills the whole triangle flatly,
// and its vertices contribute nothing to the fill (§1.4a).
val flat = faceColors?.get(face)
fillTriangle(
screen,
pixels,
depth,
width,
height,
a,
b,
c,
flat ?: payload.colors[a],
flat ?: payload.colors[b],
flat ?: payload.colors[c],
lighting?.outside?.get(face) ?: UNLIT,
lighting?.inside?.get(face) ?: UNLIT,
)
}
}
/**
* One triangle, barycentric, both sides drawn.
*
* The edge function's sign tells us the winding, and it is used only to
* normalise the barycentric weights — never to decide whether to draw
* (§1.4).
*/
@Suppress("detekt.LongParameterList")
private fun fillTriangle(
screen: Projection,
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
ia: Int,
ib: Int,
ic: Int,
colorA: Int,
colorB: Int,
colorC: Int,
shadeOutside: Float,
shadeInside: Float,
) {
val ax = screen.xs[ia]
val ay = screen.ys[ia]
val bx = screen.xs[ib]
val by = screen.ys[ib]
val cx = screen.xs[ic]
val cy = screen.ys[ic]
val area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
if (area > -MIN_AREA && area < MIN_AREA) return
val left = max(0, floorToInt(min(ax, min(bx, cx))))
val right = min(width - 1, ceilToInt(max(ax, max(bx, cx))))
val top = max(0, floorToInt(min(ay, min(by, cy))))
val bottom = min(height - 1, ceilToInt(max(ay, max(by, cy))))
if (left > right || top > bottom) return
// Each edge function is linear in x and y, so it is evaluated once at
// the corner of the span and then stepped: three adds a pixel instead
// of six multiplies and four subtractions. The weights still come out
// barycentric, they are just not recomputed from scratch every time.
//
// The sign of the signed area is folded into the coefficients rather
// than tested per pixel, which is also what draws both sides of a face
// (§1.4): a triangle wound the other way has its edge functions
// negated along with its area, so the inside test is the same one.
val flip = if (area < 0f) -1f else 1f
val constA = (bx * cy - by * cx) * flip
val stepAx = (by - cy) * flip
val stepAy = (cx - bx) * flip
val constB = (cx * ay - cy * ax) * flip
val stepBx = (cy - ay) * flip
val stepBy = (ax - cx) * flip
val total = area * flip
val inverseTotal = 1f / total
// The same sign says which side of the face is turned toward us, once
// the faces have been wound outward: screen Y runs down, so a triangle
// whose outside faces the viewer comes out with a negative signed area.
// Unlit both shades are 1 and the question never arises.
val shade = if (area < 0f) shadeOutside else shadeInside
// A face whose three corners agree has nothing to interpolate, and that
// is the common case: a stamped block is one colour, and a face that
// carries its own colour (§1.4a) arrives here as all three. Its colour
// under its light is therefore settled once for the whole triangle
// instead of being rebuilt at every pixel.
val interpolate = !(colorA == colorB && colorB == colorC)
val solid =
if (interpolate) {
0
} else if (shade == UNLIT) {
colorA
} else {
darken(colorA, shade)
}
val az = screen.zs[ia]
val bz = screen.zs[ib]
val cz = screen.zs[ic]
val startX = left + 0.5f
var rowA = constA + startX * stepAx + (top + 0.5f) * stepAy
var rowB = constB + startX * stepBx + (top + 0.5f) * stepBy
for (py in top..bottom) {
var edgeA = rowA
var edgeB = rowB
var at = py * width + left
for (px in left..right) {
val edgeC = total - edgeA - edgeB
if (edgeA >= 0f && edgeB >= 0f && edgeC >= 0f) {
val wA = edgeA * inverseTotal
val wB = edgeB * inverseTotal
val wC = edgeC * inverseTotal
val z = wA * az + wB * bz + wC * cz
if (z > depth[at]) {
depth[at] = z
pixels[at] = if (interpolate) blend(colorA, colorB, colorC, wA, wB, wC, shade) else solid
}
}
edgeA += stepAx
edgeB += stepBx
at++
}
rowA += stepAy
rowB += stepBy
}
}
/**
* The edges of the faces, each drawn once (§1.5); with no faces, one
* polyline through the vertices in order.
*/
private fun drawEdges(
payload: SnoPayload,
screen: Projection,
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
) {
if (payload.faceCount == 0) {
for (i in 0 until payload.vertexCount - 1) {
drawLine(screen, pixels, depth, width, height, i, i + 1, payload.colors[i], payload.colors[i + 1])
}
return
}
val seen = HashSet<Long>(payload.faceCount * 3)
for (face in 0 until payload.faceCount) {
val a = payload.faces[face * 3]
val b = payload.faces[face * 3 + 1]
val c = payload.faces[face * 3 + 2]
edge(seen, a, b)?.let { drawLine(screen, pixels, depth, width, height, a, b, payload.colors[a], payload.colors[b]) }
edge(seen, b, c)?.let { drawLine(screen, pixels, depth, width, height, b, c, payload.colors[b], payload.colors[c]) }
edge(seen, a, c)?.let { drawLine(screen, pixels, depth, width, height, a, c, payload.colors[a], payload.colors[c]) }
}
}
/** Null when this edge has already been drawn; the key otherwise. */
private fun edge(
seen: HashSet<Long>,
a: Int,
b: Int,
): Long? {
val low = min(a, b).toLong()
val high = max(a, b).toLong()
val key = (low shl 32) or high
return if (seen.add(key)) key else null
}
private fun drawLine(
screen: Projection,
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
from: Int,
to: Int,
colorFrom: Int,
colorTo: Int,
) {
val x0 = screen.xs[from]
val y0 = screen.ys[from]
val x1 = screen.xs[to]
val y1 = screen.ys[to]
val steps = max(abs(x1 - x0), abs(y1 - y0)).roundToInt()
if (steps <= 0) {
plot(pixels, depth, width, height, x0.roundToInt(), y0.roundToInt(), screen.zs[from], colorFrom)
return
}
val z0 = screen.zs[from]
val z1 = screen.zs[to]
for (step in 0..steps) {
val t = step.toFloat() / steps
plot(
pixels,
depth,
width,
height,
(x0 + (x1 - x0) * t).roundToInt(),
(y0 + (y1 - y0) * t).roundToInt(),
z0 + (z1 - z0) * t,
mix(colorFrom, colorTo, t),
)
}
}
/**
* The vertices, as round dots at one of the two sizes §1.5 asks for.
*
* @param asShape true where the dots are the drawing, false where something
* is already drawn and they are the hint laid over it.
*/
@Suppress("detekt.LongParameterList")
private fun drawPoints(
payload: SnoPayload,
screen: Projection,
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
asShape: Boolean,
) {
val perUnit = screen.pixelsPerUnit
val diameter =
if (asShape) {
(SHAPE_POINT_UNITS * perUnit).coerceIn(SHAPE_POINT_MIN_PX, SHAPE_POINT_MAX_PX)
} else {
(HINT_DOT_UNITS * perUnit).coerceIn(HINT_DOT_MIN_PX, HINT_DOT_MAX_PX)
}
// A dot thinner than a pixel cannot be drawn any smaller, so it is drawn
// fainter instead, which is how a pixel says "less than one of me".
val alpha = (if (asShape) 1f else HINT_DOT_ALPHA) * min(1f, diameter)
val radius = diameter * 0.5f
val colors = dotColors(payload)
for (i in 0 until payload.vertexCount) {
drawDot(pixels, depth, width, height, screen.xs[i], screen.ys[i], screen.zs[i], colors[i], radius, alpha)
}
}
/**
* The colour each vertex is marked in.
*
* Its own, except on a filled object whose faces carry their own colours:
* there §1.4a has already decided that the vertex colours are not what this
* object shows anywhere, and a dot in a colour that appears nowhere else
* would be the format contradicting itself on the reader's screen. The
* reference viewer reaches the same place from the other end — it splits
* every coloured face into three corners of its own before it draws
* anything, so its points inherit the face colour too — and where a vertex
* is shared by faces of different colours it draws one dot per face and the
* last one wins, which is exactly the vertex's last face, as here.
*/
private fun dotColors(payload: SnoPayload): IntArray {
val faceColors = payload.faceColors
if (faceColors == null || payload.mode != SnoMode.SOLID) return payload.colors
val resolved = payload.colors.copyOf()
for (face in 0 until payload.faceCount) {
val color = faceColors[face]
resolved[payload.faces[face * 3]] = color
resolved[payload.faces[face * 3 + 1]] = color
resolved[payload.faces[face * 3 + 2]] = color
}
return resolved
}
/**
* One round dot, its rim softened by how much of each pixel it covers.
*
* It reads the depth buffer and does not write it, as the reference's point
* material does (`depthWrite: false`): a vertex on the far side of an object
* stays hidden behind the faces in front of it, while two dots that overlap
* both land instead of one clipping the other. [DOT_DEPTH_BIAS] is what
* lets a vertex sitting exactly on the faces that meet there win against
* them.
*/
@Suppress("detekt.LongParameterList")
private fun drawDot(
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
centreX: Float,
centreY: Float,
z: Float,
color: Int,
radius: Float,
alpha: Float,
) {
val near = z + DOT_DEPTH_BIAS
val reach = radius + 0.5f
val left = max(0, floorToInt(centreX - reach))
val right = min(width - 1, ceilToInt(centreX + reach))
val top = max(0, floorToInt(centreY - reach))
val bottom = min(height - 1, ceilToInt(centreY + reach))
if (left > right || top > bottom) return
for (py in top..bottom) {
val dy = py + 0.5f - centreY
var at = py * width + left
for (px in left..right) {
val dx = px + 0.5f - centreX
val coverage = reach - sqrt(dx * dx + dy * dy)
if (coverage > 0f && near >= depth[at]) {
over(pixels, at, color, alpha * min(coverage, 1f))
}
at++
}
}
}
/**
* Source-over compositing of an opaque colour at [alpha] into the
* non-premultiplied ARGB buffer, which may itself be transparent where the
* background is.
*/
private fun over(
pixels: IntArray,
at: Int,
color: Int,
alpha: Float,
) {
val source = min(alpha, 1f)
if (source <= 0f) return
val destination = pixels[at]
val kept = (destination ushr 24) * (1f / 255f) * (1f - source)
val total = source + kept
if (total <= 0f) return
val inverse = 1f / total
val r = ((color shr 16 and 0xFF) * source + (destination shr 16 and 0xFF) * kept) * inverse
val g = ((color shr 8 and 0xFF) * source + (destination shr 8 and 0xFF) * kept) * inverse
val b = ((color and 0xFF) * source + (destination and 0xFF) * kept) * inverse
pixels[at] =
(clamp255((total * 255f).roundToInt()) shl 24) or
(clamp255(r.roundToInt()) shl 16) or
(clamp255(g.roundToInt()) shl 8) or
clamp255(b.roundToInt())
}
private fun plot(
pixels: IntArray,
depth: FloatArray,
width: Int,
height: Int,
x: Int,
y: Int,
z: Float,
color: Int,
) {
if (x < 0 || y < 0 || x >= width || y >= height) return
val at = y * width + x
if (z < depth[at]) return
depth[at] = z
pixels[at] = color
}
/**
* Barycentric interpolation between three opaque colours, times the light.
* The three are never all equal here; [fillTriangle] settles that case once
* for the whole triangle.
*/
@Suppress("detekt.LongParameterList")
private fun blend(
a: Int,
b: Int,
c: Int,
wA: Float,
wB: Float,
wC: Float,
shade: Float,
): Int {
val r = (((a shr 16 and 0xFF) * wA + (b shr 16 and 0xFF) * wB + (c shr 16 and 0xFF) * wC) * shade).roundToInt()
val g = (((a shr 8 and 0xFF) * wA + (b shr 8 and 0xFF) * wB + (c shr 8 and 0xFF) * wC) * shade).roundToInt()
val bl = (((a and 0xFF) * wA + (b and 0xFF) * wB + (c and 0xFF) * wC) * shade).roundToInt()
return (0xFF shl 24) or (clamp255(r) shl 16) or (clamp255(g) shl 8) or clamp255(bl)
}
/** An opaque colour under a light that is less than all of it. */
private fun darken(
color: Int,
shade: Float,
): Int =
(0xFF shl 24) or
(clamp255(((color shr 16 and 0xFF) * shade).roundToInt()) shl 16) or
(clamp255(((color shr 8 and 0xFF) * shade).roundToInt()) shl 8) or
clamp255(((color and 0xFF) * shade).roundToInt())
private fun mix(
from: Int,
to: Int,
t: Float,
): Int {
if (from == to) return from
val r = ((from shr 16 and 0xFF) + ((to shr 16 and 0xFF) - (from shr 16 and 0xFF)) * t).roundToInt()
val g = ((from shr 8 and 0xFF) + ((to shr 8 and 0xFF) - (from shr 8 and 0xFF)) * t).roundToInt()
val b = ((from and 0xFF) + ((to and 0xFF) - (from and 0xFF)) * t).roundToInt()
return (0xFF shl 24) or (clamp255(r) shl 16) or (clamp255(g) shl 8) or clamp255(b)
}
private fun clamp255(v: Int) =
if (v < 0) {
0
} else if (v > 255) {
255
} else {
v
}
private fun floorToInt(v: Float): Int {
val i = v.toInt()
return if (v < 0f && v != i.toFloat()) i - 1 else i
}
private fun ceilToInt(v: Float): Int {
val i = v.toInt()
return if (v > 0f && v != i.toFloat()) i + 1 else i
}
private const val DEG_TO_RAD = 0.017453292f
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.cyberspace
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* The budget around §7.7's sweep, on a bag the reference implementation sealed.
*
* The payload and the `d` tag below are the london height-4 vector that
* `CyberspaceBagEventTest` pins against `cyberspace-cli`'s own cipher, so a
* sweep that finds it here has reproduced the hider's region key from nothing
* but a box — which is the whole claim §7.7 makes.
*/
class BagSweepTest {
/** `cyberspace-cli`'s AES-256-GCM over a plain text note, at the london height-4 key. */
private val payload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA=="
/** The aligned bases of the boxes around that coordinate, from the reference's own interleave. */
private val box444 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c8000"
private val box666 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e3380000"
private val box121212 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76c000000000"
private fun bag(vararg extra: Array<String>): CyberspaceBagEvent =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"),
arrayOf("version", "2"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
) + extra,
content = "",
sig = "0".repeat(128),
)
private fun hint(
base: String,
h: Int,
) = arrayOf("hint", base, h.toString(), h.toString(), h.toString())
@Test
fun aBoxIsPricedFromTwoTagsAndNothingElse() {
val quote = BagSweep.quote(bag(hint(box666, 6)))
assertIs<BagSweepQuote.Searchable>(quote)
assertEquals(6, quote.gapBits, "(6-4) three times")
assertEquals(64, quote.candidates)
assertEquals(false, quote.destination)
}
@Test
fun aHintThatNamesTheRegionIsStillOfferedAsASearch() {
// §7.7 calls three heights equal to the bag's "a destination the seeker
// can compute or walk to directly, not a search". It is still a tap: a
// reader who learned that some bags open themselves would have learned
// an expectation a hostile bag could hide inside.
val quote = BagSweep.quote(bag(hint(box444, 4)))
assertIs<BagSweepQuote.Searchable>(quote)
assertEquals(0, quote.gapBits)
assertEquals(1, quote.candidates)
assertTrue(quote.destination)
}
@Test
fun aBoxPastTheCapIsNeverOfferedAsAButton() {
// 3 x (12 - 4) = 24, which §7.7's own table calls "hours".
val quote = BagSweep.quote(bag(hint(box121212, 12)))
assertIs<BagSweepQuote.OutOfReach>(quote)
assertEquals(24, quote.gapBits)
}
@Test
fun aBagWithNoUsableHintIsHiddenRatherThanBroken() {
// §7.7: a malformed hint "MUST be treated as absent", and §7.6 says a
// bag nobody can find is not an error in the bag.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag()))
// Two hint tags is one of §7.7's malformed cases.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4))))
// And a box smaller than the region it claims to hold is another.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag(hint(box444, 3))))
}
@Test
fun aBagWhoseVersionIsUnknownIsNotSwept() {
// §8.6: "A reader MUST ignore a bag whose version it does not know."
val future =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"),
arrayOf("version", "3"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
hint(box666, 6),
),
content = "",
sig = "0".repeat(128),
)
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(future))
}
@Test
fun sweepingTheBoxFindsTheRegionAndOpensTheBag() =
runTest {
val states = BagSweep.sweep(bag(hint(box666, 6))).toList()
assertIs<BagSweepState.Measuring>(states.first(), "priced on this device before the rest is spent")
val opened = assertIs<BagSweepState.Opened>(states.last())
val contents = assertIs<CyberspaceBagContents.Opaque>(opened.contents)
assertEquals("just some words, not a list", contents.bytes.decodeToString())
}
@Test
fun aDestinationHintOpensOnTheOneCandidateItNames() =
runTest {
// The measurement is never wasted: the box's own base region is the
// first candidate either way, so a gap-0 hint is answered by it.
val states = BagSweep.sweep(bag(hint(box444, 4))).toList()
assertEquals(2, states.size, "measure, then open")
assertIs<BagSweepState.Opened>(states.last())
}
@Test
fun aBoxTheHintWasWrongAboutIsSweptToTheEndAndSaysSo() =
runTest {
// The same box, against a bag addressed to a region that is not in
// it. Every candidate is derived and none matches, which is the
// honest outcome of a hint that was wrong or was bait.
val elsewhere =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "f".repeat(64)),
arrayOf("version", "2"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
hint(box666, 6),
),
content = "",
sig = "0".repeat(128),
)
val states = BagSweep.sweep(elsewhere).toList()
val notFound = assertIs<BagSweepState.NotFound>(states.last())
assertEquals(64, notFound.examined, "the whole box, and not one region more")
}
@Test
fun aQuoteThatWasDeclinedEmitsNothingAtAll() =
runTest {
// Nothing is built for a box the card already refused, which is the
// point of pricing from the tags: the refusal costs no arithmetic.
assertTrue(BagSweep.sweep(bag(hint(box121212, 12))).toList().isEmpty())
assertTrue(BagSweep.sweep(bag()).toList().isEmpty())
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.math.abs
import kotlin.math.sqrt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The placeholder an avatar with no shape is drawn as.
*
* Asserted as geometry rather than as a picture, because a hand-written face
* list is exactly the kind of thing that comes out looking almost right: an
* icosahedron is twelve corners, thirty edges and twenty faces, every corner on
* five of them and every edge on two, and a transposed index breaks one of
* those without breaking the others.
*/
class SnoDefaultAvatarTest {
private val payload = SnoDefaultAvatar.payload
@Test
fun itIsAnIcosahedron() {
assertEquals(12, payload.vertexCount)
assertEquals(20, payload.faceCount)
assertEquals(SnoMode.LINES, payload.mode, "a placeholder should read as a wireframe, not as something somebody made")
}
@Test
fun everyEdgeJoinsTwoFacesAndEveryCornerFive() {
val onEdge = HashMap<Long, Int>()
val onCorner = IntArray(payload.vertexCount)
for (face in 0 until payload.faceCount) {
val corners = intArrayOf(payload.faces[face * 3], payload.faces[face * 3 + 1], payload.faces[face * 3 + 2])
for (corner in corners) onCorner[corner]++
for (i in 0..2) {
val a = corners[i]
val b = corners[(i + 1) % 3]
val key = (minOf(a, b).toLong() shl 32) or maxOf(a, b).toLong()
onEdge[key] = (onEdge[key] ?: 0) + 1
}
}
assertEquals(30, onEdge.size, "an icosahedron has thirty edges")
assertTrue(onEdge.values.all { it == 2 }, "every edge should join exactly two faces")
assertTrue(onCorner.all { it == 5 }, "every corner should be on five faces")
}
@Test
fun everyEdgeIsTheSameLength() {
// What actually makes it regular. The corners are on the lattice and
// the golden ratio is not, so the edges differ by the rounding and by
// nothing else: 194 ticks against 1.618034 units is 0.02% out.
var shortest = Double.MAX_VALUE
var longest = 0.0
for (face in 0 until payload.faceCount) {
for (i in 0..2) {
val a = payload.faces[face * 3 + i]
val b = payload.faces[face * 3 + (i + 1) % 3]
var square = 0.0
for (axis in 0..2) {
val d = (payload.tickAt(a, axis) - payload.tickAt(b, axis)).toDouble()
square += d * d
}
val length = sqrt(square)
if (length < shortest) shortest = length
if (length > longest) longest = length
}
}
assertTrue(longest / shortest < 1.001, "edges ran $shortest to $longest ticks")
// And the edge of an icosahedron of short radius 1 is 2 units.
assertTrue(abs(shortest - 2.0 * SnoPayload.TICKS_PER_UNIT) < 1.0, "an edge should be two units, was $shortest ticks")
}
@Test
fun itDrawsSomethingAndFitsTheFrame() {
val size = 64
val pixels = SnoRasterizer.render(payload, size, size)
assertTrue(pixels.count { it != 0 } > 100, "a wireframe icosahedron should light a good many pixels")
// Nothing may spill past the margin the projection leaves.
for (x in 0 until size) {
assertEquals(0, pixels[x], "the top row should be clear")
assertEquals(0, pixels[(size - 1) * size + x], "the bottom row should be clear")
}
}
}
@@ -0,0 +1,259 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The inspection drawing DECK-0003 §4 permits: "A client MAY light an object
* instead, and many will."
*
* Two things have to hold for that to be worth doing. Every face must wind
* outward first, or a stamped block comes out half black; and the light must be
* per face and flat, from the triangle's own corners, which §4 requires in as
* many words.
*/
class SnoLightingTest {
private val red = 0xFFFF0000.toInt()
/** Three numbers per row, written out rather than nested, so the shape reads. */
private fun numbers(text: String) =
text
.trim()
.split(WHITESPACE)
.map { it.toInt() }
.toIntArray()
/**
* A box on the lattice, as twelve triangles wound outward — except for the
* ones named in [reversed], which are handed back inward, the way half of a
* stamped block's triangles arrive.
*/
private fun box(
bottom: Int = -1,
top: Int = 1,
reversed: Set<Int> = emptySet(),
): SnoPayload {
val t = SnoPayload.TICKS_PER_UNIT
// The eight corners of the unit cube, as 0 or 1 on each axis.
val cube = numbers("0 0 0 1 0 0 1 1 0 0 1 0 0 0 1 1 0 1 1 1 1 0 1 1")
val positions =
IntArray(cube.size) {
when (it % 3) {
1 -> if (cube[it] == 0) bottom else top
else -> if (cube[it] == 0) -1 else 1
} * t
}
val faces =
numbers(
"""
4 5 6 4 6 7
0 3 2 0 2 1
1 2 6 1 6 5
0 4 7 0 7 3
3 7 6 3 6 2
0 1 5 0 5 4
""",
)
for (face in reversed) {
val swap = faces[face * 3 + 1]
faces[face * 3 + 1] = faces[face * 3 + 2]
faces[face * 3 + 2] = swap
}
return payload("box", positions, faces, extent = 8)
}
private fun payload(
name: String,
positions: IntArray,
faces: IntArray,
extent: Int,
) = SnoPayload(
version = 2,
name = name,
unit = 0,
extent = extent,
mode = SnoMode.SOLID,
positions = positions,
colors = IntArray(positions.size / 3) { red },
faces = faces,
faceColors = null,
paletteRef = SnoPaletteRef.BuiltIn,
up = false,
spin = 0,
)
/** The face's normal, taken from its own three corners as §4 requires. */
private fun normalOf(
payload: SnoPayload,
faces: IntArray,
face: Int,
): FloatArray {
val a = faces[face * 3] * 3
val b = faces[face * 3 + 1] * 3
val c = faces[face * 3 + 2] * 3
val abx = (payload.positions[b] - payload.positions[a]).toFloat()
val aby = (payload.positions[b + 1] - payload.positions[a + 1]).toFloat()
val abz = (payload.positions[b + 2] - payload.positions[a + 2]).toFloat()
val acx = (payload.positions[c] - payload.positions[a]).toFloat()
val acy = (payload.positions[c + 1] - payload.positions[a + 1]).toFloat()
val acz = (payload.positions[c + 2] - payload.positions[a + 2]).toFloat()
return floatArrayOf(aby * acz - abz * acy, abz * acx - abx * acz, abx * acy - aby * acx)
}
/** Whether this face turns its back on the middle of the object. */
private fun pointsOutward(
payload: SnoPayload,
faces: IntArray,
face: Int,
): Boolean {
val normal = normalOf(payload, faces, face)
var away = 0f
for (axis in 0..2) {
var middle = 0f
for (corner in 0..2) middle += payload.positions[faces[face * 3 + corner] * 3 + axis].toFloat()
away += normal[axis] * (middle / 3f)
}
// These objects sit on the origin, so a face's middle is the direction
// out of the object at that face.
return away > 0f
}
@Test
fun aBoxWhoseTrianglesDisagreeIsWoundOutward() {
// Half of a stamped block's triangles wind inward, which never showed
// while nothing was lit. Every one of them comes back outward.
val payload = box(reversed = setOf(0, 3, 4, 7, 9, 11))
val winding = SnoFaceWinding.of(payload)
for (face in 0 until payload.faceCount) {
assertTrue(pointsOutward(payload, winding.faces, face), "face $face should face out of the box")
}
}
@Test
fun aBoxAlreadyWoundOutwardIsLeftAlone() {
val payload = box()
val winding = SnoFaceWinding.of(payload)
assertTrue(winding.faces.contentEquals(payload.faces), "nothing needed turning")
assertTrue(winding.interior.none { it }, "a lone box has no face buried in a join")
// And the payload's own array is untouched, whatever the pass decides.
assertEquals(4, payload.faces[0])
}
@Test
fun aLitBoxOfOneColourIsNoLongerAFlatHexagon() {
// The whole reason the switch exists: unlit, a box of a single colour is
// one flat shape however far you turn it, because every pixel of it is
// that colour.
val payload = box()
val flat = SnoRasterizer.render(payload, 64, 64)
val lit = SnoRasterizer.render(payload, 64, 64, lighting = SnoLighting.of(payload))
val flatShades = flat.filter { it ushr 24 == 0xFF }.toSet()
val litShades = lit.filter { it ushr 24 == 0xFF }.toSet()
assertEquals(1, flatShades.size, "unlit, every pixel of a one-colour box is the same colour")
assertTrue(litShades.size >= 3, "lit, each of the three visible faces takes its own shade: $litShades")
}
@Test
fun theLightNeverClipsAndNeverGoesOut() {
// The three lights are scaled so that a face square-on to the key shows
// its colour exactly: nothing washes out to white, and the darkest face
// keeps enough of its colour to read.
val payload = box(reversed = setOf(1, 2, 5, 8))
val lighting = SnoLighting.of(payload)
for (face in 0 until payload.faceCount) {
assertTrue(lighting.outside[face] > 0.3f, "face $face outside went dark: ${lighting.outside[face]}")
assertTrue(lighting.outside[face] <= 1f, "face $face outside clipped: ${lighting.outside[face]}")
assertTrue(
lighting.inside[face] < lighting.outside[face],
"the inside of face $face should be the darker side: ${lighting.inside[face]} against ${lighting.outside[face]}",
)
}
}
@Test
fun theSquareInsideAJoinIsNotDrawn() {
// A block stacked on a block leaves a square between them with faces on
// both sides of it. Lit, it would only fight the face it sits against.
val stacked = merge(box(bottom = -2, top = 0), box(bottom = 0, top = 2))
val winding = SnoFaceWinding.of(stacked)
val buried = (0 until stacked.faceCount).filter { winding.interior[it] }
assertEquals(4, buried.size, "the join is two triangles from each block: $buried")
for (face in buried) {
for (corner in 0..2) {
assertEquals(0, stacked.positions[winding.faces[face * 3 + corner] * 3 + 1], "the join lies at y = 0")
}
}
}
@Test
fun aFlatPlateFarFromTheOriginStillFacesUp() {
// A patch whose rays cross nothing has no vote, and falls back to its
// signed volume and then, when that is zero too, to facing up. The
// volume is the one the divergence theorem gives, and that is only
// independent of where it is measured from for a *closed* surface: for
// an open plate measured from the origin it is the volume of the cone
// between the two, which is large and carries the sign of whichever
// side of the origin the plate lies on. A plate would then be lit on
// its upper face above the origin and on its lower face below it — the
// same object drawn two ways, decided by where §1.8 let the author put
// it. Measured about the patch's own middle that cone is flat, the
// volume is zero, and the plate falls through to facing up wherever it
// lies.
val t = SnoPayload.TICKS_PER_UNIT
val plate =
payload(
"plate",
// A two-unit square lying flat, in the far corner of the lattice
// and below the origin, wound so that it starts out facing down.
IntArray(12) { numbers("60 -62 60 62 -62 60 62 -62 62 60 -62 62")[it] * t },
numbers("0 1 2 0 2 3"),
extent = 64,
)
val winding = SnoFaceWinding.of(plate)
for (face in 0 until plate.faceCount) {
assertTrue(normalOf(plate, winding.faces, face)[1] > 0f, "face $face of a plate should end up facing up")
}
assertTrue(winding.interior.none { it }, "a plate has nothing buried in it")
}
/** Two objects in one payload, as a bag of stamps would be. */
private fun merge(
first: SnoPayload,
second: SnoPayload,
) = payload(
"stack",
first.positions + second.positions,
first.faces + IntArray(second.faces.size) { second.faces[it] + first.vertexCount },
extent = 8,
)
companion object {
private val WHITESPACE = Regex("\\s+")
}
}
@@ -0,0 +1,335 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* The rendering decisions DECK-0003 §4 makes, asserted on pixels.
*
* §4 decides shading rather than leaving it to taste precisely so that the same
* object does not look like two objects, so each of its rules gets a test here
* rather than a comment.
*/
class SnoRasterizerTest {
private val dim = 64
private fun parse(json: String): SnoPayload {
val payload = SnoParser.parse(json).payloadOrNull()
assertNotNull(payload, "fixture did not parse: $json")
return payload
}
/** A single triangle filling most of the frame, facing the viewer. */
private fun triangle(
colors: String = "[238,238,238]",
extra: String = "",
mode: String = "solid",
) = parse(
"""{"v":2,"name":"t","unit":0,"mode":"$mode","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":$colors,"faces":[[0,1,2]]$extra}""",
)
private fun IntArray.at(
x: Int,
y: Int,
) = this[y * dim + x]
private fun IntArray.litCount() = count { it != 0 }
@Test
fun aSolidTriangleFillsItsMiddleAndLeavesTheCornersAlone() {
val pixels = SnoRasterizer.render(triangle(), dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
assertEquals(0xFFFF0000.toInt(), pixels.at(dim / 2, dim / 2), "the centre should be the face")
assertEquals(0, pixels.at(0, 0), "a corner outside the triangle stays transparent")
assertEquals(0, pixels.at(dim - 1, 0))
}
@Test
fun theBackgroundIsHonoured() {
val pixels = SnoRasterizer.render(triangle(), dim, dim, yawDegrees = 0f, pitchDegrees = 0f, background = 0xFF101010.toInt())
assertEquals(0xFF101010.toInt(), pixels.at(0, 0))
assertEquals(0xFFFF0000.toInt(), pixels.at(dim / 2, dim / 2))
}
@Test
fun vertexColoursInterpolateAcrossAFace() {
// §4: the default reading is unlit, and a face takes its colour by
// interpolating its three vertices.
val pixels = SnoRasterizer.render(triangle(colors = "[238,235,239]"), dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
val middle = pixels.at(dim / 2, dim / 2)
val r = middle shr 16 and 0xFF
val g = middle shr 8 and 0xFF
val b = middle and 0xFF
assertTrue(r in 1..254 && g in 1..254 && b in 1..254, "the centre of a red/green/blue triangle should be a mix, was $r,$g,$b")
}
@Test
fun aFaceColourFillsFlatlyAndItsVerticesContributeNothing() {
// §1.4a: when a face has a colour, that colour fills the whole triangle.
// The seam between two such faces is exact, which is the point of it.
val pixels =
SnoRasterizer.render(
triangle(colors = "[238,235,239]", extra = ""","facecolors":[225]"""),
dim,
dim,
yawDegrees = 0f,
pitchDegrees = 0f,
)
val lit = pixels.filter { it != 0 }
assertTrue(lit.isNotEmpty())
assertEquals(0xFFFFFFFF.toInt(), pixels.at(dim / 2, dim / 2), "the fill should be the face colour, opaque")
// Colour only: the vertex dots §1.5 lays over every mode feather at
// their rim, so a pixel at the edge of one carries partial alpha. What
// §1.4a decides is the colour, and no pixel anywhere shows a trace of
// the red, green and blue this object's vertices carry.
assertTrue(lit.all { it and 0xFFFFFF == 0xFFFFFF }, "every covered pixel should be the face colour, flat")
}
@Test
fun bothSidesOfAFaceAreDrawn() {
// §1.4: a face has no front and no back, and a reader MUST NOT cull on
// the basis of winding.
val clockwise = parse("""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]]}""")
val counter = parse("""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,2,1]]}""")
val a = SnoRasterizer.render(clockwise, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
val b = SnoRasterizer.render(counter, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
assertTrue(a.contentEquals(b), "reversing a face's winding must not change what is drawn")
assertTrue(a.litCount() > 100)
}
@Test
fun theNearerFaceWinsWhicheverOrderItIsListedIn() {
// This is what the depth buffer buys over a painter's-algorithm
// fallback: two coplanar-in-screen triangles at different depths sort
// correctly no matter how the payload orders them.
val nearLast =
parse(
"""{"v":2,"name":"z","unit":0,"mode":"solid",
"vertices":[[-4,-4,-2],[4,-4,-2],[0,4,-2],[-4,-4,2],[4,-4,2],[0,4,2]],
"colors":[238,238,238,235,235,235],"faces":[[0,1,2],[3,4,5]]}""",
)
val nearFirst =
parse(
"""{"v":2,"name":"z","unit":0,"mode":"solid",
"vertices":[[-4,-4,2],[4,-4,2],[0,4,2],[-4,-4,-2],[4,-4,-2],[0,4,-2]],
"colors":[235,235,235,238,238,238],"faces":[[0,1,2],[3,4,5]]}""",
)
val a = SnoRasterizer.render(nearLast, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
val b = SnoRasterizer.render(nearFirst, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
assertEquals(0xFF00FF00.toInt(), a.at(dim / 2, dim / 2), "the nearer (green) face should win")
assertEquals(0xFF00FF00.toInt(), b.at(dim / 2, dim / 2), "...in either listing order")
}
@Test
fun pointsModeDrawsTheVerticesAndNotTheTriangle() {
val pixels = SnoRasterizer.render(triangle(mode = "points"), dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
// Three round dots a couple of pixels across, and nothing between them:
// the whole triangle would be some 1400 pixels.
assertTrue(pixels.litCount() in 3..60, "three vertices should light three small dots, lit ${pixels.litCount()}")
assertEquals(0, pixels.at(dim / 2, dim / 2), "points mode must not fill the triangle")
}
@Test
fun theVerticesAreDrawnInEveryMode() {
// §1.5: "A client SHOULD also draw the vertices as points in every
// mode". Under a solid they are a hint rather than a second shape —
// small, and laid over the fill at part alpha — so what shows they are
// there is that the dot on the apex reaches a little past it, into
// pixels no fill of this triangle can cover.
val wide = 512
val pixels = SnoRasterizer.render(triangle(), wide, wide, yawDegrees = 0f, pitchDegrees = 0f)
// The apex of the triangle lands just under a thirteenth of the way down.
val apexRow = (wide * 0.08f).toInt()
assertEquals(0, pixels[(apexRow - 10) * wide + wide / 2], "well above the apex is empty")
assertTrue(pixels[apexRow * wide + wide / 2] != 0, "the apex's own vertex dot reaches above the fill")
}
@Test
fun aFaceColourMarksItsVerticesInThatColourToo() {
// The other half of §1.4a: where a face carries its own colour, that is
// what the object shows, and the vertex dots over it are that colour as
// well rather than the three the payload's `colors` still carries.
val pixels =
SnoRasterizer.render(
triangle(colors = "[238,235,239]", extra = ""","facecolors":[225]"""),
dim,
dim,
yawDegrees = 0f,
pitchDegrees = 0f,
)
assertTrue(pixels.filter { it != 0 }.all { it and 0xFFFFFF == 0xFFFFFF }, "no dot should show a vertex colour")
}
@Test
fun linesModeDrawsEachSharedEdgeOnce() {
// A closed pair of triangles sharing an edge: the outline is drawn, the
// interior is not, and the shared edge costs one line rather than two.
val quad =
parse(
"""{"v":2,"name":"q","unit":0,"mode":"lines",
"vertices":[[-4,-4,0],[4,-4,0],[4,4,0],[-4,4,0]],
"colors":[225,225,225,225],"faces":[[0,1,2],[0,2,3]]}""",
)
val lines = SnoRasterizer.render(quad, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
val solid = SnoRasterizer.render(parse(solidQuadJson), dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
assertTrue(lines.litCount() > 0)
assertTrue(lines.litCount() * 4 < solid.litCount(), "lines mode must not fill: lit ${lines.litCount()} against a solid ${solid.litCount()}")
// A point inside the lower triangle and off every edge stays empty...
assertEquals(0, lines.at(dim / 2 + 13, dim / 2 + 13), "lines mode must not fill")
// ...while the edge the two triangles share is drawn. In the wire format
// a face IS a triangle, so that diagonal is a real edge of both faces and
// belongs in the outline; it is drawn once rather than twice, which is
// what the shared-edge rule of §1.5 buys.
assertTrue(lines.at(dim / 2, dim / 2) != 0, "the shared edge should be drawn")
}
private val solidQuadJson =
"""{"v":2,"name":"q","unit":0,"mode":"solid",
"vertices":[[-4,-4,0],[4,-4,0],[4,4,0],[-4,4,0]],
"colors":[225,225,225,225],"faces":[[0,1,2],[0,2,3]]}"""
@Test
fun linesWithNoFacesIsOnePolylineThroughTheVerticesInOrder() {
val path =
parse(
"""{"v":2,"name":"p","unit":0,"mode":"lines",
"vertices":[[-4,-4,0],[0,4,0],[4,-4,0]],"colors":[225,225,225],"faces":[]}""",
)
val pixels = SnoRasterizer.render(path, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
assertTrue(pixels.litCount() > 20, "a polyline of two segments should light a run of pixels")
}
@Test
fun anObjectTooSmallForATriangleIsStillVisible() {
// §1.5: a client SHOULD also draw the vertices as points in every mode,
// so that an object remains visible when it is smaller on screen than a
// triangle.
val pixels = SnoRasterizer.render(triangle(), 2, 2, yawDegrees = 0f, pitchDegrees = 0f)
assertTrue(pixels.any { it != 0 }, "a two-pixel raster should still show something")
}
@Test
fun nothingIsDrawnOutsideTheBuffer() {
// The limits are the defence, and the buffer is sized from the raster
// rather than from anything the payload says.
val pixels = SnoRasterizer.render(triangle(), 17, 5, yawDegrees = 37f, pitchDegrees = -61f)
assertEquals(17 * 5, pixels.size)
}
@Test
fun aSingleVertexDoesNotCrash() {
val dot = parse("""{"v":2,"name":"d","unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[225],"faces":[]}""")
val pixels = SnoRasterizer.render(dot, dim, dim)
// One vertex is the whole object, so it is drawn at the size the shape
// profile allows and centred in the frame.
assertTrue(pixels.litCount() in 1..160, "one vertex, lit ${pixels.litCount()}")
assertTrue(pixels.at(dim / 2, dim / 2) != 0, "the one vertex should be in the middle")
}
@Test
fun coincidentVerticesDoNotDivideByZero() {
val degenerate =
parse(
"""{"v":2,"name":"d","unit":0,"mode":"solid","vertices":[[1,1,1],[1,1,1],[1,1,1]],"colors":[225,225,225],"faces":[[0,1,2]]}""",
)
val pixels = SnoRasterizer.render(degenerate, dim, dim)
assertEquals(dim * dim, pixels.size)
}
@Test
fun theSameObjectRendersIdenticallyEveryTime() {
val payload = triangle(colors = "[238,235,239]")
val a = SnoRasterizer.render(payload, dim, dim)
val b = SnoRasterizer.render(payload, dim, dim)
assertTrue(a.contentEquals(b))
}
@Test
fun rotatingChangesTheDrawingButNotTheModel() {
val payload = triangle()
val front = SnoRasterizer.render(payload, dim, dim, yawDegrees = 0f, pitchDegrees = 0f)
val turned = SnoRasterizer.render(payload, dim, dim, yawDegrees = 60f, pitchDegrees = 0f)
assertTrue(!front.contentEquals(turned), "a yaw should change the raster")
// No invented geometry, and nothing moved: the positions are untouched.
assertEquals(-480, payload.tickAt(0, 0))
assertEquals(480, payload.tickAt(1, 0))
}
/**
* Reused buffers must draw the same picture as fresh ones.
*
* The whole risk of [SnoRasterScratch] is that a frame inherits something
* the last one left behind — a pixel the background fill missed, a depth
* still holding the previous angle's surface in front of this one. So this
* turns the object through a sequence of angles twice, once allocating and
* once reusing, and every frame has to match: a leak would show up on the
* second angle onward, never on the first.
*/
@Test
fun aReusedBufferDrawsWhatAFreshOneDraws() {
val payload = triangle(colors = "[238,235,239]")
val scratch = SnoRasterScratch()
val background = 0xFF101010.toInt()
for (yaw in floatArrayOf(0f, 37f, 180f, -95f, 0f)) {
val fresh = SnoRasterizer.render(payload, dim, dim, yawDegrees = yaw, background = background)
val reused =
SnoRasterizer.render(payload, dim, dim, yawDegrees = yaw, background = background, scratch = scratch)
assertTrue(fresh.contentEquals(reused), "a reused buffer differs from a fresh one at yaw $yaw")
}
}
/** A drag ends and the raster goes back to full size; the buffers must follow. */
@Test
fun aReusedBufferFollowsAChangeOfSize() {
val payload = triangle()
val scratch = SnoRasterScratch()
for (size in intArrayOf(dim, dim * 2, 3, dim)) {
val reused = SnoRasterizer.render(payload, size, size, scratch = scratch)
assertEquals(size * size, reused.size, "the buffer did not resize to $size")
assertTrue(reused.contentEquals(SnoRasterizer.render(payload, size, size)))
}
}
/** A lit frame must not leave its shading in the buffer for an unlit one. */
@Test
fun aReusedBufferDoesNotCarryLightingOver() {
val payload = parse(solidQuadJson)
val scratch = SnoRasterScratch()
SnoRasterizer.render(payload, dim, dim, lighting = SnoLighting.of(payload), scratch = scratch)
val unlitAfterLit = SnoRasterizer.render(payload, dim, dim, scratch = scratch).copyOf()
assertTrue(unlitAfterLit.contentEquals(SnoRasterizer.render(payload, dim, dim)))
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.prodbench
import com.vitorpamplona.amethyst.commons.sno.SnoLighting
import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* What it costs to draw an object, and a floor under it.
*
* The rasterizer is the one hot path this feature owns: a feed thumbnail pays
* it once per object and the viewer pays it per frame of a drag. Its inner loop
* evaluates the edge functions incrementally, which is worth about 4x over
* recomputing them per pixel, and this is here so that a change which quietly
* gives that back shows up as a number rather than as a slow phone.
*
* Two shapes, because they cost very differently:
* - **the largest object actually on the network**, 118 vertices and 94 faces;
* - **an adversarial one at the format's ceiling**, 512 vertices and 1004 faces
* scattered at random so that every triangle spans much of the frame. Nothing
* an author would build, but §1.8's limits permit it and a stranger can
* publish it, so it is the number the viewer's off-thread raster and its
* 512px cap exist to survive.
*
* The assertion is deliberately loose — a hundredfold over the measured cost —
* so it catches an order-of-magnitude regression without flaking on a busy CI box.
*/
class SnoRasterBenchmark {
private val real =
SnoParser
.parse("{\"v\":2,\"name\":\"first object\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-1,0,1],[1,0,1],[1,0,-1],[-1,0,-1],[-1,2,1],[1,2,1],[1,2,-1],[-1,2,-1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-2,0,3],[2,0,3],[2,0,-1],[-2,0,-1],[0,4,1],[-1,0,1],[3,0,1],[3,0,-3],[-1,0,-3],[1,4,-1],[-5,0,1],[-1,0,1],[-1,0,-3],[-5,0,-3],[-3,4,-1],[-5,0,5],[-1,0,5],[-1,0,1],[-5,0,1],[-3,4,3],[-1,0,2],[3,0,2],[3,0,-2],[-1,0,-2],[1,4,0],[-3,0,2],[1,0,2],[1,0,-2],[-3,0,-2],[-1,4,0],[-4,0,3],[0,0,3],[0,0,-1],[-4,0,-1],[-2,4,1],[-4,0,-2],[0,0,-2],[0,0,-6],[-4,0,-6],[-2,4,-4],[-8,0,1],[-6,0,1],[-6,0,-1],[-8,0,-1],[-8,2,1],[-8,2,-1],[-8,0,-2],[-6,0,-2],[-6,0,-4],[-8,0,-4],[-8,2,-2],[-8,2,-4],[-8,0,-4],[-6,0,-4],[-6,0,-6],[-8,0,-6],[-8,2,-4],[-8,2,-6],[-8,0,2],[-6,0,2],[-6,0,0],[-8,0,0],[-8,2,2],[-8,2,0],[2,0,7],[4,0,7],[4,0,5],[2,0,5],[2,2,7],[2,2,5],[-2,0,7],[0,0,7],[0,0,5],[-2,0,5],[-2,2,7],[-2,2,5]],\"ticks\":[-118],\"colors\":[226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226,226],\"faces\":[[43,42,46],[44,43,46],[45,44,46],[42,45,46],[42,43,44],[42,44,45],[48,47,51],[49,48,51],[50,49,51],[47,50,51],[47,48,49],[47,49,50],[53,52,56],[54,53,56],[55,54,56],[52,55,56],[52,53,54],[52,54,55],[58,57,61],[59,58,61],[60,59,61],[57,60,61],[57,58,59],[57,59,60],[63,62,66],[64,63,66],[65,64,66],[62,65,66],[62,63,64],[62,64,65],[68,67,71],[69,68,71],[70,69,71],[67,70,71],[67,68,69],[67,69,70],[73,72,76],[74,73,76],[75,74,76],[72,75,76],[72,73,74],[72,74,75],[78,77,81],[79,78,81],[80,79,81],[77,80,81],[77,78,79],[77,79,80],[82,83,84],[82,84,85],[82,85,87],[82,87,86],[83,86,87],[83,87,84],[83,82,86],[85,84,87],[88,89,90],[88,90,91],[88,91,93],[88,93,92],[89,92,93],[89,93,90],[89,88,92],[94,95,96],[94,96,97],[94,97,99],[94,99,98],[95,98,99],[95,99,96],[97,96,99],[100,101,102],[100,102,103],[100,103,105],[100,105,104],[101,104,105],[101,105,102],[101,100,104],[103,102,105],[106,107,108],[106,108,109],[106,109,111],[106,111,110],[107,110,111],[107,111,108],[107,106,110],[109,108,111],[112,113,114],[112,114,115],[112,115,117],[112,117,116],[113,116,117],[113,117,114],[113,112,116],[115,114,117]]}")
.payloadOrNull()!!
private val adversarial: SnoPayload by lazy {
val rnd = Random(7)
val vertices = (0 until 512).joinToString(",") { "[${rnd.nextInt(-8, 9)},${rnd.nextInt(-8, 9)},${rnd.nextInt(-8, 9)}]" }
val colors = (0 until 512).joinToString(",") { (it % 256).toString() }
val faces = (0 until 1004).joinToString(",") { "[${it % 512},${(it + 1) % 512},${(it + 2) % 512}]" }
SnoParser
.parse("""{"v":2,"name":"bench","unit":0,"mode":"solid","vertices":[$vertices],"colors":[$colors],"faces":[$faces]}""")
.payloadOrNull()!!
}
private fun millisPerFrame(
payload: SnoPayload,
size: Int,
lighting: SnoLighting? = null,
): Double {
repeat(WARMUP) { SnoRasterizer.render(payload, size, size, lighting = lighting) }
val start = System.nanoTime()
repeat(RUNS) { SnoRasterizer.render(payload, size, size, lighting = lighting) }
return (System.nanoTime() - start) / 1e6 / RUNS
}
private fun millisToLight(payload: SnoPayload): Double {
repeat(3) { SnoLighting.of(payload) }
val start = System.nanoTime()
repeat(5) { SnoLighting.of(payload) }
return (System.nanoTime() - start) / 1e6 / 5
}
@Test
fun aRealObjectIsCheapToDraw() {
val thumbnail = millisPerFrame(real, THUMBNAIL_PX)
val viewer = millisPerFrame(real, VIEWER_PX)
val full = millisPerFrame(real, 1080)
val adversarialFull = millisPerFrame(adversarial, 1080)
println("real (118v/94f): ${THUMBNAIL_PX}px ${fmt(thumbnail)} ms, ${VIEWER_PX}px ${fmt(viewer)} ms, 1080px ${fmt(full)} ms")
println("adversarial at 1080px: ${fmt(adversarialFull)} ms")
assertTrue(thumbnail < 5.0, "a feed thumbnail of a real object should be far under a frame, was ${fmt(thumbnail)} ms")
assertTrue(viewer < 50.0, "a viewer frame of a real object should be well under a drag's budget, was ${fmt(viewer)} ms")
}
@Test
fun theAdversarialCeilingStaysBounded() {
val viewer = millisPerFrame(adversarial, VIEWER_PX)
println("adversarial (512v/1004f): ${VIEWER_PX}px ${fmt(viewer)} ms")
// Sluggish by design rather than frozen: this one is why the viewer
// rasters off the composition thread and caps its size.
assertTrue(viewer < 500.0, "the ceiling should stay inside the cap's budget, was ${fmt(viewer)} ms")
}
@Test
fun theLightIsPaidOncePerObjectAndNotPerFrame() {
// Winding the faces outward walks a ray from every face against every
// other one, which is quadratic in the face count and by far the most
// expensive thing this feature does. It depends on the object alone, so
// the viewer holds it across a turn; what has to stay cheap is the
// frame, and a lit frame differs from a flat one by three multiplies a
// pixel.
val realCost = millisToLight(real)
val ceiling = millisToLight(adversarial)
val flatFrame = millisPerFrame(real, VIEWER_PX)
val litFrame = millisPerFrame(real, VIEWER_PX, SnoLighting.of(real))
println("winding + light: real (94f) ${fmt(realCost)} ms, adversarial (1004f) ${fmt(ceiling)} ms")
println("a ${VIEWER_PX}px frame of a real object: flat ${fmt(flatFrame)} ms, lit ${fmt(litFrame)} ms")
assertTrue(realCost < 50.0, "lighting a real object should be a blink, was ${fmt(realCost)} ms")
assertTrue(litFrame < flatFrame * 2 + 5, "a lit frame should cost about what a flat one does, was ${fmt(litFrame)} ms")
}
private fun fmt(value: Double) = ((value * 100).toLong() / 100.0).toString()
companion object {
private const val WARMUP = 10
private const val RUNS = 20
private const val THUMBNAIL_PX = 96
private const val VIEWER_PX = 512
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import coil3.Image
import coil3.asImage
import com.vitorpamplona.amethyst.commons.base64Image.toBitmap
@@ -30,3 +32,5 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Image
actual fun PlatformImage.toCoilImage(): Image = toAndroidBitmap().asImage(true)
actual fun base64DataUriToCoilImage(dataUri: String): Image = Base64Image.toBitmap(dataUri).asImage(true)
actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toAndroidBitmap().asImageBitmap()
@@ -5433,4 +5433,35 @@
<string name="cordn_migrate_no_server">Set a media server first — the encrypted documents need somewhere to sit while the other phone fetches them.</string>
<string name="cordn_migrate_exposure_title">What leaves this device</string>
<string name="cordn_migrate_exposure_body">Your group state is encrypted and uploaded to your media server so the other phone can fetch it. It is unreadable to that server, which sees only a size and a time. Delete the blobs afterwards if you want nothing left behind.</string>
<string name="sno_object_title">3D object</string>
<string name="sno_object_details">%1$d vertices · %2$d faces</string>
<string name="sno_object_unreadable">This 3D object could not be read (rule %1$s)</string>
<string name="sno_avatar_title">Cyberspace avatar</string>
<string name="sno_avatar_unpaid">This avatar has not paid for its size, so it is not drawn</string>
<string name="sno_light_on">Lit; tap to draw the object flat</string>
<string name="sno_light_off">Flat; tap to light the object</string>
<string name="sno_object_scale">1 unit = %1$s</string>
<string name="sno_shard_dataspace">Hidden in dataspace · %1$s</string>
<string name="sno_shard_ideaspace">Hidden in ideaspace · %1$s</string>
<string name="sno_avatar_default">Default avatar</string>
<string name="sno_avatar_default_details">No shape published, so everyone sees this one</string>
<string name="cyberspace_bag_title">Hidden at a place</string>
<string name="cyberspace_bag_box">Hidden in a box of %1$s regions</string>
<string name="cyberspace_bag_destination">Hidden in one region, which the hint names exactly</string>
<string name="cyberspace_bag_no_hint">No hint, so this could be anywhere in cyberspace</string>
<string name="cyberspace_bag_out_of_reach">Out of reach: the hint names 2^%1$d regions to search</string>
<string name="cyberspace_bag_search">Search for it</string>
<string name="cyberspace_bag_cancel">Stop</string>
<string name="cyberspace_bag_measuring">Pricing the search on this device…</string>
<string name="cyberspace_bag_progress">Searched %1$s of %2$s regions</string>
<string name="cyberspace_bag_too_slow_minutes">Out of reach: about %1$d minutes of searching on this device</string>
<string name="cyberspace_bag_too_slow_hours">Out of reach: about %1$d hours of searching on this device</string>
<string name="cyberspace_bag_not_found">Not in the box the hint named</string>
<string name="cyberspace_bag_damaged">Found the region, but the contents could not be read</string>
<string name="cyberspace_bag_dropped">%1$d item hidden here did not match its signature and was dropped</string>
<string name="cyberspace_bag_dropped_many">%1$d items hidden here did not match their signatures and were dropped</string>
<string name="cyberspace_bag_empty">Opened, and there was nothing inside</string>
<string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string>
<string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string>
<string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string>
</resources>
@@ -18,31 +18,45 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
package com.vitorpamplona.amethyst.commons.buzz.ui
import androidx.compose.material3.FilledTonalButton
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.join
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.resources.buzz_pin
import com.vitorpamplona.amethyst.commons.resources.buzz_unpin
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Pin/Unpin overflow-menu item for a Buzz channel. [isPinned] picks the label and tints the pin;
* clicking closes the menu, then calls [onToggle].
*/
@Composable
fun JoinChatButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
fun BuzzPinDropdownItem(
isPinned: Boolean,
closeMenu: () -> Unit,
onToggle: () -> Unit,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.follow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isPinned) Res.string.buzz_unpin else Res.string.buzz_pin)) },
onClick = {
closeMenu()
onToggle()
},
)
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.join
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.resources.mute_notifications
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
// Membership actions shared by every channel header (NIP-28 public chats and ephemeral chats).
// They take the action as a callback so the header that knows the account decides what
// joining, leaving or muting means.
@Composable
fun JoinChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = onClick,
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
}
@Composable
fun LeaveChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = onClick,
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
}
}
/** Bell toggle for a channel's notifications; [isMuted] picks the icon and label. */
@Composable
fun MuteChannelButton(
isMuted: Boolean,
onToggle: () -> Unit,
) {
val label =
stringRes(
if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications,
)
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = onToggle,
contentPadding = ZeroPadding,
) {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = label,
modifier = Size20Modifier,
)
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
package com.vitorpamplona.amethyst.commons.music.ui
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
@@ -36,14 +36,9 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@Composable
fun NewMusicTrackButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
fun NewMusicTrackButton(nav: INav) {
FloatingActionButton(
onClick = { nav.nav(Route.NewMusicTrack()) },
modifier = Size55Modifier,
@@ -63,6 +58,6 @@ fun NewMusicTrackButton(
@Composable
private fun NewMusicTrackButtonPreview() {
ThemeComparisonRow {
NewMusicTrackButton(accountViewModel = mockAccountViewModel(), nav = EmptyNav())
NewMusicTrackButton(nav = EmptyNav())
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip17Dm.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder
import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
/**
* Rename a private group chat (NIP-17): edit the subject and write the message that carries the
* change. [onPost] receives the subject (possibly blank) and the message, then the dialog closes.
* Posting is disabled while both are blank.
*/
@Composable
fun ChatroomSubjectDialog(
initialSubject: String,
onPost: (subject: String, message: String) -> Unit,
onClose: () -> Unit,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName = remember { mutableStateOf(initialSubject) }
val message = remember { mutableStateOf("") }
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
onPost(groupName.value, message.value)
onClose()
},
// Nothing to send without a new subject or a message.
isActive = groupName.value.isNotBlank() || message.value.isNotBlank(),
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
}
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
package com.vitorpamplona.amethyst.commons.nip28PublicChat.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -28,27 +28,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** Opens the channel-metadata editor; shown to the channel's owner in the long header. */
@Composable
fun EditButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
fun EditChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) },
onClick = onClick,
contentPadding = ZeroPadding,
) {
Icon(
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Invite-code prompt for joining a closed (invite-only) NIP-29 group. Confirm hands the
* trimmed code to [onJoin] and then closes the dialog through [onDismiss].
*/
@Composable
fun RelayGroupInviteCodeDialog(
onJoin: (code: String) -> Unit,
onDismiss: () -> Unit,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.relay_group_join_title)) },
text = {
Column {
Text(stringRes(Res.string.relay_group_join_code_hint))
OutlinedTextField(
value = code,
onValueChange = { code = it },
singleLine = true,
label = { Text(stringRes(Res.string.relay_group_join_code_label)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(
enabled = code.isNotBlank(),
onClick = {
onJoin(code.trim())
onDismiss()
},
) {
Text(stringRes(Res.string.relay_group_join_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
},
)
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.add_to_messages
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Add/Remove overflow-menu item for whether a relay group shows in Messages. [onMyList] picks
* the label and icon; clicking closes the menu, then calls [onToggle].
*/
@Composable
fun RelayGroupMessagesDropdownItem(
onMyList: Boolean,
closeMenu: () -> Unit,
onToggle: () -> Unit,
) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) },
onClick = {
closeMenu()
onToggle()
},
)
}
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip32Labeling.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Asks for a hashtag to label a post with (NIP-32, `#t` namespace). The typed text is
* normalized the way hashtag feeds store tags: leading `#` and whitespace stripped,
* lowercased. Confirm hands that value to [onConfirm] and closes through [onDismiss].
*/
@Composable
fun HashtagLabelDialog(
onConfirm: (hashtag: String) -> Unit,
onDismiss: () -> Unit,
) {
var hashtag by remember { mutableStateOf("") }
// Strip the leading '#', drop whitespace and lowercase so the stored label matches the
// hashtag-feed convention. A blank result disables the confirm button.
val sanitized =
hashtag
.trim()
.removePrefix("#")
.filterNot { it.isWhitespace() }
.lowercase()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.add_hashtag_label_title)) },
text = {
Column {
Text(stringRes(Res.string.add_hashtag_label_explainer))
OutlinedTextField(
value = hashtag,
onValueChange = { hashtag = it },
singleLine = true,
label = { Text(stringRes(Res.string.add_hashtag_label_field)) },
prefix = { Text("#") },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = sanitized.isNotEmpty(),
onClick = {
onConfirm(sanitized)
onDismiss()
},
) {
Text(stringRes(Res.string.add_hashtag_label_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip34Git.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.git_status_close
import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged
import com.vitorpamplona.amethyst.commons.resources.git_status_reopen
import com.vitorpamplona.amethyst.commons.ui.stringRes
/** Compact sizing shared by the small action buttons on git cards. */
val GitCardButtonHeight = Modifier.height(32.dp)
val GitCardButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
/**
* NIP-34 status buttons for an issue, patch or pull request: "Reopen" when it is
* [closedOrApplied], otherwise "Mark merged" (patches and PRs only, [isPatchOrPr]) and "Close".
* The host decides who may see them and publishes the status event.
*/
@Composable
fun GitStatusButtons(
closedOrApplied: Boolean,
isPatchOrPr: Boolean,
onReopen: () -> Unit,
onMarkMerged: () -> Unit,
onClose: () -> Unit,
) {
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (closedOrApplied) {
FilledTonalButton(
onClick = onReopen,
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
} else {
if (isPatchOrPr) {
FilledTonalButton(
onClick = onMarkMerged,
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
OutlinedButton(
onClick = onClose,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = GitCardButtonHeight,
contentPadding = GitCardButtonPadding,
) {
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
}
}
@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_join_request
import com.vitorpamplona.amethyst.commons.resources.relay_leave_request
import com.vitorpamplona.amethyst.commons.resources.relay_member_added
import com.vitorpamplona.amethyst.commons.resources.relay_member_removed
import com.vitorpamplona.amethyst.commons.resources.relay_members_added
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_removed
import com.vitorpamplona.amethyst.commons.resources.relay_membership_list
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
/** NIP-43 kind 13534: the relay's current member list, shown as a count. */
@Composable
fun RelayMembershipListCard(event: RelayMembershipListEvent) {
val memberCount = remember(event) { event.members().size }
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = stringRes(Res.string.relay_membership_list),
subtitle = stringRes(Res.string.relay_members_count, memberCount),
)
}
/** NIP-43 kind 8000: members the relay added. */
@Composable
fun RelayAddMemberCard(event: RelayAddMemberEvent) {
val memberKeys = remember(event) { event.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_added)
} else {
stringRes(Res.string.relay_members_added, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = title,
subtitle = subtitle,
)
}
/** NIP-43 kind 8001: members the relay removed. */
@Composable
fun RelayRemoveMemberCard(event: RelayRemoveMemberEvent) {
val memberKeys = remember(event) { event.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_removed)
} else {
stringRes(Res.string.relay_members_removed, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = title,
subtitle = subtitle,
)
}
/** NIP-43 kind 28934: a request to join the relay. */
@Composable
fun RelayJoinRequestCard() {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = stringRes(Res.string.relay_join_request),
subtitle = null,
)
}
/** NIP-43 kind 28936: a request to leave the relay. */
@Composable
fun RelayLeaveRequestCard() {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = stringRes(Res.string.relay_leave_request),
subtitle = null,
)
}
@Composable
private fun RelayMemberEventCard(
icon: MaterialSymbol,
title: String,
subtitle: String?,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Preview
@Composable
private fun RelayMembershipListCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = "Relay membership list",
subtitle = "42 members",
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Member added to relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayRemoveMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = "Member removed from relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayJoinRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Relay join request",
subtitle = null,
)
}
}
@Preview
@Composable
private fun RelayLeaveRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = "Relay leave request",
subtitle = null,
)
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip52Calendar.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
/**
* Going / Maybe / Not going buttons for a NIP-52 calendar event. The one matching
* [currentStatus] is filled in its status colour; the others are outlined. [onSelect] publishes
* the chosen status.
*/
@Composable
fun CalendarRsvpButtons(
currentStatus: RSVPStatusTag.STATUS?,
onSelect: (RSVPStatusTag.STATUS) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
}
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
RsvpButtonLabel(label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
) {
RsvpButtonLabel(label)
}
}
}
private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
private fun RsvpButtonLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip56Reports.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.resources.Res
import com.vitorpamplona.amethyst.commons.resources.block_only
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user
import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal
import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation
import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public
import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam
import com.vitorpamplona.amethyst.commons.resources.report_dialog_title
import com.vitorpamplona.amethyst.commons.resources.report_malware
import com.vitorpamplona.amethyst.commons.resources.violence
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
/** The NIP-56 reasons the dialog offers, in display order, with their labels. */
private val ReportReasons =
listOf(
ReportType.SPAM to Res.string.report_dialog_spam,
ReportType.PROFANITY to Res.string.report_dialog_profanity,
ReportType.IMPERSONATION to Res.string.report_dialog_impersonation,
ReportType.NUDITY to Res.string.report_dialog_nudity,
ReportType.ILLEGAL to Res.string.report_dialog_illegal,
ReportType.MALWARE to Res.string.report_malware,
ReportType.VIOLENCE to Res.string.violence,
)
/**
* Block-or-report dialog for a post. [onBlock] hides the author; [onReport] files a public
* NIP-56 report with the chosen reason and optional text (the host also hides the author).
* Both close the dialog through [onDismiss] afterwards.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportDialog(
onBlock: () -> Unit,
onReport: (reason: ReportType, additionalReason: String) -> Unit,
onDismiss: () -> Unit,
) {
val labels = ReportReasons.map { stringRes(it.second) }
// Keyed on the labels so a locale change while the dialog is open relabels the spinner;
// typing in the reason field recomposes with equal labels and reuses the list.
val reasonOptions = remember(labels) { labels.map { TitleExplainer(it) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableIntStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringRes(id = Res.string.report_dialog_title)) },
navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } },
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround,
) {
SpacerH16()
SectionHeader(text = stringRes(id = Res.string.block_only))
SpacerH16()
Text(
text = stringRes(Res.string.report_dialog_blocking_a_user),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_block_hide_user_btn),
icon = MaterialSymbols.Block,
onClick = {
onBlock()
onDismiss()
},
)
SpacerH16()
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness)
SpacerH16()
SectionHeader(text = stringRes(Res.string.report_dialog_report_btn))
SpacerH16()
Text(stringRes(Res.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringRes(Res.string.report_dialog_select_reason_label),
placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = { selectedReason = it },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = {
Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder))
},
label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_post_report_btn),
icon = MaterialSymbols.Report,
enabled = selectedReason in 0..ReportReasons.lastIndex,
onClick = {
onReport(ReportReasons[selectedReason].first, additionalReason)
onDismiss()
},
)
}
}
}
}
@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) =
Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp,
)
@Composable
private fun ActionButton(
text: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(containerColor = LightRedColor),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nip73ExternalIds.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
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.hashtag_exclusive
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
/** Header card for a NIP-22 comment scoped to a hashtag (NIP-73 `#t` external id): tag icon + the tagged topic as a link. */
@Composable
fun HashtagExternalIdCard(
topic: String,
linkInteractionListener: LinkInteractionListener,
) {
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = stringRes(id = Res.string.hashtag_exclusive),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("hashtag", null, linkInteractionListener),
) {
append(topic)
}
},
style =
LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
),
maxLines = 1,
)
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.products
package com.vitorpamplona.amethyst.commons.nip99Classifieds.ui
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
@@ -33,13 +33,9 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewProductButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
fun NewProductButton(nav: INav) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewProduct())
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nipCCGeocaching.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
/**
* The addresses logged as found in a Finds feed ([feedState]), as `37516:pubkey:d` strings.
* Empty until the feed has loaded.
*
* Deriving it from the feed rather than scanning the cache directly means it tracks a new log
* the moment the feed does, which is what makes a hunt's progress bar move as you walk it.
*/
@Composable
fun rememberFoundCacheIds(feedState: FeedState): Set<String> =
when (feedState) {
is FeedState.Loaded -> rememberLoadedFoundCacheIds(feedState)
else -> emptySet()
}
/**
* Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated
* through its inner flow, so reading `.value` here would freeze the set at whatever the feed held
* when the hunt screen first composed — the progress bar would never move.
*/
@Composable
private fun rememberLoadedFoundCacheIds(state: FeedState.Loaded): Set<String> {
val loaded by state.feed.collectAsStateWithLifecycle()
return remember(loaded) {
loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() }
}
}
@@ -0,0 +1,162 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.nipsOnNostr.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.kinds
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
/** Card for a NIP published on Nostr ("NIPs on Nostr"): title, summary or opening text, and the kinds it defines. */
@Composable
fun NipCard(noteEvent: NipTextEvent) {
val title = remember(noteEvent) { noteEvent.title() }
val kinds = remember(noteEvent) { noteEvent.kinds() }
Column(
modifier =
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
verticalArrangement = SpacedBy5dp,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
Text(
text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content },
style = MaterialTheme.typography.bodySmall,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (kinds.isNotEmpty()) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
horizontalArrangement = SpacedBy5dp,
verticalArrangement = SpacedBy5dp,
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(Res.string.kinds),
)
kinds.forEach {
NoPaddingSuggestionChip(
label = it.toString(),
)
}
}
}
}
}
@Preview
@Composable
fun NipCardPreview() {
val event =
NipTextEvent(
id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0",
pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
createdAt = 1767978398,
content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.",
sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21",
tags =
arrayOf(
arrayOf("d", "trusted-translations"),
arrayOf("title", "Trusted Translations"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("client", "nostrhub.io"),
),
)
ThemeComparisonColumn(
toPreview = { NipCard(event) },
)
}
@Composable
private fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look
color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color
modifier = modifier,
) {
Text(
text = label,
// Apply desired internal padding to the Text itself
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -18,20 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays
package com.vitorpamplona.amethyst.commons.profile.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relays
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun RelaysTabHeader(
baseUser: User,
accountViewModel: AccountViewModel,
) {
fun RelaysTabHeader() {
Text(text = stringRes(Res.string.relays))
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import coil3.Image
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
@@ -28,3 +29,9 @@ expect fun PlatformImage.toCoilImage(): Image
/** Decodes a base64 `data:` image URI into a Coil image; throws on malformed input. */
expect fun base64DataUriToCoilImage(dataUri: String): Image
/**
* Converts a decoded [PlatformImage] straight into a Compose image, for a
* drawing that changes every frame and so has no business in an image cache.
*/
expect fun PlatformImage.toComposeImageBitmap(): ImageBitmap
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.service.image.toCoilImage
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
/**
* A Simple Nostr Object to draw, addressed by the event that carried it.
*
* [cacheKey] is what Coil's memory cache keys on, so it has to name everything
* that changes the pixels: the event, the view, the size — and the colours.
*
* The geometry needs no part in the key, because an addressable object
* republished under the same `d` arrives as a new event id. The colours are not
* like that. DECK-0003 §1.3a lets `colors` index a palette held in *another*
* event, and until that event is fetched the object is drawn against the
* built-in (§1.3b), so one event id legitimately produces two different
* pictures within a second of each other. Keyed on the id alone, the first
* would be served from memory forever and the fetch would buy nothing.
*/
@Stable
data class SnoObjectToRender(
val payload: SnoPayload,
val eventId: String,
val size: Int,
val yawDegrees: Float = SnoRasterizer.DEFAULT_YAW_DEGREES,
val pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES,
val background: Int = 0,
) {
val cacheKey: String get() = "sno:$eventId:$size:$yawDegrees:$pitchDegrees:$background:${colourSignature()}"
/**
* A stand-in for the resolved palette: the colours themselves, hashed.
*
* The palette reference cannot serve instead, because what changes is not
* which palette was named but whether it has arrived yet, and the payload
* carries only the name. The colours are already resolved to ARGB by the
* parser, so they are the thing that actually differs.
*/
private fun colourSignature(): Int = 31 * payload.colors.contentHashCode() + (payload.faceColors?.contentHashCode() ?: 0)
}
/**
* Draws an SNO into Coil's pipeline, so a thumbnail is rasterised once and then
* served from the memory cache like any other image.
*
* The same shape as [com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher]:
* pixels out of a headless decoder in `commons`, into a [PlatformImage], into a
* Coil image. Nothing here is platform-specific.
*/
@Stable
class SnoFetcher(
private val data: SnoObjectToRender,
) : Fetcher {
override suspend fun fetch(): FetchResult {
val pixels =
SnoRasterizer.render(
payload = data.payload,
width = data.size,
height = data.size,
yawDegrees = data.yawDegrees,
pitchDegrees = data.pitchDegrees,
background = data.background,
)
return ImageFetchResult(
image = PlatformImage.create(pixels, data.size, data.size).toCoilImage(),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<SnoObjectToRender> {
override fun create(
data: SnoObjectToRender,
options: Options,
imageLoader: ImageLoader,
): Fetcher = SnoFetcher(data)
}
object SKeyer : Keyer<SnoObjectToRender> {
override fun key(
data: SnoObjectToRender,
options: Options,
): String = data.cacheKey
}
}
@@ -0,0 +1,304 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
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.sno_light_off
import com.vitorpamplona.amethyst.commons.resources.sno_light_on
import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap
import com.vitorpamplona.amethyst.commons.sno.SnoLighting
import com.vitorpamplona.amethyst.commons.sno.SnoRasterScratch
import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
/** How many degrees a one-finger drag of one pixel turns the object. */
private const val DEGREES_PER_PIXEL = 0.5f
/** Past straight up or straight down there is nothing more to see. */
private const val MAX_PITCH = 89f
private const val MIN_ZOOM = 0.5f
private const val MAX_ZOOM = 8f
/**
* The raster size while a gesture is in flight.
*
* Turning an object is the thing this view is for, so the frames during a turn
* are the ones that must not stutter. They are also the ones nobody is
* inspecting closely: the sharp frame is the one you stop on. So the raster
* drops while a finger is down and goes back to full size when it lifts.
*/
private const val GESTURE_RASTER_PX = 384
/**
* The largest raster produced when the object is at rest, before zoom.
*
* A real object at 1080px costs under 5ms, so full resolution is affordable on
* anything a phone will ask for; this only bounds the memory a very large
* window or a deep zoom could otherwise demand.
*/
private const val MAX_RESTING_RASTER_PX = 1440
/** The light switch, small enough to sit over a corner of the object. */
private val TOGGLE_SIZE = 32.dp
private val TOGGLE_INSET = 4.dp
/**
* A Simple Nostr Object the reader can turn, move and zoom.
*
* One finger turns it, two move and scale it, and a double tap puts it back.
*
* Only turning needs a new raster — it changes which faces point at you.
* Moving and scaling are a [graphicsLayer] transform of the frame already
* drawn, which costs nothing per frame; when the gesture ends the object is
* redrawn at the resolution the zoom now deserves, so it sharpens where it
* settled rather than staying an upscaled bitmap.
*
* A solid object also offers a light switch, because turning something over to
* understand its shape is the one case DECK-0003 §4 has in mind when it allows
* a client to light an object: unlit, a cube of a single colour is a flat
* hexagon however far you turn it. The feed keeps §4's unlit default, so an
* object looks the same everywhere it is quoted, and the switch starts off.
* See [SnoLighting] for what it does and what it costs.
*
* Unlike [SnoThumbnail] this does not go through Coil: a turn produces a new
* angle every frame and each would become its own cache entry, evicting the
* image cache within a second. Nothing here is worth caching — the object is
* the event, and redrawing it is cheaper than remembering it.
*/
@Composable
fun SnoObjectViewer(
payload: SnoPayload,
eventId: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
background: Color = Color.Transparent,
) {
var yaw by remember(eventId) { mutableFloatStateOf(SnoRasterizer.DEFAULT_YAW_DEGREES) }
var pitch by remember(eventId) { mutableFloatStateOf(SnoRasterizer.DEFAULT_PITCH_DEGREES) }
var zoom by remember(eventId) { mutableFloatStateOf(1f) }
var panX by remember(eventId) { mutableFloatStateOf(0f) }
var panY by remember(eventId) { mutableFloatStateOf(0f) }
var gesturing by remember(eventId) { mutableStateOf(false) }
var lit by remember(eventId) { mutableStateOf(false) }
// Winding the faces outward walks a ray from every face against every other
// one, so it is worth its own cache: it depends on the object alone and not
// on the angle, and a turn would otherwise pay for it sixty times a second.
val lighting = remember(payload) { LightingCache() }
val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb()
BoxWithConstraints(
modifier =
modifier
.pointerInput(eventId) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
gesturing = true
try {
do {
val event = awaitPointerEvent(PointerEventPass.Main)
val moved = event.changes.any { it.positionChanged() }
if (moved) {
if (event.changes.size > 1) {
// Two fingers move and scale the object.
val scale = event.calculateZoom()
if (scale != 1f) zoom = (zoom * scale).coerceIn(MIN_ZOOM, MAX_ZOOM)
val pan = event.calculatePan()
panX += pan.x
panY += pan.y
} else {
// One finger turns it.
val pan = event.calculatePan()
yaw = wrapDegrees(yaw + pan.x * DEGREES_PER_PIXEL)
pitch = (pitch - pan.y * DEGREES_PER_PIXEL).coerceIn(-MAX_PITCH, MAX_PITCH)
}
event.changes.forEach { it.consume() }
}
} while (event.changes.any { it.pressed })
} finally {
gesturing = false
}
}
}.pointerInput(eventId) {
detectTapGestures(
onDoubleTap = {
yaw = SnoRasterizer.DEFAULT_YAW_DEGREES
pitch = SnoRasterizer.DEFAULT_PITCH_DEGREES
zoom = 1f
panX = 0f
panY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
val onScreenPx = with(LocalDensity.current) { minOf(maxWidth, maxHeight).roundToPx() }
if (onScreenPx <= 0) return@BoxWithConstraints
// Coarse while a finger is down, full — and scaled by how far in the
// reader has zoomed — once it lifts.
val restingPx = minOf((onScreenPx * zoom).toInt(), MAX_RESTING_RASTER_PX)
val rasterPx = if (gesturing) minOf(onScreenPx, GESTURE_RASTER_PX) else restingPx
// Held across angles rather than recomputed in composition: the previous
// frame stays on screen while the next is drawn, so a heavy object makes
// the turn coarse instead of freezing the gesture.
var bitmap by remember(eventId) { mutableStateOf<ImageBitmap?>(null) }
// The buffers every frame of the turn draws into. Held here rather than
// allocated per frame: see [SnoRasterScratch] — a drag that allocates
// them spends more of the device on collecting the last frame's pair
// than on drawing the next one.
val scratch = remember(eventId) { SnoRasterScratch() }
// One collector, and angles conflated into it. A finger produces a new
// angle far faster than this device draws one, and `render` runs to
// completion whether or not anyone still wants it, so an effect
// restarted per angle would leave several frames in flight at once —
// all but one of them discarded, and all of them writing over each
// other's `scratch`. Conflating keeps only the newest angle waiting, so
// exactly one frame is ever being drawn and the turn still lands on
// wherever the finger actually stopped.
LaunchedEffect(eventId, payload, rasterPx, backgroundArgb) {
snapshotFlow { Triple(yaw, pitch, lit) }
.conflate()
.collect { (atYaw, atPitch, isLit) ->
bitmap =
withContext(Dispatchers.Default) {
val pixels =
SnoRasterizer.render(
payload = payload,
width = rasterPx,
height = rasterPx,
yawDegrees = atYaw,
pitchDegrees = atPitch,
background = backgroundArgb,
lighting = if (isLit) lighting.of(payload) else null,
scratch = scratch,
)
// Copies into the bitmap, so `scratch` is free to be
// drawn over by the next frame.
PlatformImage.create(pixels, rasterPx, rasterPx).toComposeImageBitmap()
}
}
}
bitmap?.let { drawn ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Image(
bitmap = drawn,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
translationX = panX,
translationY = panY,
),
)
}
}
// Only a filled object has faces for a light to fall on; points and
// wireframes look the same lit or not, so they are not offered a switch.
if (payload.mode == SnoMode.SOLID && payload.faceCount > 0) {
IconButton(
onClick = { lit = !lit },
modifier = Modifier.align(Alignment.TopEnd).padding(TOGGLE_INSET).size(TOGGLE_SIZE),
) {
Icon(
MaterialSymbols.BrightnessMedium,
contentDescription = stringResource(if (lit) Res.string.sno_light_on else Res.string.sno_light_off),
tint =
if (lit) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
filled = lit,
)
}
}
}
}
/**
* One object's lighting, kept until the object changes.
*
* Not Compose state: it is written from the drawing coroutine and read only
* there, and making it state would recompose the view for a value nothing in
* composition looks at.
*/
private class LightingCache {
private var lighting: SnoLighting? = null
fun of(payload: SnoPayload): SnoLighting = lighting ?: SnoLighting.of(payload).also { lighting = it }
}
private fun wrapDegrees(value: Float): Float {
var wrapped = value % 360f
if (wrapped < 0f) wrapped += 360f
return wrapped
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.sno.SnoObjectToRender
import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
/**
* A Simple Nostr Object drawn at a fixed size, for a feed or a thread.
*
* Rasterised once per (event, size, view) through Coil, so scrolling past the
* same object costs a memory-cache hit rather than a re-render.
*/
@Composable
fun SnoThumbnail(
payload: SnoPayload,
eventId: String,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
yawDegrees: Float = SnoRasterizer.DEFAULT_YAW_DEGREES,
pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES,
background: Color = Color.Transparent,
) {
val pixelSize = with(LocalDensity.current) { size.roundToPx() }
val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb()
val request =
remember(payload, eventId, pixelSize, yawDegrees, pitchDegrees, backgroundArgb) {
SnoObjectToRender(
payload = payload,
eventId = eventId,
size = pixelSize,
yawDegrees = yawDegrees,
pitchDegrees = pitchDegrees,
background = backgroundArgb,
)
}
Box(modifier.size(size), contentAlignment = Alignment.Center) {
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier.size(size),
)
}
}
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
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
/**
* [Crossfade][MyCrossfade] between [targetState]s when [enabled], a plain swap otherwise (the app
* turns it off in performance mode). The animated path defers building its transition until the
* target first changes; see [DeferredCrossfade].
*/
@Composable
fun <T> CrossfadeIfEnabled(
targetState: T,
enabled: Boolean,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
if (enabled) {
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
} else {
Box(modifier, contentAlignment) {
content(targetState)
}
}
}
/** 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() } }
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.add_to_public_bookmarks
import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Round bookmark toggle: a filled, primary-tinted bookmark when [isBookmarked], an outlined
* "add" bookmark otherwise. [onToggle] adds or removes the bookmark.
*
* A compact clickable glyph rather than a Material IconButton, whose 48dp minimum touch target
* would stand taller than a title it sits beside; [iconSize] defaults to one title line.
*/
@Composable
fun BookmarkToggleButton(
isBookmarked: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
) {
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = onToggle,
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
}
@@ -18,31 +18,36 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink
/**
* The `#tag` link a note header shows when the note carries a hashtag the viewer follows,
* explaining why it is in their feed. Single line, ellipsized.
*/
@Composable
fun LeaveChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
fun FollowedHashtagLabel(
tag: String,
onClick: () -> Unit,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.unfollow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableTextColor(
"#$tag",
linkColor = MaterialTheme.colorScheme.lessImportantLink,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
onClick = onClick,
)
}
}
}
@@ -18,31 +18,38 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.join
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.resources.pow_settings_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Composable
fun JoinChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.follow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
@Preview
fun ProofOfWorkPillPreview() {
ThemeComparisonColumn(
toPreview = { ProofOfWorkPill(pow = 24, onClick = {}) },
)
}
/**
* Compact pill showing the proof of work a received note carries: a gear plus
* the difficulty in leading zero bits. Sits inline in note headers, so it stays
* at text height. [onClick] is where the host explains what the number means.
*/
@Composable
fun ProofOfWorkPill(
pow: Int,
onClick: () -> Unit,
) {
HeaderPill(
symbol = MaterialSymbols.Manufacturing,
text = pow.toString(),
contentDescription = stringRes(Res.string.pow_settings_title),
onClick = onClick,
)
}
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_default_details
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_title
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_unpaid
import com.vitorpamplona.amethyst.commons.resources.sno_object_details
import com.vitorpamplona.amethyst.commons.resources.sno_object_scale
import com.vitorpamplona.amethyst.commons.sno.SnoDefaultAvatar
import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.quartz.cyberspace.CyberspaceScale
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import org.jetbrains.compose.resources.stringResource
private val AVATAR_THUMBNAIL_SIZE = 96.dp
/**
* A cyberspace avatar (`CYBERSPACE_V2.md` §8.10 kind 11333), which is an SNO
* payload in a replaceable container.
*/
@Composable
fun SnoAvatarCard(
payload: SnoPayload,
eventId: String,
name: String?,
onClick: (() -> Unit)? = null,
) {
val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp)
Row(
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
verticalAlignment = Alignment.CenterVertically,
) {
SnoThumbnail(
payload = payload,
eventId = eventId,
size = AVATAR_THUMBNAIL_SIZE,
contentDescription = name ?: payload.name.ifBlank { null },
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = name ?: payload.name.ifBlank { stringResource(Res.string.sno_avatar_title) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.sno_object_details, payload.vertexCount, payload.faceCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// An avatar's `unit` is what it was priced on (§8.10 pays for reach
// as well as detail), so it is the one card where the scale is not
// only informative but the reason the work came out as it did.
Text(
text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* The avatar of someone who has not published a shape.
*
* §8.10 makes a `kind 11333` with empty content the default avatar: no
* geometry, no work owed, and what everyone is until they adopt something.
* Drawing nothing for it is correct and unhelpful — the note disappears out of
* the feed, which reads as a fault — so it is drawn as the wireframe
* icosahedron the reference puts in its place, which is also a shape nobody
* could mistake for one somebody made.
*/
@Composable
fun SnoAvatarDefaultCard(eventId: String) {
Row(
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SnoThumbnail(
payload = SnoDefaultAvatar.payload,
eventId = eventId,
size = AVATAR_THUMBNAIL_SIZE,
contentDescription = stringResource(Res.string.sno_avatar_default),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = stringResource(Res.string.sno_avatar_default),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.sno_avatar_default_details),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* What stands in for an avatar a client may not draw.
*
* `CYBERSPACE_V2.md` §8.10 is normative here: "a client MUST NOT draw an avatar
* event that is not paid, or that carries content it cannot read". An avatar is
* the one thing in cyberspace that lands on other people's screens whether they
* asked for it or not, so its size and its detail are paid for in proof of work
* on the event that publishes it, and a client draws nothing it cannot verify
* has paid.
*/
@Composable
fun SnoAvatarUnpaidCard() {
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = stringResource(Res.string.sno_avatar_unpaid),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.sno_object_details
import com.vitorpamplona.amethyst.commons.resources.sno_object_scale
import com.vitorpamplona.amethyst.commons.resources.sno_object_title
import com.vitorpamplona.amethyst.commons.resources.sno_object_unreadable
import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.quartz.cyberspace.CyberspaceScale
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import org.jetbrains.compose.resources.stringResource
private val THUMBNAIL_SIZE = 96.dp
/**
* A Simple Nostr Object in a feed or a thread (DECK-0003 kind 33331).
*
* The geometry is the event, so there is nothing to fetch and nothing to
* expand: the card draws what it already has. Tapping opens it where it can be
* turned.
*
* The scale line is not decoration. §1.6's `unit` is the only field separating
* two objects with byte-identical geometry, and at `0` and `40` those two are a
* molecule and a mountain; a card that prints the vertex count for both has
* said nothing about the difference. [CyberspaceScale] turns the exponent into
* a length.
*
* @param footnote one more line under the scale, for something the container
* knows and the payload does not — a shard's place, say.
*/
@Composable
fun SnoObjectCard(
payload: SnoPayload,
eventId: String,
onClick: (() -> Unit)? = null,
footnote: String? = null,
) {
val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp)
Row(
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
verticalAlignment = Alignment.CenterVertically,
) {
SnoThumbnail(
payload = payload,
eventId = eventId,
size = THUMBNAIL_SIZE,
contentDescription = payload.name.ifBlank { null },
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = payload.name.ifBlank { stringResource(Res.string.sno_object_title) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.sno_object_details, payload.vertexCount, payload.faceCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.sno_object_scale, CyberspaceScale.describeUnit(payload.unit)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
footnote?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* What a client shows instead of an object it may not draw.
*
* DECK-0003 §3.1: a client that receives an event whose content fails §1.9 MUST
* NOT render it and SHOULD say why rather than failing silently — hence the
* rule number, which is the whole reason the parser hands one back.
*/
@Composable
fun SnoObjectUnreadableCard(rule: String) {
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = stringResource(Res.string.sno_object_unreadable, rule),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import coil3.Image
import coil3.asImage
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
@@ -36,3 +38,5 @@ actual fun base64DataUriToCoilImage(dataUri: String): Image {
val encoded = SkiaImage.makeFromEncoded(Base64.decode(payload))
return Bitmap.makeFromImage(encoded).asImage(true)
}
actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap()
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import coil3.Image
import coil3.asImage
import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage
@@ -29,3 +31,5 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Image
actual fun PlatformImage.toCoilImage(): Image = toSkiaBitmap().asImage(true)
actual fun base64DataUriToCoilImage(dataUri: String): Image = Base64Image.toPlatformImage(dataUri).toCoilImage()
actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap()
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import androidx.compose.ui.graphics.ImageBitmap
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* The path a drawn object actually takes on this platform: rasteriser to
* [PlatformImage] to a Compose image. Compiling proves the expect/actual lines
* up; this proves the pixels survive the trip.
*/
class SnoComposeImageBridgeTest {
private val json =
"""{"v":2,"name":"tetra","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]]}"""
@Test
fun aRasterSurvivesTheTripToCompose() {
val payload = SnoParser.parse(json).payloadOrNull()
assertNotNull(payload)
val size = 48
val pixels = SnoRasterizer.render(payload, size, size, yawDegrees = 0f, pitchDegrees = 0f, background = 0xFF000000.toInt())
assertEquals(0xFFFF0000.toInt(), pixels[(size / 2) * size + size / 2], "the raster itself should be red in the middle")
val bitmap: ImageBitmap = PlatformImage.create(pixels, size, size).toComposeImageBitmap()
assertEquals(size, bitmap.width)
assertEquals(size, bitmap.height)
val readBack = IntArray(size * size)
bitmap.readPixels(readBack)
assertEquals(0xFFFF0000.toInt(), readBack[(size / 2) * size + size / 2], "and still red after the conversion")
assertTrue(readBack.any { it == 0xFF000000.toInt() }, "the background should survive too")
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
/**
* What Coil's memory cache is allowed to treat as the same picture.
*
* The interesting case is the one DECK-0003 §1.3a creates: an object whose
* `colors` index a palette in another event is drawn against the built-in until
* that event arrives (§1.3b), so a single event id produces two different
* pictures. A key that names only the id would serve the first of them forever.
*/
class SnoFetcherKeyTest {
private fun payload(colors: String): SnoPayload =
SnoParser
.parse(
"""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":$colors,"faces":[[0,1,2]]}""",
).payloadOrNull()!!
private fun render(payload: SnoPayload) = SnoObjectToRender(payload, eventId = "abc", size = 96)
@Test
fun theSameObjectInTheSameViewIsTheSamePicture() {
assertEquals(render(payload("[238,238,238]")).cacheKey, render(payload("[238,238,238]")).cacheKey)
}
@Test
fun repaintingTheSameEventInAnotherPaletteIsADifferentPicture() {
assertNotEquals(
render(payload("[238,238,238]")).cacheKey,
render(payload("[225,225,225]")).cacheKey,
"the same id in different colours must not be served from one cache entry",
)
}
@Test
fun aColourOnAFaceCountsToo() {
// §1.4a's facecolors replace the fill without touching `colors`, so a
// key built from the vertex colours alone would miss them entirely.
val plain = payload("[238,238,238]")
val flat =
SnoParser
.parse(
"""{"v":2,"name":"t","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[238,238,238],"faces":[[0,1,2]],"facecolors":[225]}""",
).payloadOrNull()!!
assertNotEquals(render(plain).cacheKey, render(flat).cacheKey)
}
@Test
fun theViewAndTheSizeStillSeparatePictures() {
val p = payload("[238,238,238]")
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 192).cacheKey)
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "abc", 96, yawDegrees = 90f).cacheKey)
assertNotEquals(render(p).cacheKey, SnoObjectToRender(p, "def", 96).cacheKey)
}
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.compose.setSingletonImageLoaderFactory
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* The card actually draws, in real Compose.
*
* Everything under this — the parser, the rasterizer, the image bridge — has
* its own tests, and all of them passed while two defects sat in the Compose
* layer above: a detail line that clipped mid-word because its column had no
* width to work with, and a viewer that rasterised on the composition thread.
* Compiling proved nothing about either. This renders the card offscreen
* through `ImageComposeScene` (no display, no device) and asserts the object
* reached the screen through Coil, the fetcher and the platform image bridge.
*/
class SnoObjectCardRenderTest {
/** A flat gold triangle: one colour, so it is unmistakable in the pixels. */
private val gold =
"""{"v":2,"name":"Gold","unit":0,"mode":"solid","vertices":[[-4,-4,0],[4,-4,0],[0,4,0]],"colors":[249,249,249],"faces":[[0,1,2]]}"""
@Test
fun theCardDrawsTheObject() {
val payload = SnoParser.parse(gold).payloadOrNull()
assertTrue(payload != null, "fixture did not parse")
val width = 700
val height = 260
val scene = ImageComposeScene(width = width, height = height, density = Density(2f))
try {
scene.setContent {
setSingletonImageLoaderFactory { ctx: PlatformContext ->
ImageLoader
.Builder(ctx)
.components {
add(SnoFetcher.Factory)
add(SnoFetcher.SKeyer)
}.build()
}
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxSize()) {
SnoObjectCard(payload = payload!!, eventId = "render-test", onClick = {})
}
}
}
}
// Coil resolves off the composition, so the first frames are the
// placeholder. Poll rather than sleeping a fixed amount.
// `return@repeat` would be a continue, not a break, and the
// assertion would then read whatever the last frame happened to be.
var goldPixels = 0
for (poll in 0 until POLLS) {
val pixels = scene.render().toPixels(width, height)
goldPixels = pixels.count { it.isGold() }
if (goldPixels > 0) break
Thread.sleep(POLL_MILLIS)
}
assertTrue(
goldPixels > 200,
"the object should have reached the card through Coil and the image bridge; found $goldPixels gold pixels",
)
} finally {
scene.close()
}
}
@Test
fun theViewerDrawsTheObject() {
// The viewer has its own composition path — BoxWithConstraints sizing,
// an off-thread raster held across angles, and a graphicsLayer for zoom
// and pan. None of that is exercised by the card's path.
val payload = SnoParser.parse(gold).payloadOrNull()
assertTrue(payload != null, "fixture did not parse")
val side = 400
val scene = ImageComposeScene(width = side, height = side, density = Density(2f))
try {
scene.setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.surface) {
SnoObjectViewer(
payload = payload!!,
eventId = "viewer-test",
modifier = Modifier.size(200.dp),
)
}
}
}
var goldPixels = 0
for (poll in 0 until POLLS) {
val pixels = scene.render().toPixels(side, side)
goldPixels = pixels.count { it.isGold() }
if (goldPixels > 0) break
Thread.sleep(POLL_MILLIS)
}
assertTrue(goldPixels > 200, "the viewer should have drawn the object; found $goldPixels gold pixels")
} finally {
scene.close()
}
}
/** Palette index 249 is `#ffd300`, which nothing else on the card is. */
private fun Int.isGold(): Boolean {
val r = (this shr 16) and 0xFF
val g = (this shr 8) and 0xFF
val b = this and 0xFF
return r > 0xE0 && g in 0xB0..0xF0 && b < 0x40
}
/** The rendered frame as ARGB, through PNG so no Skia pixel layout is assumed. */
private fun Image.toPixels(
width: Int,
height: Int,
): IntArray {
val png = encodeToData(EncodedImageFormat.PNG)!!.bytes
val decoded = ImageIO.read(ByteArrayInputStream(png))
val out = IntArray(width * height)
decoded.getRGB(0, 0, width, height, out, 0, width)
return out
}
companion object {
private const val POLLS = 40
private const val POLL_MILLIS = 50L
}
}
@@ -40,6 +40,7 @@ import coil3.svg.SvgDecoder
import com.vitorpamplona.amethyst.commons.service.image.Base64Fetcher
import com.vitorpamplona.amethyst.commons.service.image.BlurHashFetcher
import com.vitorpamplona.amethyst.commons.service.image.ThumbHashFetcher
import com.vitorpamplona.amethyst.commons.sno.SnoFetcher
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import okhttp3.Call
import okio.Path.Companion.toOkioPath
@@ -63,9 +64,11 @@ object DesktopImageLoaderSetup {
add(SkiaGifDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(SnoFetcher.Factory)
add(ThumbHashFetcher.Factory)
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(SnoFetcher.SKeyer)
add(ThumbHashFetcher.TKeyer)
}.build()
@@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
import com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
@@ -1685,19 +1686,19 @@ fun UserProfileScreen(
}
if (showProfileReportDialog && iAccount != null) {
com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog(
ReportNoteDialog(
onDismiss = { showProfileReportDialog = false },
onReport = { type, comment ->
onBlock = {
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
profileSnackbar?.showSnackbar("Report sent")
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("User muted")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Report failed: ${e.message}")
profileSnackbar?.showSnackbar("Mute failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
onReport = { type, comment ->
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
@@ -45,12 +45,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -100,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
@@ -1435,8 +1434,8 @@ private suspend fun sendEncryptedFiles(
}
/**
* Dialog to set or change a group's NIP-14 subject (name). Mirrors Android's
* NewChatroomSubjectDialog: it sends a normal NIP-17 message carrying a
* Dialog to set or change a group's NIP-14 subject (name), using the shared
* [ChatroomSubjectDialog]: it sends a normal NIP-17 message carrying a
* `subject` tag (plus an optional accompanying message) to every room member,
* so all participants pick up the new name.
*/
@@ -1449,56 +1448,23 @@ private fun GroupSubjectDialog(
onClose: () -> Unit,
) {
val scope = rememberCoroutineScope()
var groupName by remember { mutableStateOf(currentSubject) }
var message by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onClose,
title = { Text("Group name") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = groupName,
onValueChange = { groupName = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Subject") },
placeholder = { Text("A name for this group") },
singleLine = true,
)
OutlinedTextField(
value = message,
onValueChange = { message = it },
modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp),
label = { Text("Message (optional)") },
)
}
},
confirmButton = {
TextButton(
enabled = groupName.isNotBlank(),
onClick = {
scope.launch {
try {
val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() }
val template =
ChatMessageEvent.build(message, pTags) {
groupName.ifBlank { null }?.let { changeSubject(it) }
}
account.sendNip17PrivateMessage(template)
} catch (e: Exception) {
println("Failed to set group subject: ${e.message}")
ChatroomSubjectDialog(
initialSubject = currentSubject,
onPost = { subject, message ->
scope.launch {
try {
val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() }
val template =
ChatMessageEvent.build(message, pTags) {
subject.ifBlank { null }?.let { changeSubject(it) }
}
}
onClose()
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onClose) {
Text("Cancel")
account.sendNip17PrivateMessage(template)
} catch (e: Exception) {
println("Failed to set group subject: ${e.message}")
}
}
},
onClose = onClose,
)
}
@@ -20,90 +20,24 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.nip56Reports.ui.ReportDialog
import com.vitorpamplona.quartz.nip56Reports.ReportType
private val REPORT_OPTIONS =
listOf(
ReportType.SPAM to "Spam",
ReportType.PROFANITY to "Profanity / Hateful speech",
ReportType.IMPERSONATION to "Impersonation",
ReportType.NUDITY to "Nudity / Sexual content",
ReportType.ILLEGAL to "Illegal content",
ReportType.MALWARE to "Malware / Phishing",
)
/**
* NIP-56 report dialog. Lets the user pick a report reason and optionally add a
* comment, then either just report or report-and-block the author. Mirrors the
* Android `ReportNoteDialog`, adapted to Compose Desktop.
* NIP-56 block-or-report dialog for desktop: the shared [ReportDialog]. [onBlock] hides the
* author; [onReport] files a public report with the chosen reason and text (callers also hide
* the author, as Android does).
*/
@Composable
fun ReportNoteDialog(
onDismiss: () -> Unit,
onBlock: () -> Unit,
onReport: (ReportType, String) -> Unit,
onBlockAndReport: (ReportType, String) -> Unit,
) {
var selected by remember { mutableStateOf(REPORT_OPTIONS.first().first) }
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Report note") },
text = {
Column {
REPORT_OPTIONS.forEach { (type, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected == type, onClick = { selected = type })
.padding(vertical = 2.dp),
) {
RadioButton(selected = selected == type, onClick = { selected = type })
Text(label, modifier = Modifier.padding(start = 4.dp))
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(onClick = {
onReport(selected, comment)
onDismiss()
}) { Text("Report") }
},
dismissButton = {
Row {
TextButton(onClick = {
onBlockAndReport(selected, comment)
onDismiss()
}) { Text("Block & report") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
ReportDialog(
onBlock = onBlock,
onReport = onReport,
onDismiss = onDismiss,
)
}
@@ -159,17 +159,17 @@ fun NoteReportDialog(
val scope = rememberCoroutineScope()
ReportNoteDialog(
onDismiss = onDismiss,
onReport = { type, comment ->
onBlock = {
scope.launch {
try {
account.reportEvent(event, type, comment)
snackbar?.showSnackbar("Report sent")
account.hideUser(event.pubKey)
snackbar?.showSnackbar("User muted")
} catch (e: Exception) {
snackbar?.showSnackbar("Report failed: ${e.message}")
snackbar?.showSnackbar("Mute failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
onReport = { type, comment ->
scope.launch {
try {
account.reportEvent(event, type, comment)
@@ -0,0 +1,418 @@
# Cyberspace §7 — opening a region bag
_Written 2026-09-22, after DECK-0003 (SNO) shipped its reader and left `kind 33330`
closed. This is the plan for opening it._
Source: [`CYBERSPACE_V2.md`](https://github.com/arkin0x/cyberspace/blob/master/CYBERSPACE_V2.md)
§2 (coordinates), §4 (Cantor trees), §7 (location-based encryption and discovery),
§8.6 (the bag event). Reference implementations: `arkin0x/cyberspace-cli` (Python) and
`arkin0x/cyberspace-cli-js` (TypeScript, published as `cyberspace-core`), plus
`arkin0x/ONOSENDAI` as the only client that does this today.
Decisions taken up front: **all three layers ship** (protocol, CLI, UI), and **every
sweep is a tap** — no background CPU is ever spent on a stranger's puzzle, however
cheap it looks.
---
## 1. What this reverses
`amethyst/plans/2026-09-21-deck-0003-sno.md` deferred bags as decision D2, on a
reason that turned out to be wrong twice over.
The first version said opening a bag needs "§7.4 discovery scanning", and leaned on
Amethyst having no position in cyberspace to scan from. §7.7 says the opposite in as
many words:
> 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.
Scanning your own neighbourhood is the opportunistic half, and §7.7 is blunt about
what it is worth alone: "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."
ONOSENDAI's own code agrees — `destinationKeys.ts` opens with "Things are hidden in
cubes, so that key almost never fits a lock."
What finds a bag is a **hint**: the hider publishes an aligned box, and a seeker
sweeps the candidate regions inside it. A client with no avatar, no position and no
movement chain can do that.
The second wrong reason was that it would cost too much. It does not, at the heights
anyone actually hides at. §3 measures it.
## 2. What a bag is, end to end
1. A hider picks a coordinate and a height `h`, and derives the region key: three
per-axis Cantor roots of the aligned subtrees at `h` (§4.5, §4.6), combined by
nested Cantor pairing into `region_n` (§4.7), then
`key = sha256(region_n)` and `lookup_id = sha256(key)` (§7.2).
2. They encrypt everything they have hidden in that region — a JSON array of nostr
events — with AES-256-GCM under that key, and publish it as a `kind 33330`
addressable by `d = lookup_id` (§7.6, §8.6).
3. They MAY attach a `hint` tag naming an aligned box the region lies in, one height
per axis (§7.7).
4. A seeker sweeps the box: for each candidate region derive the key, hash to the
`lookup_id`, and ask a relay for a bag with that `d`. Found, decrypt, verify the
items, render by inner kind.
The two-layer hash is the whole trick: `lookup_id` is safe to publish because it is
a hash *of* the key, so seeing it buys nothing without the region preimage.
## 3. What it costs, measured
A region key is three `O(2^h)` folds of BigInts that double in width at every level,
plus one combine. Measured on this JVM against the same leaf-by-leaf fold
`cyberspace-core` uses:
| bag height `h` | one axis root | one combine + 2 SHA-256 |
|---:|---:|---:|
| 5 | 0.03 ms | 0.033 ms |
| 8 | 0.18 ms | 0.48 ms |
| 12 | 9.2 ms | 32 ms |
| 16 | 555 ms | 1,873 ms |
Per key (three axes plus a combine) that is 1.2 ms at h8 and 2.1 s at h16, against
the spec's own figures in §7.7 of 1.3 ms and 816 ms on one desktop core — within 2
to 3x, which is what a JVM `BigInteger` (Karatsuba and Toom-Cook, no FFT) should
give against C.
**A sweep decomposes per axis.** The axes are independent all the way to the combine
(§4.7), so a box of gap `G` costs `3 · 2^(G/3)` tree builds and `2^G` combines, not
`2^G` full keys. The combine term dominates above h8:
| | gap 0 | gap 6 | gap 12 | gap 18 |
|---|---|---|---|---|
| **h5** | instant | 2 ms | 0.14 s | 9 s |
| **h8** | 2 ms | 31 ms | 2 s | 2 min |
| **h12** | 60 ms | 2 s | 2 min | 2.3 h |
| **h16** | 3.5 s | 2 min | 2 h | — |
This agrees with §7.7's own table (gap 12 at h≤8 is "seconds", gap 24 is "hours"),
and it says the affordable corner is most of the corner anyone uses. Android will be
slower — measure it before quoting a number in the UI.
**The growth ratio is ~2.2x per height, not 2x**, in the spec's figures and in the
measurement, because a pairing doubles the leaf count *and* the operand width. A
`GROWTH_RATIO_FLOOR` of that shape is what ONOSENDAI's own calibration fits.
## 4. Layer 1 — quartz, protocol only
New package `quartz/.../cyberspace/`, beside the `CyberspaceScale` and
`CyberspaceCoordinate` the SNO work already put there.
| Piece | What | Pinned by |
| --- | --- | --- |
| `CyberspaceCoordinate` | §2.2 in full: interleave and de-interleave X/Y/Z/plane. Fixed 256 bits, so a `ByteArray(32)`; no bignum, no allocation per axis | §9.8's six vectors round-tripped, §7.7's three hint vectors |
| `CantorTree` | §4.5/§4.6: `cantorPair`, `alignedBase`, `subtreeRoot(base, height)`, with the reference's `maxComputeHeight` refusal | `cyberspace-cli`'s own roots, through the CLI harness |
| `RegionKey` | §7.2's two hashes over `int_to_bytes_be_min` | the same |
| `CyberspaceHint` | §7.7 tag parse with every MUST: arity, lowercase hex, aligned base, `H` in `[0,85]`, canonical integers, `H >= h`, sector-tag agreement — and a malformed hint is **absent**, never a rejection of the bag | §7.7's three golden vectors |
| `CyberspaceBagEvent` | kind 33330: `d`/`h`/`encrypted`/`hint`/sector tags, AES-256-GCM open, §7.6's plaintext rules | round trip against the reference CLI's `encrypt` |
| `RegionSweep` | the axis-decomposed enumeration, as a cold sequence a caller pulls with its own budget | a unit test that a gap-0 hint is one candidate and equals the direct derivation |
Two things need an `expect`/`actual`: arbitrary-precision integers (`java.math.BigInteger`
on `jvmAndroid`) and AES-256-GCM (`javax.crypto`). iOS gets neither for now and the
module declares it.
**The rules that are easy to get wrong**, all normative, all worth a test each:
- A failed decryption "MUST NOT be treated as an error in the bag" (§7.6) — it means
only that this reader lacks the key.
- Items MAY be unsigned. A signed item must have the canonical id and a verifying
signature or it is dropped, **and only it**: "one corrupt or forged item says
nothing about the others". An unsigned item's `pubkey` is a claim and MUST NOT be
shown as verified authorship.
- Placement is attributable to the bag's author; authorship of an item only to the
item's own pubkey, and only when signed.
- An item's `C` tag must lie inside the bag's region, and a reader MAY drop one that
does not.
- A reader that does not understand an item's kind skips it and renders the rest.
## 5. Layer 2 — `amy`
Thin assembly over Layer 1, which is where interop is proven. The SNO conformance
harness already runs 11 of 11 against the reference implementations; these extend it.
```
amy cyberspace coord <hex> # x, y, z, plane
amy cyberspace region <hex> <height> # region_n, key, lookup_id
amy cyberspace hint <bag.json> # box, candidates, estimated cost
amy cyberspace sweep <bag.json> [--budget] # the lookup_ids, or the key when found
amy cyberspace open <bag.json> --key <hex> # decrypt, verify items, dump
```
`region` and `open` are directly diffable against `cyberspace-cli`; `hint` against
`hint-reference.py`'s golden vectors. No UI decision touches any of it.
## 6. Layer 3 — Amethyst
Only for a bag someone put in front of you: quoted in a note, or in a thread. Nothing
sweeps the network on its own, and nothing sweeps on arrival.
A `kind 33330` in a feed renders a card that has already parsed its hint and priced
it, and says so: *"Hidden in a 4,096-region box — about 2 minutes of searching."*
A button starts it. While it runs, progress and a cancel. Found, the items render
through the cards that already exist: `3330` through `SnoObjectCard`, `1` as a note,
anything else skipped.
**Every sweep is a tap, including a gap-0 hint** that the spec itself calls "a
destination the seeker can compute or walk to directly, not a search" and which costs
60 ms. The consistency is the point: a reader never learns that some bags open
themselves, so a hostile bag cannot hide inside that expectation.
**Price before you spend.** The budget comes from the `hint` tag and the `h` tag
before a single tree is built, and it is a hard cap, because a bag can carry a gap-30
hint precisely to burn a day of a reader's battery. A sweep that would exceed the cap
is not offered as a button at all — it is reported as out of reach.
## 7. Not in scope
- §7.4 position scanning. We have nowhere to stand, and §7.7 says it is not how
things are found.
- §4–§6 movement chains, §8.3 spawn, DECK-0001 hyperspace. That is a client *for*
cyberspace; this is a Nostr client reading an event.
- Publishing, hiding, or holding a region (§7.8). Read-only, like the rest of SNO.
- §9.7's GPS mapping: 96-digit decimal arithmetic with a hand-rolled deterministic
trig series, carried so every client agrees where a place on Earth is. Amethyst has
no Earth to agree about.
## 8. Order
1. **Coordinates.** `CyberspaceCoordinate` in full, §9.8 and §7.7 vectors. No bignum
and no new dependency: an 85-bit axis splits at 64 into a 21-bit high half and an
unsigned low Long, which is enough for the bit layout, the plane, the sector shift
of §10 and `alignedBase` — and `alignedBase` is exactly what a sweep enumerates,
so this is the foundation the rest stands on. **Done**, 8 tests, every vector the
spec publishes.
It does *not* improve the shard card, which was the claim when this was written.
The thought was to upgrade the `C` tag line from a plane to a place, but the only
human-readable thing a decode adds is the sector triple, and at 55 bits an axis
that is a fifty-character string for a 12 cm cube — worse than the abbreviated
coordinate already there, which at least copies. Two shards in one sector would be
worth saying; there are 21 reachable `3330`s and one author, so there is nothing
to compare. Revisit when there is.
2. **Cantor + region key**, with the big integer and the height refusal.
`amy cyberspace region`, diffed against `cyberspace-cli`. **Done** — see §9.
3. **Hints.** Parse, validate, price. `amy cyberspace hint`. The three golden
vectors. **Done** — see §10.
4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace
open`, round-tripped against the reference CLI's `encrypt`. **Done** — see §11.
5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. **Done** —
see §11.
6. **The card**, last, once every number it quotes is measured on a device.
**Done** — see §12, which measures them on the device at the tap rather than
baking in a constant.
Steps 1 to 5 have no product risk and every one of them is diffable against a
reference implementation. Step 6 is the only judgement call, and it is small.
## 9. Step 2 as built
`CantorTree`, `RegionKey`, `UBigInt`, `amy cyberspace coord|region`. The
conformance harness is now 12 of 12, the new section comparing **16 region keys
and their coordinate decodes** against `cyberspace-cli` — four coordinates
(§9.8's london, nyc and origin, plus §7.7's ideaspace point) at heights 0, 1, 4
and 8. Amethyst derives the keys the rest of the network derives.
### The big integer, and why there are two of them
The plan said "an `expect`/`actual` over `java.math.BigInteger`". The first
attempt went the other way — one portable implementation everywhere — on the
argument that a region key is a **consensus value**, since §7.2 turns it into an
AES key, so two implementations is two chances to disagree and an object that
opens on a desktop and not on a phone.
Measurement reversed that. Portable Kotlin came in **3 to 10 times slower** than
`java.math.BigInteger` on the operands a Cantor tree reaches, because
`BigInteger.multiplyToLen` is a HotSpot intrinsic and the JDK adds Toom-Cook
above a few hundred limbs. §7's whole feasibility is a number, and that factor
is the difference between a search a reader waits for and one they abandon.
So: `UBigInt` is an `expect class`, aliased through a thin wrapper to
`java.math.BigInteger` on `jvmAndroid`, and backed by `PortableUBigInt` on
`nativeMain` — which covers Apple and Linux together, so there are two actuals
and not three. A wrapper rather than a `typealias` for one reason:
`toMinimalBytes`. The reference hashes `int_to_bytes_be_min`, and
`BigInteger.toByteArray()` is two's complement, so it grows a `0x00` sign byte
whenever the top bit is set — half of all numbers — and aliasing would have put
that byte into a SHA-256 and produced a key nobody else derives.
**What makes two implementations safe is that the disagreement is testable, and
tested.** `PortableUBigIntDifferentialTest` runs every operation against
`java.math.BigInteger` over random inputs at fifteen widths from 0 to 352,000
bits, straddling the Karatsuba threshold in both directions, and a ninth test
asserts the two *actuals* agree with each other on the same inputs — including
the bytes, which is the one place aliasing would have gone wrong silently.
`CantorTreeBenchmark` then folds a whole subtree both ways and compares the
roots, which is where an off-by-one in the fold's stack would live rather than
in the arithmetic.
### What it costs, on the shipped path
The §3 table was measured on `java.math.BigInteger`, which is what now ships on
JVM and Android, so it stands. Apple and Linux pay the portable multiplier on
top; nothing there opens a bag yet.
### Corrections to §3 worth carrying forward
The earlier measurement said a sweep decomposes per axis, `3 · 2^(G/3)` tree
builds and `2^G` combines. Building it confirmed the decomposition and also that
**the combine dominates above about height 8** — a combine at height 12 is
roughly ten times an axis root at the same height, because it multiplies two
numbers the size of the root rather than folding up to one. A budget model that
prices a sweep by its tree builds will under-quote badly; price it by `2^G`
combines and add the trees.
## 10. Step 3 as built
`CyberspaceHint`, `amy cyberspace hint`, and a harness section that runs §7.7's
three golden vectors through the CLI and diffs them against the spec's own
`hint-reference.py`. The harness is 13 of 13.
The vectors pin more than a parser. Each states a point, a bag height and three
hint heights, then gives the coordinate the aligned base must come out as and
the sector tags the bag must carry — so building a hint from the point has to
reproduce the published tag byte for byte, and `london_h5_box11`,
`london_h5_x_exact` and `ideaspace_h8_y_open` all do.
### What the type says, and why
A hint is the hider's **difficulty knob**, so the number that matters is
`gapBits` — `(Hx - h) + (Hy - h) + (Hz - h)` — and it is reported as an exponent
rather than a count because it runs to 255 when all three axes are left open and
nothing holds `2^255`. §7.7's own table reads in those terms: 12 is seconds, 24
is hours, 30 or more is "days to never".
`axisTrees` is reported apart from `candidates`, and the asymmetry is the point.
A sweep needs one Cantor tree per distinct base *per axis* and then one combine
per candidate, so a sector-only hint on a height-5 bag is a hundred million
trees against `2^75` combines: both say hopeless, only one of them can say it
with a number. A budget that prices a sweep by its trees under-quotes by the
same margin §9 warned about.
### The rule that shapes the API
§7.7: "A `hint` tag that breaks any rule above MUST be treated as absent...
A bad hint never invalidates the bag, because hints are advisory metadata about
where to look; whether a bag is valid is decided by §7.2 and §7.6 alone."
So `read` returns null and never throws, for every one of: wrong arity, bad hex,
uppercase hex, an unaligned base, a height outside `[0, 85]`, a non-canonical
integer (`"011"` is not `"11"`), a height below the bag's `h`, and — the one the
spec states as a publisher rule without saying what a reader does — more than
one `hint` tag, which is read the same way because a bag whose author cannot be
read literally is exactly the case §7.7's remedy is for.
The canonical-integer rule is worth keeping for the same reason the aligned base
is: both exist so that two hiders who hint the same box publish the same bytes,
and a reader that accepts `"05"` alongside `"5"` lets one box have two
spellings and breaks comparison by equality.
## 11. Steps 4 and 5 as built
`CyberspaceBagEvent` and `RegionSweep` in quartz, `amy cyberspace open` and
`amy cyberspace sweep` over them, and two more harness sections. The harness is
**15 of 15**.
### What the two new sections actually prove
Everything before this diffed a *number* against the reference — a key, a
lookup id, a hint tag. These diff a **ciphertext**, which is the only test that
catches a chain that agrees at every step and still cannot open a bag.
`cyberspace-cli` derives the region key with `location_encryption`, seals the
plaintext with `encrypt_with_location_key`, and writes the §8.6 tags with
`make_encrypted_content_event`. `amy` is handed the event and a coordinate and
has to reach the same 32 bytes: §2.2's interleave, §4.7's three Cantor roots,
§7.2's two hashes, §7.6's `nonce || ciphertext || tag`. Section 8 then takes the
coordinate away and makes it find the same bag from its hint box alone.
The box in section 8 comes from the reference's own `coord_to_xyz` /
`xyz_to_coord` rather than from ours, so a disagreement about alignment shows up
as a bag that is not in the box we were handed — not as a test grading its own
arithmetic.
### The two design decisions worth recording
**`open` exits 0 on the wrong key.** §7.6: "A failed decryption therefore means
only that the reader does not hold this region's key; it MUST NOT be treated as
an error in the bag." So a wrong key is a verdict (`opened: false`), the way
`sno parse` reports an invalid payload, and the harness asserts the exit code as
well as the field. What *does* fail is a bag that cannot be attempted at all: an
unknown `version` (§8.6 says ignore it) or no `aes-256-gcm` payload to try.
**`sweep` refuses before it spends.** The gap is read from the `hint` and `h`
tags and checked against `--max-gap` (default 20) before a single tree is built,
and the refusal quotes the exponent and names the flag that would buy it. A
harness case pins it: a gap-33 hint is declined rather than swept. This is the
same shape the card needs, which is why it lives in the CLI first — a budget
that can be tested in a shell script is a budget that can be trusted in a feed.
### A shell trap worth remembering
`jq`'s `//` treats `false` as empty, so `.opened // "null"` turns a correct
`false` into `"null"` and fails a passing test. Read booleans with a plain
`jq -r .field`.
## 12. Step 6 as built
`BagSweep` in `commons/cyberspace/` (headless: the quote, the budget and the
cold flow) and `RenderCyberspaceBag` in `amethyst/ui/note/types/` (the card),
wired into `NoteCompose` and `ThreadFeedView`, with kind 33330 routed through
`EventCache`'s addressable path. Nine tests in `commons`.
### Pricing without a constant
§3 of this plan ended with "Android will be slower — measure it before quoting a
number in the UI". What shipped measures it **at the moment of the tap, on the
device that is about to pay**, which is the same answer without a constant that
goes stale on the next handset. The order is:
1. **Free, while the row composes.** `BagSweep.quote` reads two tags and does
three subtractions. A box past `MAX_GAP_BITS` (20) or a bag deeper than
`MAX_BAG_HEIGHT` (12, the ceiling ONOSENDAI puts on its own discovery scan)
is reported as out of reach and never becomes a button. No Cantor tree is
built for a hint the reader has already declined — a test pins that the flow
emits nothing at all in that case.
2. **One candidate, timed.** On the tap, the box's own base region is derived
and the elapsed time recorded. That candidate is never wasted work: a gap-0
hint names exactly it, so the measurement *is* the search for a destination
hint.
3. **The estimate, then the rest.** `perCandidate × candidates` against a
two-minute budget. It over-quotes — the first candidate is three axis roots
plus a combine and every later one is a combine, so by about 4x at shallow
heights and 2x at deep ones — and over-quoting is the safe direction for a
number whose only job is to decide whether to spend somebody's battery.
A slower phone therefore refuses boxes a faster one accepts. That asymmetry is
correct: the cost is the reader's, so the reader's hardware should decide.
### What the card says, and what it never does
Every sweep is a tap, **including a gap-0 hint**, which §7.7 itself calls "a
destination the seeker can compute or walk to directly, not a search" and which
costs about a frame. A reader who learned that some bags open themselves would
have learned an expectation a hostile bag could hide inside, so the one
candidate is offered exactly like the million. A test pins it.
Scrolling the row away cancels: the flow is cold over a cold sequence and the
card disposes its job, so a sweep never outlives the card that asked for it.
Items render through the cards that already exist — a `3330` shard through
`SnoObjectCard` (palette fetched by `WithSnoPalette`, as a standalone object
would be), a `kind 1` as its text, anything else named and skipped, because
§7.6 says a reader that does not understand an item's kind "skips it and renders
the rest". The attribution is the non-obvious part and §7.6 fixes it: placement
belongs to the bag's author, authorship only to a signed item's own pubkey. An
unsigned item therefore carries a line saying its author is a claim, rather than
borrowing either name.
### One correction carried back into quartz
`SnoShardEvent`'s KDoc said Amethyst "implements none of it" and that a reader
without the key sees base64 and nothing else. Both were true when it was
written. The class now points at `RegionSweep` and says what actually decides
whether a bag opens — whether its own hint prices the search into reach.
+1
View File
@@ -12,6 +12,7 @@ _Audited 2026-09-17. 13 plans: 7 shipped (archived), 0 in-progress, 5 queued, 1
| [2026-08-13-gpu-pow-mining.md](2026-08-13-gpu-pow-mining.md) | GPU NIP-13 mining declined (ARMv8 has SHA-256 in silicon, mobile GPUs do not). Midstate is ~3x on JVM targets; Android hinges on Conscrypt per-digest JNI cost, still unmeasured. created_at refresh while mining shipped. |
| [2026-09-17-cordn-interop.md](2026-09-17-cordn-interop.md) | Cordn (cordn.net) is an alternative binding of MLS onto Nostr, not an alternative to MLS: same ciphersuite `0x0001`, byte-identical ChaCha20-Poly1305 seal and NIP-01 envelope, but the delivery service is an MCP server over ContextVM with no key-package event kind. Only the RFC 9420 engine is shareable, and it is not yet Marmot-clean (3 files, 10 imports, 3 hardcoded policies). ContextVM must be written from scratch: full review of the spec + all 12 CEPs, with a per-CEP compliance matrix, `CVM-*` rule ids and a 5-tier test method (including a fixture server that misbehaves on demand, and RFC 8785 JCS which Quartz lacks). Blocked on the credential-identity encoding (raw 32 bytes vs 64-byte hex ASCII). Includes a coordinator metadata-exposure analysis. |
| [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stages 0-4 done (mdk interop reference, app_data_dictionary, identity proof v2, the six group components, transport corrections); lifecycle + branch selection landed. |
| [2026-09-22-cyberspace-region-bags.md](2026-09-22-cyberspace-region-bags.md) | Open `kind 33330` region bags: §2 coordinates, §4 Cantor roots, §7.2 region keys, §7.7 hint sweeps, §7.6 item verification. Reverses SNO's D2 — §7.7 says a seeker's position never enters the cost, and a key is 1.2 ms at h8 against the spec's own 1.3 ms. Three layers: quartz, `amy`, and a tap-to-search card. |
## Archived (shipped)
| Plan | Summary |
@@ -77,6 +77,16 @@ object CordnGroupPolicy : MlsGroupPolicy {
override val defaultLeafCapabilities: Capabilities
get() = Capabilities(extensions = listOf(CordnGroupMetadata.EXTENSION_TYPE))
/**
* None, deliberately.
*
* `spec/01.md` §6 requires a member of a group using `0xC04D` to advertise
* it, and the reference client does — so RFC 9420 §13.4 can be enforced
* here from capabilities alone, with no exemption to weaken it. cordn has
* no deployed groups predating that rule to be kind to.
*/
override val knownExtensionTypes: Set<Int> = emptySet()
/**
* None.
*

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