From 15d950ed5b1fc506062532bc1127410cba5abf18 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 20:32:27 +0000 Subject: [PATCH 01/38] docs: investigate DECK-0003 (SNO) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feasibility study and implementation plan for Simple Nostr Objects — kind 33331 triangle meshes carried in an event's content. Checked the spec against the live network rather than reading it alone: 7 kind-33331 events exist across four relays, and three of them fail the spec's own reference validator because they are v:2 payloads still carrying [r,g,b] triples from before the 2026-09-16 palette change. The two deployed kind-3330 shards carry their geometry in tags with empty content, which is not the encoding DECK §3.2 describes. Also verified the rendering constraint: Canvas.drawVertices is common Compose API (present in ui-graphics-desktop) but hardware-accelerated only from API 29, against our minSdk 26 — so the plan puts a CPU rasterizer in commons on the existing blurhash PlatformImage rails instead, which also buys a z-buffer and headless pixel tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 354 +++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 amethyst/plans/2026-09-21-deck-0003-sno.md diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md new file mode 100644 index 0000000000..627cf15aa5 --- /dev/null +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -0,0 +1,354 @@ +# DECK-0003 (SNO — Simple Nostr Objects) — feasibility and implementation plan + +_Status: investigation. Nothing built yet._ + +Source: +(Draft, last updated 2026-09-16, requires `CYBERSPACE_V2.md` `2026-03-16-h34-corrected`). +Reference implementation: `decks/sno-reference.py` in the same repo — no dependencies, +25 rejection cases, and it runs clean. + +## 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 deployed `kind 3330` shards do not match §3.2.** 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… +``` + +DECK-0003 §3.2 says a bag item is "the payload of §1" under `kind 3330`; what is +actually published is a tag-encoded form the DECK does not describe (`vertices` / +`colors` / `indices` / `display` / `version` as tags). Implementing 3330 today means +implementing an undocumented encoding against two events. 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." We must clamp defensively (see §4). +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. 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 +``` + +### 2.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` | + +### 2.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>`: 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` and convert to float **only** at +the rasterizer boundary, per §1.2. + +### 2.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. + +### 2.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, three parse only + under the D1 leniency flag, zero crash. + +### 2.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 | + +## 3. 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/`. + +### 3.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. + +### 3.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. + +### 3.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." + +## 4. 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. +- **Clamp the position bound that the spec leaves open.** §1.8 puts the 64-unit bound + on publishers only and §8.3 admits a reader obligation is missing. Amethyst should + reject (or clamp to) vertices beyond ±64 units — a payload at 2^50 units is valid + per the text and is a denial of service against our own projection math. Log it as + a deliberate divergence in the KDoc. +- **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. + +## 5. Suggested sequencing + +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. **Optional later:** `kind 11333` avatars (same payload, different container), + 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. + +## 6. Decisions to make before step 1 + +- **D1 — legacy `v: 2` triples.** Recommend **accept**, reading the triples literally + the way `v: 1` colors are read, behind a named constant so the leniency is visible + and removable. Rejecting is spec-conformant and hides 3 of the 7 objects that + exist, including one by an author whose other object is correct. +- **D2 — `kind 3330` bag items.** Recommend **defer**. The deployed form is + tag-encoded and undocumented; DECK §3.2's description does not match either live + event. Revisit when the DECK or ONOSENDAI settles. +- **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** (§4). A deliberate divergence from a spec that admits + the gap. Recommend clamping and documenting it. + +## 7. 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. From 5d9cb8749f459ba42bc2b70119c4e9b6c70c596f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 21:00:16 +0000 Subject: [PATCH 02/38] docs: read the specs and clients around DECK-0003 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the rest of the cyberspace repo (CYBERSPACE_V2 §7.6/§8.6 bags, §8.10 avatars, §9.4/§11 axis conventions, DECK-0001/0002, the deck registry) and the two clients that implement SNO, then corrected the plan where they disagreed with it. Three changes of substance: - The kind 3330 finding was wrong about the cause. ONOSENDAI does put the §1 payload in content; the two tag-encoded shards on relays are v1-era leftovers. The real reason to defer 3330 is §7.6: a current shard is an item inside an AES-256-GCM bag keyed to a cyberspace region, so reading one means implementing the coordinate system, region key derivation and discovery scanning. - D1 is settled upstream. sno-core v0.1.4 accepts v:2 payloads carrying literal colour triples on purpose, because v2 shipped as two changes that did not land together and refusing them orphaned real objects. Measured on the seven live events: sno-core reads 7 of 7, sno-reference.py 4 of 7. So the two reference implementations no longer agree, and the deck's conformance note saying they do is stale. - §8.10 gives avatars (kind 11333) a normative proof-of-work computed from the payload, with a MUST NOT draw an unpaid one. That is implementable on the nip13Pow code we already have, so it moves into the sequencing. Also recorded: sno-core is MIT and safe to read, ONOSENDAI is CC-BY-SA so its code must not be copied, and neither reference reader bounds how far a vertex may sit from the origin, which is why our clamp is a divergence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 236 +++++++++++++++++---- 1 file changed, 193 insertions(+), 43 deletions(-) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index 627cf15aa5..332f9abb48 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -4,8 +4,12 @@ _Status: investigation. Nothing built yet._ Source: (Draft, last updated 2026-09-16, requires `CYBERSPACE_V2.md` `2026-03-16-h34-corrected`). -Reference implementation: `decks/sno-reference.py` in the same repo — no dependencies, -25 rejection cases, and it runs clean. +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 @@ -78,8 +82,9 @@ that would rather be generous MAY read the triples literally the way §1.3 reads Real payloads also already use `mode: "lines"` and a non-zero `unit` (12), so neither is a theoretical branch. -**The deployed `kind 3330` shards do not match §3.2.** Both live shards have **empty -`content`** and carry their geometry in *tags*: +**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) @@ -87,19 +92,25 @@ 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… +vertices (empty) colors (empty) indices (empty) +position 251894327,617397300,844414457 +display solid nonce 0000000000000000 e cb5a78f7… ``` -DECK-0003 §3.2 says a bag item is "the payload of §1" under `kind 3330`; what is -actually published is a tag-encoded form the DECK does not describe (`vertices` / -`colors` / `indices` / `display` / `version` as tags). Implementing 3330 today means -implementing an undocumented encoding against two events. Defer it — decision D2. +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` @@ -112,7 +123,7 @@ the only palette-event reader worth writing. 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." We must clamp defensively (see §4). + produce a grid no renderer wants." We must clamp defensively (see §5). 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. @@ -126,7 +137,131 @@ the only palette-event reader worth writing. 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. Quartz — new code +## 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.6 / §8.6 — the bag, and why `kind 3330` is out of reach + +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. + +### 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. 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.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 @@ -145,7 +280,7 @@ cyberspace/deck0003Sno/ └── tags/ reuse NameTag (nip01Core), AltTag (nip31Alts), DTag ``` -### 2.1 Reuse — do not re-implement +### 3.1 Reuse — do not re-implement | Need | Already exists | | ---- | ---- | @@ -156,7 +291,7 @@ cyberspace/deck0003Sno/ | Fetching the referenced palette event | `nip01Core/relay/client/accessories/` — `fetchFirst`. **Do not hand-roll a REQ loop** (CLAUDE.md) | | Event construction/signing | `eventTemplate` + `TagArrayBuilder` | -### 2.2 Parse with `JsonElement`, not a `@Serializable` data class +### 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]` @@ -175,7 +310,7 @@ Keep positions as the integer pair `(whole, tick)` on the payload. Expose `fun position(i: Int, axis: Int): Pair` and convert to float **only** at the rasterizer boundary, per §1.2. -### 2.3 The built-in palette is a vendored artifact +### 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 @@ -185,7 +320,7 @@ of truth is `decks/sno-palette.json` (2,748 bytes) and its generator (238 = `#ff0000`, 235 = `#00ff00`, 239 = `#0000ff`, 225 = `#ffffff`, 224 = `#000000`) so a bad paste cannot ship silently. -### 2.4 Tests — the table already exists +### 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 @@ -196,10 +331,11 @@ tetrahedron. Plus: - `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, three parse only - under the D1 leniency flag, zero crash. +- 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. -### 2.5 Registration points (each is a real integration, not a formality) +### 3.5 Registration points (each is a real integration, not a formality) | File | Change | | ---- | ---- | @@ -208,12 +344,12 @@ tetrahedron. Plus: | `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 | -## 3. Rendering — the part with a real decision in it +## 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/`. -### 3.1 `Canvas.drawVertices` is common Compose API but unusable below API 29 +### 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 @@ -225,7 +361,7 @@ The blocker is Android: `Canvas.drawVertices()` is hardware-accelerated **only f 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. -### 3.2 Recommended: a CPU rasterizer in `commons/`, on the blurhash rails +### 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 @@ -263,7 +399,7 @@ triangles into a 384×384 buffer with a `FloatArray` depth buffer. That is well a millisecond of real work on the JVM; the Android figure needs measuring, not guessing, before the interactive viewer lands. -### 3.3 Two surfaces, and only one of them needs to be fast +### 4.3 Two surfaces, and only one of them needs to be fast | Surface | Renderer | Cache | | ---- | ---- | ---- | @@ -284,7 +420,7 @@ validate them (rule 10 rejects a bad `spin` whether or not `up` is present), sur so explicitly: an application with no natural base unit "SHOULD render an object at a size that suits its context rather than refusing it." -## 4. Security review items +## 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. @@ -304,7 +440,7 @@ size that suits its context rather than refusing it." feed. Gate the thumbnail behind the same content-warning / follow-based rules the feed already applies to images, rather than inventing a new one. -## 5. Suggested sequencing +## 6. Suggested sequencing 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 @@ -319,21 +455,30 @@ size that suits its context rather than refusing it." 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. **Optional later:** `kind 11333` avatars (same payload, different container), - NIP-89 handler advertisement, an authoring/publishing path. +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. -## 6. Decisions to make before step 1 +## 7. Decisions to make before step 1 -- **D1 — legacy `v: 2` triples.** Recommend **accept**, reading the triples literally - the way `v: 1` colors are read, behind a named constant so the leniency is visible - and removable. Rejecting is spec-conformant and hides 3 of the 7 objects that - exist, including one by an author whose other object is correct. -- **D2 — `kind 3330` bag items.** Recommend **defer**. The deployed form is - tag-encoded and undocumented; DECK §3.2's description does not match either live - event. Revisit when the DECK or ONOSENDAI settles. +- **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.** Recommend **defer**, on `CYBERSPACE_V2.md` §7.6 + rather than on the DECK: a current shard is an item inside an AES-256-GCM bag keyed + to a Cyberspace region, so reading one means implementing §2 coordinates, §7.2 key + derivation and §7.4 discovery. That is the protocol, not a renderer. The two 3330s + sitting on relays are v1-era leftovers. - **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 @@ -342,10 +487,15 @@ are where the risk is, and they are testable without a device. - **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** (§4). A deliberate divergence from a spec that admits - the gap. Recommend clamping and documenting it. +- **D5 — the ±64 unit clamp** (§5). A deliberate divergence, and now a confirmed + one: neither `sno-reference.py` nor `sno-core`'s `fromPayload` bounds how far a + vertex may lie from the origin — both just grow the extent, which is what §8's + third open question admits. sno-core even has a `validPoint(p, extent)` helper and + does not call it on the read path. Recommend clamping anyway and documenting it as + a divergence; we render strangers' events in a feed, which is not the position + either of those readers is in. -## 7. Size, for the record +## 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 From 3895d5dc4245c420df38da6d034deea1b1575b3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 21:05:21 +0000 Subject: [PATCH 03/38] docs: note the avatar PoW check cannot reuse Event.pow() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoWRankEvaluator.compute returns min(actualRank, committed), so comparing it against avatar_work passes an event whose id falls short of its own commitment. §8.10 needs both conditions read separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index 332f9abb48..ee3b40aa87 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -183,7 +183,15 @@ that is not paid, or that carries content it cannot read; it draws its default a 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. It is also the only place in this +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 From a237fe8580ff47a7e8fbeeb62d91b17ce8fd473e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 21:41:44 +0000 Subject: [PATCH 04/38] feat: read Simple Nostr Objects (DECK-0003) in quartz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Simple Nostr Object is a small triangle mesh on an integer lattice, carried whole in one event's content. This adds the format to quartz: the payload, the §1.9 validator, the built-in 256-colour palette, the palette-event reader, kind 33331 standalone objects, and kind 11333 avatars with the proof-of-work gate §8.10 of the base spec puts on them. Positions are kept as exact integers in total ticks (120ths of a model unit) and converted to floats only at a renderer, which is what §1.2 requires of welding, equality and hashing: one 120th is not representable in binary floating point. Three deliberate positions, each documented where it is taken: - A v:2 payload whose colours are still literal [r,g,b] triples reads, which §5 permits and sno-core does. Version 2 shipped as two changes that did not land together, so objects written in between declare v2, carry triples, and are right in every other respect. Three of the seven objects on the network are of that shape. - A vertex beyond 64 units from the origin is refused. §1.8 puts that bound on publishers only and §8's third open question admits leaving it off readers is unsafe; neither reference implementation checks it. We draw strangers' events in a feed, and whole * 120 overflows an Int long before the coordinates the text permits. - A missing name is tolerated. §1.1 calls it required and neither reference implementation enforces it. 50 tests. The 25-case rejection table is generated from _rejections() in decks/sno-reference.py rather than typed, the avatar ladder is computed from §8.10's own pseudocode rather than lifted from a client, and all seven kind-33331 events that exist on the network are committed as fixtures and must all read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../cyberspace/deck0003Sno/SnoAvatarEvent.kt | 89 ++++ .../cyberspace/deck0003Sno/SnoAvatarWork.kt | 83 ++++ .../deck0003Sno/SnoBuiltInPalette.kt | 85 ++++ .../quartz/cyberspace/deck0003Sno/SnoMode.kt | 44 ++ .../cyberspace/deck0003Sno/SnoObjectEvent.kt | 92 ++++ .../cyberspace/deck0003Sno/SnoPalette.kt | 70 +++ .../deck0003Sno/SnoPaletteEventReader.kt | 119 +++++ .../cyberspace/deck0003Sno/SnoParser.kt | 419 ++++++++++++++++++ .../cyberspace/deck0003Sno/SnoPayload.kt | 110 +++++ .../quartz/utils/EventFactory.kt | 4 + .../deck0003Sno/SnoAvatarWorkTest.kt | 111 +++++ .../deck0003Sno/SnoBuiltInPaletteTest.kt | 64 +++ .../deck0003Sno/SnoLiveFixtureTest.kt | 165 +++++++ .../deck0003Sno/SnoPaletteEventReaderTest.kt | 133 ++++++ .../deck0003Sno/SnoParserAcceptanceTest.kt | 319 +++++++++++++ .../deck0003Sno/SnoParserRejectionTest.kt | 98 ++++ 16 files changed, 2005 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt new file mode 100644 index 0000000000..cb97c255c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag + +/** + * `CYBERSPACE_V2.md` §8.10 — an avatar, kind 11333: the shape an identity is + * drawn as, carrying an SNO payload in its content or empty content for the + * default avatar. + * + * Replaceable, so relays keep the newest per `(pubkey, kind)` and an identity + * has exactly one avatar. This kind **was** 33331 with a `d` fixed at + * `"avatar"` before the spec moved it here, and 33331 was then handed to the + * standalone objects of DECK-0003 §3.1 — so an old 33331 whose `d` is literally + * `avatar` is not a surprise, merely stale. + * + * 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 detail are paid for in + * proof of work on the event that publishes it, and **a client MUST NOT draw an + * avatar that is not paid, or that carries content it cannot read** — it draws + * its default for that identity instead. [isPaid] is that gate. + */ +@Immutable +class SnoAvatarEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** True when this identity asked for the default avatar, which owes no work. */ + fun isDefaultAvatar(): Boolean = content.isBlank() + + fun sno(): SnoResult = SnoParser.parse(content) + + /** The `name` tag: the shape's name for humans. */ + fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + /** + * Whether this avatar has paid for the room it takes up (§8.10). + * + * Both conditions are required: the committed target must cover the work the + * payload owes, **and** the id must actually carry that many leading zero + * bits. Committing the target before mining is what stops a lucky id being + * claimed against a lower bar than it was mined for (NIP-13). + * + * Note that `Event.pow()` cannot stand in for this. It is + * `PoWRankEvaluator.compute(id, commitedPoW)`, which returns + * `min(actualRank, committed)` — so comparing it against the required work + * passes an event whose id falls short of its own commitment: work 16, + * committed 30 and an id carrying 20 gives 20, which clears 16 while + * failing the second condition outright. + */ + fun isPaid(): Boolean { + if (isDefaultAvatar()) return true + val payload = sno().payloadOrNull() ?: return false + val committed = tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } ?: return false + if (committed < SnoAvatarWork.required(payload)) return false + return PoWRankEvaluator.calculatePowRankOf(id) >= committed + } + + companion object { + const val KIND = 11333 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt new file mode 100644 index 0000000000..05244827c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt @@ -0,0 +1,83 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.math.ceil +import kotlin.math.ln +import kotlin.math.max + +/** + * The proof of work an avatar owes for its size and its detail + * (`CYBERSPACE_V2.md` §8.10, normative). + * + * ```python + * AVATAR_FLOOR_BITS = 16 # every avatar + * AVATAR_SIZE_BITS = 2 # per doubling of reach + * AVATAR_DETAIL_BITS = 3 # per doubling of detail beyond the free thirty-two + * 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)) + * return ceil(AVATAR_FLOOR_BITS + AVATAR_SIZE_BITS * log2(reach) + AVATAR_DETAIL_BITS * log2(detail / AVATAR_DETAIL_FREE)) + * ``` + * + * Reach is the term that matters to other people, since a large avatar is the + * one that gets in everyone's way: two bits per doubling makes a shape twice as + * far across cost four times the work, so a modest shape of a few gibsons costs + * minutes on a phone while a sector-sized one is out of reach of any hash power. + * Reach is measured from the build origin rather than the shape's own centre, + * so a shape is priced as its builder placed it. + */ +object SnoAvatarWork { + const val FLOOR_BITS = 16 + const val SIZE_BITS = 2 + const val DETAIL_BITS = 3 + const val DETAIL_FREE = 32 + + /** The leading zero bits [payload] must have been mined to, as an avatar. */ + fun required(payload: SnoPayload): Int { + var farthestTicks = 0 + for (tick in payload.positions) { + val magnitude = if (tick < 0) -tick else tick + if (magnitude > farthestTicks) farthestTicks = magnitude + } + + // In the application's base unit at true scale: one model unit is 2^unit + // of it, and a tick is one 120th of a model unit. Never below one, which + // is what `max(1.0, ...)` does before the logarithm. + val reach = max(1.0, (farthestTicks.toDouble() / SnoPayload.TICKS_PER_UNIT) * pow2(payload.unit)) + val detail = max(DETAIL_FREE, payload.vertexCount + payload.faceCount) + + val bits = FLOOR_BITS + SIZE_BITS * log2(reach) + DETAIL_BITS * log2(detail.toDouble() / DETAIL_FREE) + return ceil(bits).toInt() + } + + /** `2^exponent` for the 0..84 range `unit` allows, beyond what an Int holds. */ + private fun pow2(exponent: Int): Double { + var value = 1.0 + repeat(exponent) { value *= 2.0 } + return value + } + + private fun log2(value: Double): Double = ln(value) / LN_2 + + private val LN_2 = ln(2.0) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt new file mode 100644 index 0000000000..3a2e983312 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPalette.kt @@ -0,0 +1,85 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +/** + * `cyberspace-neon-256`, the palette an object's colour indices name when it + * carries no `palette` of its own (DECK-0003 Appendix C, normative). + * + * The layout is arithmetic rather than a list to memorise: + * - `0..191` 24 hues of 8 steps; hue `h` step `s` is `h * 8 + s`, step 0 darkest. + * - `192..223` 32 steels, black to white, faintly cyan. + * - `224..255` 32 signatures: the client's instrument colours, the six sRGB gamut + * corners, six neon staples and six deep grounds. + * + * Transcribed from `decks/sno-palette.json` in the cyberspace repository + * (`"built": "2026-09-16"`), which is generated by `decks/sno-palette.mjs`. + * Generated here rather than typed: [SnoBuiltInPaletteTest] pins the documented + * anchors so a bad paste cannot ship silently. + */ +object SnoBuiltInPalette { + /** The registered name of this palette, the only one DECK-0003 §1.3a defines. */ + const val NAME = "cyberspace-neon-256" + + private const val HEX = + "003632004d49006562007e7d00979900b2b800cdd700e8f9" + // 0 + "003538004c5100636d007c8a0095a900afca00c9ed6fdfff" + // 8 + "00343f004a5a0061780079980092ba00abdf34c4ff93d8ff" + // 16 + "003346004865005e860075ab008dd200a4fd6ebcffa7d3ff" + // 24 + "00305200447600589e006ccc017fff5a9bff8bb5ffb5ceff" + // 32 + "00266d002cac1902ff374fff5a73ff7e92ffa1aeffc3c9ff" + // 40 + "2200883700b75000e76837ff8064ff9b86ffb6a5ffd0c4ff" + // 48 + "3500794f00a36b00cd8b00f8a24bffb875ffcd9affe1bcff" + // 56 + "4200695f008d7f00b1a200d5c600f8dd53ffed84fff9afff" + // 64 + "4b00586b00768e0094b200b1d900cdff1ae7ff7de2ffb1e6" + // 72 + "52004674005f980077be008ee600a4ff45b4ff87c0ffb5d2" + // 80 + "5700357a00489f005ac6006bee007bff538dff8ca6ffb7c2" + // 88 + "5a00247e0031a3003dcb0047f4004eff5b6aff908effb9b4" + // 96 + "5c0010800014a60013ce0006f22400ff613eff9375ffbba5" + // 104 + "561200752200943300b44600d45a00f47000ff9652ffbd93" + // 112 + "4b1f00683100854300a25700c06d00de8300fc9b00ffc077" + // 120 + "4426005e3800794c00946100b07800cc8f00e7a800ffc333" + // 128 + "3e2a00563d006e5300876900a08100b99900d2b300ebce00" + // 136 + "372d004d42006358007970008f8800a5a300b9be00cdda00" + // 144 + "2f3000424600555e0067770077910087ad0094ca009fe800" + // 152 + "243400314b003d6400457f00499b0045b9002cd90000f837" + // 160 + "073900005108006a1f008436009f4e00ba6700d68200f39e" + // 168 + "00381e00503000684400825b009c7300b78d00d3a900efc6" + // 176 + "00372a004e3f00665600806e009a8900b4a500d0c200ece1" + // 184 + "010101020203050606090b0c0f121315191a1b2021222728" + // 192 + "282e302f3638373e403e4648454e504d5658555e605d6769" + // 200 + "666f716e787a77808380898c899294939b9d9ca4a6a6adaf" + // 208 + "b0b6b8babfc1c4c9caced2d3d8dcdce3e5e6eeefeff8f8f8" + // 216 + "000000ffffff00e5ffff3b6bf7931a52e39fc8f5ff6f8ea0" + // 224 + "1d354705070dff00ff00ff0000ffffffff00ff00000000ff" + // 232 + "39ff14ff6ec77df9ffb026fffffb00ff330000ff9fff007f" + // 240 + "4d4dffffd3000a0f1a12182a1a0f240f1f1c2410161c1c0f" // 248 + + /** 256 opaque ARGB colours, index-aligned with the sheet in Appendix C. */ + val COLORS: IntArray = + IntArray(256) { i -> + val at = i * 6 + val r = HEX.substring(at, at + 2).toInt(16) + val g = HEX.substring(at + 2, at + 4).toInt(16) + val b = HEX.substring(at + 4, at + 6).toInt(16) + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt new file mode 100644 index 0000000000..937bad07dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoMode.kt @@ -0,0 +1,44 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +/** + * Which of the three drawings of the same vertex list a client should produce + * (DECK-0003 §1.5). Mode is a property of the object, not of the viewer. + */ +enum class SnoMode( + val code: String, +) { + /** The triangles in `faces`, colours interpolated across each face. */ + SOLID("solid"), + + /** The vertices, each at its own colour. */ + POINTS("points"), + + /** The edges of the faces, each drawn once; with no faces, one polyline through the vertices in order. */ + LINES("lines"), + + ; + + companion object { + fun parseOrNull(code: String?): SnoMode? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt new file mode 100644 index 0000000000..ad0ec2c1bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoObjectEvent.kt @@ -0,0 +1,92 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * DECK-0003 §3.1 — a standalone Simple Nostr Object, kind 33331. + * + * `content` is the payload of §1 serialized as JSON; [sno] reads it. The `d` + * tag is the object's identifier, chosen by its author and stable across + * edits, and an optional `name` tag duplicates the payload's name so a relay + * query can filter on it without parsing the content. + * + * 33331 falls in NIP-01's addressable range, so relays keep the newest event + * per `(pubkey, kind, d)` and an author edits an object in place. That is the + * right class for a thing someone iterates on in a modeling tool, and the deck + * states its cost plainly: reactions, zaps and comments reference an event id, + * and an address resolves to whatever its author last published, so a payment + * made against an object can end up pointing at content that changed after it. + * + * There is deliberately no regular twin of this kind. An object that must stay + * as it was found travels as a `kind 3330` bag item instead (§3.2), which this + * client does not read — a current bag item is encrypted to a cyberspace + * region key (`CYBERSPACE_V2.md` §7.6) and is not fetchable on its own. + * + * @see DECK-0003 + */ +@Immutable +class SnoObjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * The object, or the rule it broke. A client MUST NOT render a payload that + * fails §1.9 and SHOULD say why rather than failing silently (§3.1), which + * is why this hands back the failure instead of a null. + */ + fun sno(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) + + fun snoOrNull(fetchedPalette: SnoPalette? = null): SnoPayload? = sno(fetchedPalette).payloadOrNull() + + /** The `name` tag, which duplicates the payload's name for relay-side filtering. */ + fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) + + companion object { + const val KIND = 33331 + const val ALT = "A 3D object" + + fun build( + dTag: String, + payloadJson: String, + name: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, payloadJson, createdAt) { + dTag(dTag) + name?.let { add(arrayOf("name", it)) } + alt(name?.let { "$ALT: $it" } ?: ALT) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt new file mode 100644 index 0000000000..8903d629c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPalette.kt @@ -0,0 +1,70 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable + +/** + * The 2 to 256 colours an object's indices name (DECK-0003 §1.3a), as opaque ARGB. + */ +@Immutable +class SnoPalette( + val colors: IntArray, +) { + val size: Int get() = colors.size + + operator fun get(index: Int): Int = colors[index] + + companion object { + const val MIN_ENTRIES = 2 + const val MAX_ENTRIES = 256 + + val BUILT_IN = SnoPalette(SnoBuiltInPalette.COLORS) + } +} + +/** + * What an object's `palette` field said, kept after parsing so a caller can + * decide whether to go looking for a referenced palette event. + * + * The rule that makes a reference safe is that it is never load-bearing + * (§1.3b): an unresolved reference reads as the built-in, so the worst case is + * an object drawn in the wrong colours and never one that cannot be drawn. + */ +@Immutable +sealed class SnoPaletteRef { + /** No `palette` field, or the registered name of the built-in. */ + data object BuiltIn : SnoPaletteRef() + + /** + * An `nevent1…` or `naddr1…` naming a palette published as its own event. + * Pinned to the event it names: a reader MUST NOT follow the `e` chain + * forward to a newer version (§1.3b). + */ + data class Event( + val bech32: String, + ) : SnoPaletteRef() + + /** A palette carried in the object itself. */ + data class Inline( + val palette: SnoPalette, + ) : SnoPaletteRef() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt new file mode 100644 index 0000000000..77a8f49dd6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt @@ -0,0 +1,119 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull + +/** + * Reads a palette out of an event an object's `palette` reference named + * (DECK-0003 §1.3b). + * + * The kind is deliberately not constrained: this format does not define a + * palette kind and does not want one. `kind 3367`, the colour-moment + * convention, is what carries them today, and a reader that accepts the shape + * below will read whatever convention wins without the deck being revised. + * + * Two shapes, in order, and the first that succeeds wins: + * + * 1. **The `c` tags**, in the order they appear, if there are 2 to 256 of them + * and every value is a well-formed `#rrggbb`. Their order is the palette's + * order. This is the form every palette on the network actually uses, and + * it is a form relays index: `{"#c": ["#FF0000"]}` finds every palette + * containing pure red, with no new index and no new kind. + * 2. Otherwise **`content`**, if it parses as a JSON array of 2 to 256 entries + * that are each `[r, g, b]` integers or a `"#rrggbb"` string. A legacy form, + * accepted only because an earlier draft described it; nothing should write + * it now. + * + * Anything else is not a palette event, which counts as a failed fetch, which + * means the built-in — never a refusal to draw the object that named it. + */ +object SnoPaletteEventReader { + fun read(event: Event): SnoPalette? = fromTags(event) ?: fromContent(event.content) + + private fun fromTags(event: Event): SnoPalette? { + val values = event.tags.mapNotNull { if (it.size > 1 && it[0] == "c") it[1] else null } + if (values.size < SnoPalette.MIN_ENTRIES || values.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(values.size) + for (i in values.indices) { + colors[i] = parseHexColor(values[i]) ?: return null + } + return SnoPalette(colors) + } + + private fun fromContent(content: String): SnoPalette? { + val entries = + try { + KotlinSerializationMapper.json.parseToJsonElement(content) as? JsonArray + } catch (_: Exception) { + null + } ?: return null + + if (entries.size < SnoPalette.MIN_ENTRIES || entries.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(entries.size) + for (i in 0 until entries.size) { + colors[i] = + when (val entry = entries[i]) { + is JsonArray -> { + if (entry.size != 3) return null + val r = channel(entry[0]) ?: return null + val g = channel(entry[1]) ?: return null + val b = channel(entry[2]) ?: return null + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + is JsonPrimitive -> { + if (!entry.isString) return null + parseHexColor(entry.content) ?: return null + } + else -> return null + } + } + return SnoPalette(colors) + } + + private fun channel(element: kotlinx.serialization.json.JsonElement): Int? { + val primitive = element as? JsonPrimitive ?: return null + if (primitive.isString) return null + val value = primitive.intOrNull ?: return null + return if (value in 0..255) value else null + } + + /** `#rrggbb`, and nothing else: no shorthand, no alpha, no bare hex. */ + private fun parseHexColor(value: String): Int? { + if (value.length != 7 || value[0] != '#') return null + var rgb = 0 + for (i in 1..6) { + val digit = + when (val c = value[i]) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> return null + } + rgb = (rgb shl 4) or digit + } + return (0xFF shl 24) or rgb + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt new file mode 100644 index 0000000000..3808407532 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt @@ -0,0 +1,419 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlin.math.abs +import kotlin.math.roundToInt + +/** What [SnoParser] made of a payload. */ +@Immutable +sealed class SnoResult { + @Immutable + data class Valid( + val payload: SnoPayload, + ) : SnoResult() + + /** + * The payload was refused. [rule] names the numbered rule of DECK-0003 §1.9 + * that failed, so a client can say why rather than failing silently, which + * §3.1 asks it to do. + */ + @Immutable + data class Invalid( + val rule: String, + val reason: String, + ) : SnoResult() + + fun payloadOrNull(): SnoPayload? = (this as? Valid)?.payload +} + +/** + * Reads a Simple Nostr Object out of an event's content (DECK-0003 §1). + * + * §1.9 is enforced in full and in its own order: a payload that fails any rule + * is refused whole, because "a partially valid object is not rendered + * partially: a face index pointing past the end of the vertex list is not a + * defect that degrades gracefully". Counts are checked against the arrays + * themselves and buffers are sized from those counts, never from a number the + * payload supplies — the format has no length fields for exactly this reason + * (§6). + * + * Written against the deck rather than ported, and checked against both + * reference implementations: `decks/sno-reference.py` in the cyberspace + * repository, which carries a rejection case per rule, and `sno-core`, the MIT + * TypeScript library ONOSENDAI and the snocrash workshop both read with. Where + * the two disagree, see [readLiteralTriple]. + */ +object SnoParser { + /** + * @param fetchedPalette the palette an [SnoPaletteRef.Event] resolved to, if + * one has been fetched since. Absent, a reference reads as the built-in, + * which §1.3b requires: a reader MUST render without waiting for anything. + */ + fun parse( + content: String, + fetchedPalette: SnoPalette? = null, + ): SnoResult { + val root = + try { + KotlinSerializationMapper.json.parseToJsonElement(content) as? JsonObject + } catch (_: Exception) { + null + } ?: return SnoResult.Invalid("json", "content is not a JSON object") + + return parse(root, fetchedPalette) + } + + fun parse( + root: JsonObject, + fetchedPalette: SnoPalette? = null, + ): SnoResult { + // Rule 1. `v` is 1 or 2. A `type` field, if present, is ignored and never + // rejected on (§1.1a): the field never said anything the kind did not, + // and rejecting on noise would refuse every object already published. + val version = root["v"].asIntOrNull() + if (version != 1 && version != 2) return SnoResult.Invalid("1", "v is not 1 or 2") + + // Rule 2. The three required arrays, and vertices parallel to colors. + val vertices = root["vertices"] as? JsonArray ?: return SnoResult.Invalid("2", "vertices is not an array") + val colors = root["colors"] as? JsonArray ?: return SnoResult.Invalid("2", "colors is not an array") + val faces = root["faces"] as? JsonArray ?: return SnoResult.Invalid("2", "faces is not an array") + if (vertices.size != colors.size) return SnoResult.Invalid("2", "vertices and colors differ in length") + + // Rule 3. The limits, enforced before anything is allocated from them (§6). + if (vertices.size > SnoPayload.MAX_VERTICES) return SnoResult.Invalid("3", "more than ${SnoPayload.MAX_VERTICES} vertices") + if (faces.size > SnoPayload.MAX_FACES) return SnoResult.Invalid("3", "more than ${SnoPayload.MAX_FACES} faces") + + // Rule 4. + val mode = SnoMode.parseOrNull(root["mode"].asStringOrNull()) ?: return SnoResult.Invalid("4", "mode is not solid, points or lines") + + // Rule 5. + val unit = root["unit"].asIntOrNull() ?: return SnoResult.Invalid("5", "unit is not an integer") + if (unit < 0 || unit > SnoPayload.MAX_UNIT) return SnoResult.Invalid("5", "unit is outside 0..${SnoPayload.MAX_UNIT}") + + // Rule 7, before rule 6, because a position needs both halves to exist + // before it can be assembled. Absent ticks means every position is whole. + val ticks = expandTicks(root["ticks"], vertices.size) ?: return SnoResult.Invalid("7", "ticks do not expand to one remainder per vertex") + + // Rule 6, and the position bound this reader adds (see MAX_TICKS_FROM_ORIGIN). + val positions = IntArray(vertices.size * 3) + for (i in 0 until vertices.size) { + val triple = vertices[i] as? JsonArray ?: return SnoResult.Invalid("6", "vertex $i is not an array") + if (triple.size != 3) return SnoResult.Invalid("6", "vertex $i is not three integers") + for (axis in 0..2) { + val whole = triple[axis].asIntOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers") + if (abs(whole) > SnoPayload.MAX_EXTENT) return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + val total = whole * SnoPayload.TICKS_PER_UNIT + ticks[i * 3 + axis] + if (abs(total) > SnoPayload.MAX_TICKS_FROM_ORIGIN) return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + // §2: a `v: 1` payload has +Z away from the viewer, so its Z is + // negated on read, which renders the object exactly as its author + // built it. A `v: 2` payload is read as written. + positions[i * 3 + axis] = if (axis == 2 && version == 1) -total else total + } + } + + // Rule 8. + val faceIndices = IntArray(faces.size * 3) + for (i in 0 until faces.size) { + val triple = faces[i] as? JsonArray ?: return SnoResult.Invalid("8", "face $i is not an array") + if (triple.size != 3) return SnoResult.Invalid("8", "face $i is not three indices") + val a = triple[0].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + val b = triple[1].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + val c = triple[2].asIntOrNull() ?: return SnoResult.Invalid("8", "face $i has a non-integer index") + if (a < 0 || b < 0 || c < 0 || a >= vertices.size || b >= vertices.size || c >= vertices.size) { + return SnoResult.Invalid("8", "face $i points at a vertex that does not exist") + } + if (a == b || b == c || a == c) return SnoResult.Invalid("8", "face $i does not have three distinct vertices") + faceIndices[i * 3] = a + faceIndices[i * 3 + 1] = b + faceIndices[i * 3 + 2] = c + } + + // Rule 8a. An unresolved reference counts as the built-in and is never a + // reason to reject (§1.3b). + val paletteRef = readPaletteRef(root["palette"]) ?: return SnoResult.Invalid("8a", "palette is not a known name, a well-formed nevent/naddr, or 2..256 RGB entries") + val palette = + when (paletteRef) { + is SnoPaletteRef.Inline -> paletteRef.palette + is SnoPaletteRef.Event -> fetchedPalette ?: SnoPalette.BUILT_IN + SnoPaletteRef.BuiltIn -> SnoPalette.BUILT_IN + } + + // Rule 8b. + val vertexColors = IntArray(colors.size) + for (i in 0 until colors.size) { + vertexColors[i] = readColor(colors[i], version, palette) ?: return SnoResult.Invalid("8b", "colors[$i] is not a valid colour for a v$version payload") + } + + // Rule 8c. + val faceColorsField = root["facecolors"] + val faceColors = + if (faceColorsField == null) { + null + } else { + val expanded = expandFaceColors(faceColorsField, faces.size, palette.size) ?: return SnoResult.Invalid("8c", "facecolors do not expand to one valid index per face") + IntArray(expanded.size) { palette[expanded[it]] } + } + + // Rule 10. + val upField = root["up"] + if (upField != null && upField.asBooleanOrNull() == null) return SnoResult.Invalid("10", "up is not a boolean") + val up = upField?.asBooleanOrNull() == true + val spinField = root["spin"] + // Validated whether or not `up` is present, because a payload carrying a + // nonsense bearing is malformed even where the bearing is unused (§1.7). + if (spinField != null) { + val spin = spinField.asIntOrNull() + if (spin == null || spin < 0 || spin > 359) return SnoResult.Invalid("10", "spin is not an integer in 0..359") + } + + // Rule 9. `extent` is repaired rather than validated: out of range becomes + // the default, then it grows to contain the data. The data wins and the + // hint is corrected, so an object is never refused for disagreeing with + // its own extent (§1.8). + val declared = root["extent"].asIntOrNull() + val repaired = if (declared != null && declared >= SnoPayload.MIN_EXTENT && declared <= SnoPayload.MAX_EXTENT) declared else SnoPayload.DEFAULT_EXTENT + val extent = grownExtent(repaired, positions) + + return SnoResult.Valid( + SnoPayload( + version = version, + name = root["name"].asStringOrNull()?.take(SnoPayload.MAX_NAME) ?: "", + unit = unit, + extent = extent, + mode = mode, + positions = positions, + colors = vertexColors, + faces = faceIndices, + faceColors = faceColors, + paletteRef = paletteRef, + up = up, + // §1.7: a reader MUST ignore the value of `spin` when `up` is not true. + spin = if (up) spinField?.asIntOrNull() ?: 0 else 0, + ), + ) + } + + /** + * The grid half-width that holds every vertex, in model units (§1.8). + * Rounded away from zero, so a vertex at 8 units and one tick needs 9. + */ + private fun grownExtent( + declared: Int, + positions: IntArray, + ): Int { + var needed = declared + for (tick in positions) { + val units = (abs(tick) + SnoPayload.TICKS_PER_UNIT - 1) / SnoPayload.TICKS_PER_UNIT + if (units > needed) needed = units + } + return needed + } + + /** + * The sub-unit part of every position, run-length decoded (§1.2). + * + * An entry is either a triple, standing for one vertex, or a negative + * integer `-N`, standing for N consecutive vertices whose remainder is + * `[0, 0, 0]`. Zero and positive integers are not valid entries, and the + * entries must expand to exactly one remainder per vertex. Absent means + * every position is whole. + */ + private fun expandTicks( + field: JsonElement?, + vertexCount: Int, + ): IntArray? { + val out = IntArray(vertexCount * 3) + if (field == null) return out + + val entries = field as? JsonArray ?: return null + var written = 0 + for (entry in entries) { + when (entry) { + is JsonArray -> { + if (entry.size != 3) return null + if (written >= vertexCount) return null + for (axis in 0..2) { + val v = entry[axis].asIntOrNull() ?: return null + if (v < 0 || v >= SnoPayload.TICKS_PER_UNIT) return null + out[written * 3 + axis] = v + } + written++ + } + is JsonPrimitive -> { + val run = entry.asIntOrNull() ?: return null + if (run >= 0) return null + val count = -run + if (written + count > vertexCount) return null + written += count + } + else -> return null + } + } + return if (written == vertexCount) out else null + } + + /** + * One palette index per face, run-length decoded (§1.4a). + * + * An entry is either a palette index, read exactly as §1.3 reads one, or a + * negative integer `-N` standing for N further faces of the index before + * it. The first entry must be an index, since a run has nothing to repeat + * before one, and the sign is what separates the two kinds of entry — + * which works because an index is never negative. + */ + private fun expandFaceColors( + field: JsonElement, + faceCount: Int, + paletteSize: Int, + ): IntArray? { + val entries = field as? JsonArray ?: return null + val out = IntArray(faceCount) + var written = 0 + var previous = -1 + for (entry in entries) { + val value = (entry as? JsonPrimitive).asIntOrNull() ?: return null + if (value < 0) { + if (previous < 0) return null + val count = -value + if (written + count > faceCount) return null + repeat(count) { out[written++] = previous } + } else { + if (value >= paletteSize) return null + if (written >= faceCount) return null + out[written++] = value + previous = value + } + } + return if (written == faceCount) out else null + } + + /** §1.3a, with rule 8a's shapes. Null means the payload is refused. */ + private fun readPaletteRef(field: JsonElement?): SnoPaletteRef? { + if (field == null) return SnoPaletteRef.BuiltIn + + val name = field.asStringOrNull() + if (name != null) { + if (name == SnoBuiltInPalette.NAME) return SnoPaletteRef.BuiltIn + val entity = Nip19Parser.uriToRoute(name)?.entity + return if (entity is NEvent || entity is NAddress) SnoPaletteRef.Event(name) else null + } + + val entries = field as? JsonArray ?: return null + if (entries.size < SnoPalette.MIN_ENTRIES || entries.size > SnoPalette.MAX_ENTRIES) return null + val colors = IntArray(entries.size) + for (i in 0 until entries.size) { + val rgb = entries[i] as? JsonArray ?: return null + if (rgb.size != 3) return null + val r = rgb[0].asIntOrNull() ?: return null + val g = rgb[1].asIntOrNull() ?: return null + val b = rgb[2].asIntOrNull() ?: return null + if (r !in 0..255 || g !in 0..255 || b !in 0..255) return null + colors[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + return SnoPaletteRef.Inline(SnoPalette(colors)) + } + + private fun readColor( + field: JsonElement, + version: Int, + palette: SnoPalette, + ): Int? { + if (field is JsonArray) return readLiteralTriple(field) + + // An index is version 2's form, and version 1 never had one. + if (version == 1) return null + val index = field.asIntOrNull() ?: return null + if (index < 0 || index >= palette.size) return null + return palette[index] + } + + /** + * A literal `[r, g, b]` of numbers from 0 to 1, clamped on read and taken + * exactly as written — never snapped to the nearest palette entry, because + * snapping on read would change objects nobody asked to change (§1.3). + * + * This is version 1's colour, and it is **also accepted in version 2**, + * which is the one place this reader is knowingly more forgiving than + * `sno-reference.py`. Version 2 arrived as two changes that did not ship + * together: the wire turned right-handed first, and colour became a palette + * index two releases later. Objects written in between declare `v: 2`, + * carry triples, and are correct in every other respect including their + * frame. §5 permits a reader to be generous with them, `sno-core` was + * changed to accept them because refusing orphaned real work, and three of + * the seven objects on the network are of exactly this shape — so a strict + * reader shows an error for nearly half of what exists. + * + * The two forms cannot be confused, because one is an array and the other + * an integer. Note that the frame still follows the declared version: one + * of these is in the v2 frame and flipping it as though it were v1 would + * mirror the object. + */ + private fun readLiteralTriple(triple: JsonArray): Int? { + if (triple.size != 3) return null + val r = channel(triple[0]) ?: return null + val g = channel(triple[1]) ?: return null + val b = channel(triple[2]) ?: return null + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + private fun channel(field: JsonElement): Int? { + val raw = (field as? JsonPrimitive)?.let { if (it.isString) null else it.doubleOrNull } ?: return null + val clamped = + if (raw < 0.0) { + 0.0 + } else if (raw > 1.0) { + 1.0 + } else { + raw + } + return (clamped * 255.0).roundToInt() + } + + private fun JsonElement?.asIntOrNull(): Int? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.intOrNull + } + + private fun JsonElement?.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } + + private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.booleanOrNull + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt new file mode 100644 index 0000000000..db7bdc4cdd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt @@ -0,0 +1,110 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable + +/** + * One Simple Nostr Object: a small triangle mesh on an integer lattice + * (DECK-0003 §1). A single connected budget of vertices, colours and faces; + * not a scene and not a hierarchy. + * + * Positions are exact. [positions] holds **total ticks** — whole units times + * [TICKS_PER_UNIT] plus the sub-unit remainder — as a single integer per axis, + * which is the same exact value the wire's `vertices`/`ticks` pair carries and + * is what §1.2 requires welding, deduplication, equality, sorting and hashing + * to run on. One 120th is not representable in binary floating point, so a + * reader that compares derived floats has given away the one guarantee the + * lattice makes. Convert to a float at the rasteriser and nowhere earlier. + * + * Colours arrive resolved: [colors] and [faceColors] are opaque ARGB, already + * looked up through whichever palette [paletteRef] named. A palette reference + * that has not been fetched resolves against the built-in, so the payload is + * always drawable; when the referenced event does arrive, re-parse the content + * with it rather than patching this object. + */ +@Immutable +class SnoPayload( + /** Format version, 1 or 2 (§2). Z has already been negated for a `v: 1` payload. */ + val version: Int, + /** A name for humans, truncated to [MAX_NAME] characters. */ + val name: String, + /** Scale exponent: one model unit is `2^unit` of the application's base unit (§1.6). */ + val unit: Int, + /** Grid half-width in model units, repaired and grown to contain the data (§1.8). */ + val extent: Int, + val mode: SnoMode, + /** Three total-tick coordinates per vertex: X, Y, Z. */ + val positions: IntArray, + /** One opaque ARGB colour per vertex, parallel to the vertices. */ + val colors: IntArray, + /** Three vertex indices per face. */ + val faces: IntArray, + /** One opaque ARGB colour per face, or null when every face interpolates its vertices (§1.4a). */ + val faceColors: IntArray?, + val paletteRef: SnoPaletteRef, + /** True when the object stands on the Earth's surface where it is placed (§1.7). */ + val up: Boolean, + /** With [up], the compass bearing the object's `+Z` faces, 0 to 359. */ + val spin: Int, +) { + val vertexCount: Int get() = colors.size + + val faceCount: Int get() = faces.size / 3 + + /** The total-tick coordinate of [vertex] on [axis] (0 = X, 1 = Y, 2 = Z). */ + fun tickAt( + vertex: Int, + axis: Int, + ): Int = positions[vertex * 3 + axis] + + companion object { + /** The lattice spacing: a tick is one 120th of a model unit (§1.2). */ + const val TICKS_PER_UNIT = 120 + + const val MAX_VERTICES = 512 + const val MAX_FACES = 1024 + const val MIN_EXTENT = 1 + const val MAX_EXTENT = 64 + const val DEFAULT_EXTENT = 8 + const val MAX_UNIT = 84 + const val MAX_NAME = 64 + + /** + * The farthest a vertex may lie from the origin on any axis, in ticks. + * + * DECK-0003 §1.8 states this bound as an obligation on publishers and + * lets a reader "reject such a payload" or "repair it by growing the + * extent"; §8's third open question admits that leaving it off readers + * is unsafe as a general rule, since "a payload of 512 vertices at 2^50 + * units is valid under this text and will produce a grid no renderer + * wants". Neither reference implementation bounds it — ONOSENDAI grows + * the extent without a ceiling, and `sno-reference.py` does not check. + * + * Amethyst rejects, which is the deliberate divergence, because it + * draws strangers' events in a feed and because `whole * 120` overflows + * an Int well before 2^50 units, so the bound is a representation limit + * here and not only a matter of taste. Nothing on the network is near + * it: the widest object published so far reaches 8 units. + */ + const val MAX_TICKS_FROM_ORIGIN = MAX_EXTENT * TICKS_PER_UNIT + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a9d7267e03..1df8103880 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -647,6 +649,8 @@ class EventFactory { FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + SnoObjectEvent.KIND -> SnoObjectEvent(id, pubKey, createdAt, tags, content, sig) + SnoAvatarEvent.KIND -> SnoAvatarEvent(id, pubKey, createdAt, tags, content, sig) GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig) GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig) GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt new file mode 100644 index 0000000000..987c43670d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The work an avatar owes (`CYBERSPACE_V2.md` §8.10). + * + * The expected values are computed independently from the section's own + * normative pseudocode rather than lifted from a client, so this checks the + * Kotlin against the specification and not against another port of it. + */ +class SnoAvatarWorkTest { + private data class Vector( + val name: String, + val expectedBits: Int, + val json: String, + ) + + private val vectors = + listOf( + Vector( + name = "one gibson", + expectedBits = 16, + json = "{\"v\":2,\"name\":\"one gibson\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[0,1,0]],\"colors\":[225,225,225],\"faces\":[[0,1,2]]}", + ), + Vector( + name = "tetra at unit 0", + expectedBits = 18, + json = "{\"v\":2,\"name\":\"tetra at unit 0\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "tetra at unit 12", + expectedBits = 42, + json = "{\"v\":2,\"name\":\"tetra at unit 12\",\"unit\":12,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "tetra at unit 33", + expectedBits = 84, + json = "{\"v\":2,\"name\":\"tetra at unit 33\",\"unit\":33,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"colors\":[225,225,225,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Vector( + name = "sub-unit only", + expectedBits = 16, + json = "{\"v\":2,\"name\":\"sub-unit only\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[0,0,0],[0,0,0]],\"colors\":[225,225,225],\"faces\":[[0,1,2]],\"ticks\":[[0,0,0],[40,0,0],[0,60,0]]}", + ), + Vector( + name = "detailed", + expectedBits = 25, + json = "{\"v\":2,\"name\":\"detailed\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[2,0,0],[3,0,0],[4,0,0],[0,1,0],[1,1,0],[2,1,0],[3,1,0],[4,1,0],[0,2,0],[1,2,0],[2,2,0],[3,2,0],[4,2,0],[0,3,0],[1,3,0],[2,3,0],[3,3,0],[4,3,0],[0,4,0],[1,4,0],[2,4,0],[3,4,0],[4,4,0],[0,0,1],[1,0,1],[2,0,1],[3,0,1],[4,0,1],[0,1,1],[1,1,1],[2,1,1],[3,1,1],[4,1,1],[0,2,1],[1,2,1],[2,2,1],[3,2,1],[4,2,1],[0,3,1],[1,3,1],[2,3,1],[3,3,1],[4,3,1],[0,4,1],[1,4,1],[2,4,1],[3,4,1],[4,4,1],[0,0,2],[1,0,2],[2,0,2],[3,0,2],[4,0,2],[0,1,2],[1,1,2],[2,1,2],[3,1,2],[4,1,2]],\"colors\":[225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2]]}", + ), + ) + + @Test + fun theLadderMatchesTheSpecification() { + vectors.forEach { vector -> + val payload = SnoParser.parse(vector.json).payloadOrNull() + assertTrue(payload != null, "${vector.name} did not parse") + assertEquals(vector.expectedBits, SnoAvatarWork.required(payload), vector.name) + } + } + + @Test + fun theFloorIsSixteenBits() { + // "reach: ... never below one gibson", and detail below the free + // thirty-two contributes nothing, so the smallest avatar owes the floor. + val tiny = SnoParser.parse("""{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[225],"faces":[]}""").payloadOrNull()!! + assertEquals(SnoAvatarWork.FLOOR_BITS, SnoAvatarWork.required(tiny)) + } + + @Test + fun reachCostsTwoBitsPerDoubling() { + // The ladder §8.10 names: one gibson 16 bits, two 18, four 20, sixteen 24. + fun atUnit(unit: Int) = + SnoAvatarWork.required( + SnoParser.parse("""{"v":2,"name":"r","unit":$unit,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""").payloadOrNull()!!, + ) + assertEquals(16, atUnit(0), "one gibson") + assertEquals(18, atUnit(1), "two gibsons") + assertEquals(20, atUnit(2), "four gibsons") + assertEquals(24, atUnit(4), "sixteen gibsons") + } + + @Test + fun aSectorSizedAvatarIsOutOfReachOfAnyHashPower() { + // "one the size of cyberspace about 190, which is to say never." + val huge = SnoParser.parse("""{"v":2,"name":"vast","unit":84,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""").payloadOrNull()!! + assertTrue(SnoAvatarWork.required(huge) > 150, "a cyberspace-sized avatar must be unmineable") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt new file mode 100644 index 0000000000..fdccd1ea19 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoBuiltInPaletteTest.kt @@ -0,0 +1,64 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The built-in palette is normative (DECK-0003 Appendix C) and 256 entries long, + * generated here from `decks/sno-palette.json` rather than typed. These anchors + * are the ones the deck itself names, so a bad paste cannot ship silently. + */ +class SnoBuiltInPaletteTest { + @Test + fun theresTwoHundredAndFiftySixOfThem() { + assertEquals(256, SnoBuiltInPalette.COLORS.size) + assertEquals(256, SnoPalette.BUILT_IN.size) + } + + @Test + fun theAnchorsAppendixANamesAreWhereItSaysTheyAre() { + assertEquals(0xFFFF0000.toInt(), SnoBuiltInPalette.COLORS[238], "238 is pure red") + assertEquals(0xFF00FF00.toInt(), SnoBuiltInPalette.COLORS[235], "235 is pure green") + assertEquals(0xFF0000FF.toInt(), SnoBuiltInPalette.COLORS[239], "239 is pure blue") + assertEquals(0xFFFFFFFF.toInt(), SnoBuiltInPalette.COLORS[225], "225 is white") + assertEquals(0xFF000000.toInt(), SnoBuiltInPalette.COLORS[224], "224 is black") + } + + @Test + fun theRampsAndSteelsAreWhereTheLayoutSaysTheyAre() { + // 0..191 is 24 hues of 8 steps; the first entry is the darkest of hue 0. + assertEquals(0xFF003632.toInt(), SnoBuiltInPalette.COLORS[0]) + // 192..223 is 32 steels running black to white. + assertEquals(0xFF010101.toInt(), SnoBuiltInPalette.COLORS[192]) + assertEquals(0xFFF8F8F8.toInt(), SnoBuiltInPalette.COLORS[223]) + // 224..255 is the signatures; the last is a deep ground. + assertEquals(0xFF1C1C0F.toInt(), SnoBuiltInPalette.COLORS[255]) + } + + @Test + fun everyEntryIsOpaque() { + SnoBuiltInPalette.COLORS.forEach { + assertEquals(0xFF, (it shr 24) and 0xFF) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt new file mode 100644 index 0000000000..639350ff1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoLiveFixtureTest.kt @@ -0,0 +1,165 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Every `kind 33331` object that existed on the network, read back. + * + * Collected 2026-09-21 from relay.damus.io and nos.lol (cyberspace.nostr1.com + * and relay.primal.net held none). Seven objects from six authors. + * + * Three of them are the legacy cohort DECK-0003 §5 describes: `v: 2` payloads + * whose `colors` are still literal `[r, g, b]` triples from before the palette + * change of 2026-09-16. `decks/sno-reference.py` refuses those three; `sno-core` + * v0.1.4 accepts them deliberately, and so do we. **All seven must read**, which + * is the parity target — a strict reader shows an error for nearly half of what + * exists. + */ +class SnoLiveFixtureTest { + private data class Fixture( + val id: String, + val name: String, + val vertices: Int, + val faces: Int, + val mode: SnoMode, + val unit: Int, + val legacyTriples: Boolean, + val json: String, + ) + + private val fixtures = + listOf( + Fixture( + id = "fbe6a776bde5b90d", + name = "hello world", + vertices = 4, + faces = 4, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"hello world\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[3,0,0],[1,0,-3],[1,3,-1]],\"ticks\":[-4],\"colors\":[[1,0.15,0.15],[0.15,1,0.3],[0.2,0.4,1],[1,1,0.6]],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Fixture( + id = "4b05663f46353d11", + name = "second thought", + vertices = 4, + faces = 4, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"second thought\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[3,0,0],[1,0,-3],[1,3,-1]],\"ticks\":[-4],\"colors\":[[1,0.15,0.15],[0.15,1,0.3],[0.2,0.4,1],[1,1,0.6]],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}", + ), + Fixture( + id = "04df8e12623fe071", + name = "Ship", + vertices = 18, + faces = 24, + mode = SnoMode.LINES, + unit = 0, + legacyTriples = true, + json = "{\"v\":2,\"type\":\"shard\",\"name\":\"Ship\",\"unit\":0,\"extent\":8,\"mode\":\"lines\",\"vertices\":[[0,0,0],[-1,0,0],[0,0,-2],[0,0,1],[-1,0,0],[0,0,0],[0,0,0],[0,-1,0],[-1,-1,0],[1,-1,0],[-1,0,0],[-1,0,1],[-1,0,0],[0,0,1],[0,0,0],[0,0,0],[0,0,1],[-1,0,1]],\"ticks\":[[48,0,0],[72,0,0],[0,0,96],-1,[72,24,72],[48,24,72],[0,48,0],[0,96,0],[0,96,72],[0,96,72],[48,0,96],[96,0,0],[72,24,96],[24,0,0],[72,0,96],[48,24,96],[48,0,24],[72,0,24]],\"colors\":[[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.08627450980392157,0.0784313725490196,0.1411764705882353],[0.5882352941176471,0.14901960784313725,1],[0.5882352941176471,0.14901960784313725,1],[0.21176470588235294,0.054901960784313725,0.3607843137254902],[0.08627450980392157,0.0784313725490196,0.1411764705882353],[0.5882352941176471,0.14901960784313725,1],[0.5882352941176471,0.14901960784313725,1],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.8352941176470589,0],[1,0.13725490196078433,0.13725490196078433],[1,0.13725490196078433,0.13725490196078433]],\"faces\":[[9,5,0],[4,1,8],[2,0,7],[1,2,7],[2,6,0],[1,6,2],[6,1,3],[3,0,6],[3,0,7],[3,1,7],[3,9,0],[3,1,8],[4,1,3],[5,0,3],[4,8,3],[5,3,9],[15,13,14],[11,12,10],[17,12,10],[12,11,17],[10,11,17],[15,13,16],[14,15,16],[14,16,13]]}", + ), + Fixture( + id = "89f510e1fc40fe85", + name = "Shard 4 copy", + vertices = 35, + faces = 20, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Shard 4 copy\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[4,0,0],[2,0,-2],[0,0,-2],[0,2,0],[0,5,0],[0,2,-2],[0,2,-2],[4,0,-4],[4,0,-5],[3,0,-5],[3,0,-6],[2,0,-6],[1,0,-6],[1,0,-5],[0,0,-5],[0,0,-4],[0,0,-3],[1,0,-3],[1,0,-2],[2,0,-2],[3,0,-2],[3,0,-3],[4,0,-3],[4,0,-4],[7,0,-4],[5,0,-5],[4,0,-8],[2,0,-6],[0,0,-6],[1,0,-4],[0,0,-2],[2,0,-2],[4,0,0],[5,0,-3]],\"ticks\":[-35],\"colors\":[246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246,246],\"faces\":[[0,1,2],[0,2,3],[4,7,6],[4,6,5],[0,4,5],[0,5,1],[3,2,6],[3,6,7],[0,3,7],[0,7,4],[1,5,6],[1,6,2],[34,25,26],[34,26,27],[34,27,28],[34,28,29],[34,29,30],[34,30,31],[34,31,32],[32,33,34]]}", + ), + Fixture( + id = "47031e0f64e233d6", + name = "Shard 2", + vertices = 5, + faces = 5, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Shard 2\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[-1,0,2],[3,0,2],[3,0,-2],[-1,0,-2],[1,4,0]],\"ticks\":[-5],\"colors\":[226,226,226,226,226],\"faces\":[[1,0,4],[2,1,4],[3,2,4],[0,1,2],[0,2,3]]}", + ), + Fixture( + id = "d12996088aa99127", + name = "first object", + vertices = 118, + faces = 94, + mode = SnoMode.SOLID, + unit = 0, + legacyTriples = false, + json = "{\"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]]}", + ), + Fixture( + id = "8fe392c2fd93d6a7", + name = "Triforce", + vertices = 12, + faces = 18, + mode = SnoMode.SOLID, + unit = 12, + legacyTriples = false, + json = "{\"v\":2,\"name\":\"Triforce\",\"unit\":12,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,2,0],[-1,0,0],[1,0,0],[-2,-1,0],[0,-1,0],[2,-1,0],[0,2,-1],[-1,0,-1],[1,0,-1],[-2,-1,-1],[0,-1,-1],[2,-1,-1]],\"ticks\":[[0,40,0],[0,80,0],[0,80,0],-3,[0,40,80],[0,80,80],[0,80,80],[0,0,80],[0,0,80],[0,0,80]],\"colors\":[249,249,249,249,249,249,249,249,249,249,249,249],\"faces\":[[1,0,2],[3,1,4],[2,4,5],[7,6,8],[9,7,10],[8,10,11],[5,0,6],[6,11,5],[9,11,5],[5,3,9],[9,6,0],[0,3,9],[10,7,1],[1,4,10],[4,2,8],[8,10,4],[1,2,8],[8,7,1]]}", + ), + ) + + @Test + fun everyObjectOnTheNetworkReads() { + fixtures.forEach { fixture -> + val result = SnoParser.parse(fixture.json) + assertTrue(result is SnoResult.Invalid == false, "${fixture.id} (${fixture.name}) was refused: $result") + val payload = (result as SnoResult.Valid).payload + assertEquals(fixture.name, payload.name, fixture.id) + assertEquals(fixture.vertices, payload.vertexCount, fixture.id) + assertEquals(fixture.faces, payload.faceCount, fixture.id) + assertEquals(fixture.mode, payload.mode, fixture.id) + assertEquals(fixture.unit, payload.unit, fixture.id) + } + } + + @Test + fun theLegacyCohortIsThreeOfSeven() { + assertEquals(7, fixtures.size) + assertEquals(3, fixtures.count { it.legacyTriples }) + } + + @Test + fun everyColourIsOpaque() { + fixtures.forEach { fixture -> + val payload = SnoParser.parse(fixture.json).payloadOrNull()!! + payload.colors.forEach { argb -> + assertEquals(0xFF, (argb shr 24) and 0xFF, "${fixture.id} has a transparent colour") + } + } + } + + @Test + fun everyFaceIndexIsInRange() { + fixtures.forEach { fixture -> + val payload = SnoParser.parse(fixture.json).payloadOrNull()!! + payload.faces.forEach { index -> + assertTrue(index >= 0 && index < payload.vertexCount, "${fixture.id} has a face index out of range") + } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt new file mode 100644 index 0000000000..bced983477 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReaderTest.kt @@ -0,0 +1,133 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Reading a palette out of the event an object's reference named (§1.3b). + * + * The `c`-tag shapes here are taken from real `kind 3367` colour moments + * sampled off relay.damus.io and nos.lol: four to six `#rrggbb` values in + * document order, an emoji in `content`, and `layout` / `alt` / `client` / + * `name` alongside. A survey of five relays found 205 such events from 51 + * pubkeys and every one of them is shaped this way, so the tag form is not a + * hypothetical. + */ +class SnoPaletteEventReaderTest { + private fun event( + tags: Array>, + content: String = "", + kind: Int = 3367, + ) = Event("00".repeat(32), "11".repeat(32), 0L, kind, tags, content, "22".repeat(64)) + + @Test + fun theCTagsAreThePaletteAndTheirOrderIsTheIndex() { + val palette = + SnoPaletteEventReader.read( + event( + arrayOf( + arrayOf("name", "dusk"), + arrayOf("c", "#93A8D7"), + arrayOf("c", "#49413A"), + arrayOf("c", "#534E46"), + arrayOf("c", "#D2D1BF"), + arrayOf("alt", "a colour moment"), + arrayOf("client", "somewhere"), + ), + content = "\uD83C\uDF05", + ), + ) + + assertNotNull(palette) + assertEquals(4, palette.size) + assertEquals(0xFF93A8D7.toInt(), palette[0]) + assertEquals(0xFF49413A.toInt(), palette[1]) + assertEquals(0xFFD2D1BF.toInt(), palette[3]) + } + + @Test + fun lowercaseHexReadsTheSame() { + val palette = SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")))) + assertNotNull(palette) + assertEquals(0xFFFF0000.toInt(), palette[0]) + } + + @Test + fun aMalformedColourDisqualifiesTheTagForm() { + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "ff0000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#gg0000"), arrayOf("c", "#00ff00"))))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff000000"), arrayOf("c", "#00ff00"))))) + } + + @Test + fun oneColourIsNotAPalette() { + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"))))) + } + + @Test + fun theContentFormIsAcceptedAsLegacy() { + // Accepted only because an earlier draft of §1.3b described it; nothing + // should write it now, and no event on the network does. + val triples = SnoPaletteEventReader.read(event(arrayOf(), content = "[[255,0,0],[0,255,0]]")) + assertNotNull(triples) + assertEquals(0xFFFF0000.toInt(), triples[0]) + + val strings = SnoPaletteEventReader.read(event(arrayOf(), content = """["#ff0000","#00ff00"]""")) + assertNotNull(strings) + assertEquals(0xFF00FF00.toInt(), strings[1]) + } + + @Test + fun theTagsWinOverTheContent() { + val palette = + SnoPaletteEventReader.read( + event( + arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")), + content = "[[0,0,255],[0,0,255],[0,0,255]]", + ), + ) + assertNotNull(palette) + assertEquals(2, palette.size) + assertEquals(0xFFFF0000.toInt(), palette[0]) + } + + @Test + fun anEventThatIsNotAPaletteReadsAsNothing() { + // Which counts as a failed fetch, which means the built-in — never a + // refusal to draw the object that named it. + assertNull(SnoPaletteEventReader.read(event(arrayOf(arrayOf("p", "ff")), content = "hello"))) + assertNull(SnoPaletteEventReader.read(event(arrayOf(), content = ""))) + } + + @Test + fun theKindIsNotConstrained() { + // "This format does not define a palette kind and does not want one." + val palette = SnoPaletteEventReader.read(event(arrayOf(arrayOf("c", "#ff0000"), arrayOf("c", "#00ff00")), kind = 1)) + assertNotNull(palette) + assertEquals(2, palette.size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt new file mode 100644 index 0000000000..808c4b2d63 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt @@ -0,0 +1,319 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What DECK-0003 says a reader must *accept*, which the rejection table cannot + * cover: the worked example, the exactness the lattice promises, the two places + * the format repairs rather than refuses, and the run-length encodings. + */ +class SnoParserAcceptanceTest { + private val appendixA = + """ + {"v":2,"name":"tetra","unit":0,"extent":8,"mode":"solid", + "vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]], + "ticks":[-4], + "colors":[238,235,239,225], + "faces":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]} + """.trimIndent() + + @Test + fun appendixAReads() { + val payload = SnoParser.parse(appendixA).payloadOrNull() + assertNotNull(payload) + assertEquals("tetra", payload.name) + assertEquals(4, payload.vertexCount) + assertEquals(4, payload.faceCount) + assertEquals(SnoMode.SOLID, payload.mode) + assertEquals(0, payload.unit) + assertEquals(8, payload.extent) + + // The built-in palette names these: pure red, green, blue, white. + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + assertEquals(0xFF00FF00.toInt(), payload.colors[1]) + assertEquals(0xFF0000FF.toInt(), payload.colors[2]) + assertEquals(0xFFFFFFFF.toInt(), payload.colors[3]) + + // The fourth vertex is at (1, 2, 1) whole units, exactly. + assertEquals(120, payload.tickAt(3, 0)) + assertEquals(240, payload.tickAt(3, 1)) + assertEquals(120, payload.tickAt(3, 2)) + } + + @Test + fun fortyTicksIsExactlyOneThirdOfAUnit() { + // The whole reason positions are integers: a third of a unit lands on the + // lattice and stays there. Three of them make exactly one unit, which is + // a thing no float format can promise. + val payload = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", ticks = ""","ticks":[[40,0,0],[40,0,0],[40,0,0],-1]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(40, payload.tickAt(0, 0)) + assertEquals(SnoPayload.TICKS_PER_UNIT, payload.tickAt(0, 0) * 3) + } + + @Test + fun absentTicksMeansEveryPositionIsWhole() { + val withRun = SnoParser.parse(appendixA).payloadOrNull()!! + val without = SnoParser.parse(payload(ticks = "")).payloadOrNull()!! + assertTrue(withRun.positions.contentEquals(without.positions), "[-4] and an absent ticks array must mean the same thing") + } + + @Test + fun extentIsRepairedAndThenGrown() { + // Out of range becomes the default of 8... + assertEquals(8, SnoParser.parse(payload(extent = ""","extent":0""")).payloadOrNull()!!.extent) + assertEquals(8, SnoParser.parse(payload(extent = ""","extent":999""")).payloadOrNull()!!.extent) + assertEquals(8, SnoParser.parse(payload(extent = "")).payloadOrNull()!!.extent) + + // ...and then grows until it contains every vertex. The data wins and the + // hint is corrected; the object is never refused for disagreeing with it. + val wide = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[9,2,1]]", extent = ""","extent":8""")).payloadOrNull() + assertNotNull(wide) + assertEquals(9, wide.extent) + } + + @Test + fun aVertexPastOneUnitGrowsTheExtentToTheNextWholeUnit() { + val justOver = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[8,2,1]]", ticks = ""","ticks":[-3,[1,0,0]]""")).payloadOrNull() + assertNotNull(justOver) + assertEquals(9, justOver.extent, "8 units and one tick needs a grid of 9") + } + + @Test + fun faceColoursRunLengthDecode() { + val flat = SnoParser.parse(payload(extra = ""","facecolors":[238,-3]""")).payloadOrNull() + assertNotNull(flat) + val faceColors = flat.faceColors + assertNotNull(faceColors) + assertEquals(4, faceColors.size) + faceColors.forEach { assertEquals(0xFFFF0000.toInt(), it) } + } + + @Test + fun absentFaceColoursIsNotColourZero() { + assertNull(SnoParser.parse(appendixA).payloadOrNull()!!.faceColors) + } + + @Test + fun vertexColoursSurviveFaceColours() { + // §1.4a: vertex colours are untouched and still colour the points and the + // lines, so an object may carry both without contradiction. + val both = SnoParser.parse(payload(extra = ""","facecolors":[238,-3]""")).payloadOrNull()!! + assertEquals(0xFF00FF00.toInt(), both.colors[1]) + } + + @Test + fun versionOneNegatesZAndVersionTwoDoesNot() { + val v2 = SnoParser.parse(payload(version = 2)).payloadOrNull()!! + val v1 = SnoParser.parse(payload(version = 1, colors = """[[1,0,0],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull()!! + + // Vertex 2 is at z = 2 units as written. + assertEquals(240, v2.tickAt(2, 2)) + assertEquals(-240, v1.tickAt(2, 2)) + // X and Y are untouched by the flip. + assertEquals(v2.tickAt(2, 0), v1.tickAt(2, 0)) + assertEquals(v2.tickAt(2, 1), v1.tickAt(2, 1)) + } + + @Test + fun aVersionTwoPayloadCarryingLiteralTriplesStillReads() { + // The one place this reader is knowingly more forgiving than + // sno-reference.py, and it matches sno-core. See SnoParser.readLiteralTriple. + val payload = SnoParser.parse(payload(colors = """[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF, (payload.colors[0] shr 16) and 0xFF) + assertEquals(38, (payload.colors[0] shr 8) and 0xFF, "0.15 of 255, rounded") + + // ...and it keeps the v2 frame. Flipping it as though it were v1 would + // mirror the object. + assertEquals(240, payload.tickAt(2, 2)) + } + + @Test + fun aVersionOnePayloadWithAnIndexIsRefused() { + // Version 1 never had an index, so this is not a colour it can read. + val result = SnoParser.parse(payload(version = 1)) + assertTrue(result is SnoResult.Invalid) + assertEquals("8b", result.rule) + } + + @Test + fun literalTriplesAreClampedNotRejected() { + val payload = SnoParser.parse(payload(colors = """[[2,-1,0.5],[0,1,0],[0,0,1],[1,1,1]]""")).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF, (payload.colors[0] shr 16) and 0xFF) + assertEquals(0x00, (payload.colors[0] shr 8) and 0xFF) + } + + @Test + fun aStrayTypeFieldIsIgnored() { + // §1.1a: rejecting on noise would refuse every object already published, + // and three of the seven on the network carry this field. + val payload = SnoParser.parse(payload(extra = ""","type":"shard"""")).payloadOrNull() + assertNotNull(payload) + assertEquals("tetra", payload.name) + } + + @Test + fun unknownFieldsAreIgnored() { + // §5: what allows an optional field to be added without a version bump. + assertNotNull(SnoParser.parse(payload(extra = ""","emissive":true,"whatever":[1,2,3]""")).payloadOrNull()) + } + + @Test + fun nameIsTruncatedNotRejected() { + val long = "x".repeat(200) + val payload = SnoParser.parse(payload(name = long)).payloadOrNull() + assertNotNull(payload) + assertEquals(SnoPayload.MAX_NAME, payload.name.length) + } + + @Test + fun aMissingNameIsToleratedBecauseNothingEnforcesIt() { + // §1.1's table calls `name` required and neither reference implementation + // checks it: sno-reference.py has no test at all and sno-core falls back + // to a default. Refusing here would be stricter than everything that + // exists, over a field that is decoration. + val payload = SnoParser.parse("""{"v":2,"unit":0,"mode":"points","vertices":[[0,0,0]],"colors":[0],"faces":[]}""").payloadOrNull() + assertNotNull(payload) + assertEquals("", payload.name) + } + + @Test + fun anEmptyFaceListIsAPointCloud() { + val payload = SnoParser.parse("""{"v":2,"name":"dust","unit":0,"mode":"points","vertices":[[0,0,0],[1,1,1]],"colors":[0,1],"faces":[]}""").payloadOrNull() + assertNotNull(payload) + assertEquals(0, payload.faceCount) + assertEquals(2, payload.vertexCount) + } + + @Test + fun spinIsIgnoredWithoutUpButStillValidated() { + // §1.7: a reader MUST ignore the value when `up` is not true, and MUST + // reject a spin out of range whether or not `up` is present. + val ignored = SnoParser.parse(payload(extra = ""","spin":90""")).payloadOrNull() + assertNotNull(ignored) + assertEquals(0, ignored.spin) + assertTrue(!ignored.up) + + val standing = SnoParser.parse(payload(extra = ""","up":true,"spin":90""")).payloadOrNull() + assertNotNull(standing) + assertEquals(90, standing.spin) + assertTrue(standing.up) + } + + @Test + fun upFalseIsTheSameAsAbsent() { + val payload = SnoParser.parse(payload(extra = ""","up":false,"spin":90""")).payloadOrNull() + assertNotNull(payload) + assertTrue(!payload.up) + assertEquals(0, payload.spin) + } + + @Test + fun anInlinePaletteIsUsed() { + val payload = + SnoParser + .parse(payload(colors = "[0,1,2,3]", extra = ""","palette":[[255,0,0],[0,255,0],[0,0,255],[255,255,255]]""")) + .payloadOrNull() + assertNotNull(payload) + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + assertTrue(payload.paletteRef is SnoPaletteRef.Inline) + } + + @Test + fun theRegisteredNameIsTheBuiltIn() { + val payload = SnoParser.parse(payload(extra = ""","palette":"cyberspace-neon-256"""")).payloadOrNull() + assertNotNull(payload) + assertEquals(SnoPaletteRef.BuiltIn, payload.paletteRef) + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + } + + @Test + fun anUnknownPaletteNameIsRefused() { + val result = SnoParser.parse(payload(extra = ""","palette":"someone-elses-256"""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("8a", result.rule) + } + + @Test + fun anUnresolvedPaletteReferenceDrawsInTheBuiltIn() { + // §1.3b: a reference is never load-bearing. The worst case is an object + // drawn in the wrong colours, never one that cannot be drawn. + val reference = "nevent1qqsglcujct7e84485kkv4sdtsyfg9nszefsc7s40r4ss07a9nqpzzhghup94y" + val result = SnoParser.parse(payload(extra = ""","palette":"$reference"""")) + val payload = result.payloadOrNull() + assertNotNull(payload, "an unresolvable reference must never refuse the object: $result") + assertEquals(0xFFFF0000.toInt(), payload.colors[0], "indices name the built-in until the event is in hand") + assertTrue(payload.paletteRef is SnoPaletteRef.Event) + } + + @Test + fun aFetchedPaletteReplacesTheBuiltIn() { + val reference = "nevent1qqsglcujct7e84485kkv4sdtsyfg9nszefsc7s40r4ss07a9nqpzzhghup94y" + val fetched = SnoPalette(IntArray(256) { 0xFF123456.toInt() }) + val payload = SnoParser.parse(payload(extra = ""","palette":"$reference""""), fetched).payloadOrNull() + assertNotNull(payload) + assertEquals(0xFF123456.toInt(), payload.colors[0]) + } + + @Test + fun anNaddrReferenceIsAlsoWellFormed() { + // §1.3a accepts an naddr for a palette somebody publishes as an + // addressable event of their own, though what an naddr cannot do is name + // a kind 3367, which is where the palettes actually are. + val naddr = "naddr1qqyhqctvv468gefdxypzp68dx7vvdlltll5w6duccml7hllga5me33hla0l73mfhnrr0l6llqvzqqqqdyucnkls9" + val payload = SnoParser.parse(payload(extra = ""","palette":"$naddr"""")).payloadOrNull() + assertNotNull(payload) + assertTrue(payload.paletteRef is SnoPaletteRef.Event) + } + + @Test + fun aVertexBeyondTheBoundIsRefused() { + // Amethyst's own rule, and a deliberate divergence: §1.8 puts the bound on + // publishers and lets readers repair instead, which neither reference + // implementation does either way. + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]")) + assertTrue(result is SnoResult.Invalid) + assertEquals("bound", result.rule) + } + + @Test + fun aVertexExactlyAtTheBoundIsAccepted() { + assertNotNull(SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[64,2,1]]")).payloadOrNull()) + } + + private fun payload( + version: Int = 2, + name: String = "tetra", + vertices: String = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", + colors: String = "[238,235,239,225]", + ticks: String = ""","ticks":[-4]""", + extent: String = ""","extent":8""", + extra: String = "", + ) = """{"v":$version,"name":"$name","unit":0$extent,"mode":"solid","vertices":$vertices$ticks,"colors":$colors,"faces":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]$extra}""" +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt new file mode 100644 index 0000000000..9b5d214092 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserRejectionTest.kt @@ -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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * DECK-0003 §1.9, one rejection per numbered rule. + * + * This table is `_rejections()` from `decks/sno-reference.py` in the cyberspace + * repository, transcribed mechanically rather than by hand: each case is a + * one-field mutation of the Appendix A tetrahedron, and each was confirmed to + * fail the reference validator on the rule it claims before being written here. + * A port that passes this passes what the deck's own arbiter enforces. + */ +class SnoParserRejectionTest { + private data class Case( + val rule: String, + val json: String, + ) + + private val cases = + listOf( + Case("1", "{\"v\":3,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("1", "{\"v\":0,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("2", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case( + "3", + "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],\"ticks\":[-513],\"colors\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"faces\":[]}", + ), + Case("4", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"wireframe\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("5", "{\"v\":2,\"name\":\"tetra\",\"unit\":85,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("5", "{\"v\":2,\"name\":\"tetra\",\"unit\":1.5,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("6", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1.5]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-3],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[[0,0,120],-3],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("7", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,9]]}"), + Case("8", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,1]]}"), + Case("8a", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"palette\":[[255,0,0]]}"), + Case("8a", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"palette\":[[255,0,0,0],[0,0,0]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,256],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,-1],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8b", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[0,1,2,1.5],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238,-4]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[-4]}"), + Case("8c", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"facecolors\":[238,256,-2]}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"up\":\"yes\"}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"up\":true,\"spin\":360}"), + Case("10", "{\"v\":2,\"name\":\"tetra\",\"unit\":0,\"extent\":8,\"mode\":\"solid\",\"vertices\":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],\"ticks\":[-4],\"colors\":[238,235,239,225],\"faces\":[[0,1,2],[0,1,3],[1,2,3],[0,2,3]],\"spin\":360}"), + ) + + @Test + fun everyNumberedRuleRejects() { + cases.forEach { case -> + val result = SnoParser.parse(case.json) + assertTrue(result is SnoResult.Invalid, "rule ${case.rule} should have been refused: ${case.json}") + assertEquals(case.rule, result.rule, "wrong rule blamed for ${case.json} (said: ${result.reason})") + } + } + + @Test + fun theTableCoversEveryRule() { + assertEquals( + setOf("1", "2", "3", "4", "5", "6", "7", "8", "8a", "8b", "8c", "10"), + cases.map { it.rule }.toSet(), + ) + assertEquals(25, cases.size) + } + + @Test + fun contentThatIsNotAJsonObjectIsRefused() { + listOf("", "not json", "[]", "null", "12").forEach { + assertTrue(SnoParser.parse(it) is SnoResult.Invalid, "should have been refused: $it") + } + } +} From 1ac64bcc79b5765fd8b46499ea3b77a8d1706bb9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 21:49:21 +0000 Subject: [PATCH 05/38] feat: rasterize Simple Nostr Objects on the CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A software rasterizer in commons, because Canvas.drawVertices is common Compose API but only hardware-accelerated from API 29 against our minSdk 26 — on Android 8 and 9 it silently draws nothing. Doing it here also buys a real depth buffer, which lattice geometry needs the moment two objects are authored to share an edge, and it makes the renderer testable headless. Implements what DECK-0003 §4 decides rather than leaving it to taste: unlit with vertex colours interpolated across a face, both sides of every triangle drawn and nothing culled by winding, a face carrying its own colour filled flatly so its seams stay exact, and no invented geometry. Positions convert from the exact lattice to floats here and nowhere earlier. 15 tests, one per rule §4 decides, plus the depth ordering that a painter's-algorithm fallback would get wrong. Checked by eye as well: all seven objects on the network render, the one named Triforce comes out as a Triforce, and the 118-vertex one is a coherent skyline from every yaw. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/commons/sno/SnoRasterizer.kt | 452 ++++++++++++++++++ .../amethyst/commons/sno/SnoRasterizerTest.kt | 245 ++++++++++ 2 files changed, 697 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt new file mode 100644 index 0000000000..8f020e84da --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt @@ -0,0 +1,452 @@ +/* + * 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 + +/** + * 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. + * + * 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, a solid object is drawn as points too, so + * it "remains visible when it is smaller on screen than a triangle" (§1.5). + */ + private const val TOO_SMALL_FOR_TRIANGLES = 3f + + private const val TRANSPARENT = 0 + + /** + * @param background an opaque ARGB fill, or 0 for a transparent buffer. + * @return `width * height` ARGB pixels, row-major. + */ + fun render( + payload: SnoPayload, + width: Int, + height: Int, + yawDegrees: Float = DEFAULT_YAW_DEGREES, + pitchDegrees: Float = DEFAULT_PITCH_DEGREES, + background: Int = TRANSPARENT, + ): IntArray { + require(width > 0 && height > 0) { "a raster needs a positive size" } + + val pixels = IntArray(width * height) { background } + if (payload.vertexCount == 0) return pixels + + val depth = FloatArray(width * height) { 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) + } + if (payload.mode == SnoMode.LINES) { + drawEdges(payload, screen, pixels, depth, width, height) + } + if (payload.mode == SnoMode.POINTS || payload.mode == SnoMode.LINES || !drawTriangles || screen.spanPixels < TOO_SMALL_FOR_TRIANGLES) { + drawPoints(payload, screen, pixels, depth, width, height) + } + 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) + } + + 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, + ) + + private fun drawFaces( + payload: SnoPayload, + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + ) { + val faceColors = payload.faceColors + 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] + // 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], + ) + } + } + + /** + * 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, + ) { + 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 (abs(area) < 1e-6f) 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 + + val az = screen.zs[ia] + val bz = screen.zs[ib] + val cz = screen.zs[ic] + val inverseArea = 1f / area + + for (py in top..bottom) { + val y = py + 0.5f + val rowBase = py * width + for (px in left..right) { + val x = px + 0.5f + var wA = ((bx - x) * (cy - y) - (by - y) * (cx - x)) * inverseArea + var wB = ((cx - x) * (ay - y) - (cy - y) * (ax - x)) * inverseArea + var wC = 1f - wA - wB + if (wA < 0f || wB < 0f || wC < 0f) { + if (wA > 0f || wB > 0f || wC > 0f) continue + // Wound the other way: the weights are all non-positive, so + // flip them rather than dropping the face. + wA = -wA + wB = -wB + wC = -wC + val sum = wA + wB + wC + if (sum <= 0f) continue + wA /= sum + wB /= sum + wC /= sum + } + + val z = wA * az + wB * bz + wC * cz + val at = rowBase + px + if (z <= depth[at]) continue + depth[at] = z + pixels[at] = blend(colorA, colorB, colorC, wA, wB, wC) + } + } + } + + /** + * 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(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, + 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), + ) + } + } + + private fun drawPoints( + payload: SnoPayload, + screen: Projection, + pixels: IntArray, + depth: FloatArray, + width: Int, + height: Int, + ) { + for (i in 0 until payload.vertexCount) { + plot(pixels, depth, width, height, screen.xs[i].roundToInt(), screen.ys[i].roundToInt(), screen.zs[i], payload.colors[i]) + } + } + + 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. */ + private fun blend( + a: Int, + b: Int, + c: Int, + wA: Float, + wB: Float, + wC: Float, + ): Int { + if (a == b && b == c) return a + val r = ((a shr 16 and 0xFF) * wA + (b shr 16 and 0xFF) * wB + (c shr 16 and 0xFF) * wC).roundToInt() + val g = ((a shr 8 and 0xFF) * wA + (b shr 8 and 0xFF) * wB + (c shr 8 and 0xFF) * wC).roundToInt() + val bl = ((a and 0xFF) * wA + (b and 0xFF) * wB + (c and 0xFF) * wC).roundToInt() + return (0xFF shl 24) or (clamp255(r) shl 16) or (clamp255(g) shl 8) or clamp255(bl) + } + + 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 +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt new file mode 100644 index 0000000000..b9ca53c1fb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt @@ -0,0 +1,245 @@ +/* + * 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()) + assertTrue(lit.all { it == 0xFFFFFFFF.toInt() }, "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) + assertTrue(pixels.litCount() <= 3, "three vertices should light at most three pixels, lit ${pixels.litCount()}") + assertTrue(pixels.litCount() >= 1) + } + + @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) + assertEquals(1, pixels.litCount()) + } + + @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)) + } +} From ce888dea74691f3ce5da9c9129a89ebd56b037f9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 22:26:50 +0000 Subject: [PATCH 06/38] feat: render Simple Nostr Objects in the feed and thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind 33331 object now draws where it appears: a card with the object, its name and its vertex and face counts, and a tap opens it where it can be turned with a drag. Quoting a nostr:naddr to one renders it inline. The thumbnail goes through Coil, keyed by event, size and view, so scrolling past the same object costs a cache hit. The viewer deliberately does not: a drag makes a new angle every frame, and at 360dp each would be a half-megabyte cache entry that evicts everything else within a second of dragging. It rasterises straight to a bitmap instead, through a new PlatformImage.toComposeImageBitmap() beside the existing Coil bridge. A payload that fails §1.9 is not drawn, which the deck requires, and the card shows the rule it broke rather than failing silently, which is what §3.1 asks for and the whole reason the parser hands a rule back. LocalCache stores 33331 addressably, without which the addressable cache never holds the object and a quoted naddr stays empty. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/model/LocalCache.kt | 2 + .../service/images/ImageLoaderSetup.kt | 3 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/SnoObject.kt | 78 +++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../service/image/CoilImageBridge.android.kt | 4 + .../composeResources/values/strings.xml | 4 + .../commons/service/image/CoilImageBridge.kt | 7 + .../amethyst/commons/sno/SnoFetcher.kt | 99 ++++++++++++++ .../commons/sno/ui/SnoObjectViewer.kt | 122 ++++++++++++++++++ .../amethyst/commons/sno/ui/SnoThumbnail.kt | 79 ++++++++++++ .../amethyst/commons/ui/note/SnoObjectCard.kt | 108 ++++++++++++++++ .../service/image/CoilImageBridge.ios.kt | 4 + .../service/image/CoilImageBridge.jvm.kt | 4 + .../service/images/DesktopImageLoaderSetup.kt | 3 + 15 files changed, 527 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e20e10b7ae..9a4762c162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -145,6 +145,7 @@ 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.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -3804,6 +3805,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is GeocacheListingEvent, is GeocacheCurationListEvent, is GeohashListEvent, + is SnoObjectEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, is UserGraspListEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 96cf0f2ff9..814b614f4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -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.images.BlossomFetcher import com.vitorpamplona.amethyst.service.images.ProfilePictureFetcher @@ -108,11 +109,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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index f270a77af2..605d4f3ab1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -213,6 +213,7 @@ 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.RenderSnoObject import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease @@ -257,6 +258,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -1367,6 +1369,10 @@ private fun RenderNoteRow( ) } + is SnoObjectEvent -> { + RenderSnoObject(baseNote) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt new file mode 100644 index 0000000000..a7d691a1ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt @@ -0,0 +1,78 @@ +/* + * 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.quartz.cyberspace.deck0003Sno.SnoObjectEvent +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 card is drawn from what + * the note already carries: no fetch, no decode of an external asset, and no + * network access at all. 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) { + val noteEvent = baseNote.event as? SnoObjectEvent ?: return + val parsed = remember(noteEvent) { noteEvent.sno() } + + 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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index d9e71d0499..fc01413d31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -225,6 +225,7 @@ 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.RenderSnoObject import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease @@ -264,6 +265,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -1064,6 +1066,8 @@ private fun FullBleedNoteCompose( RenderBirdex(baseNote) } else if (noteEvent is BirdDetectionEvent) { RenderBirdDetection(baseNote) + } else if (noteEvent is SnoObjectEvent) { + RenderSnoObject(baseNote) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt index 672cfdea8b..42fc0ad49c 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.android.kt @@ -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() diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index ab19800ef0..039b93686c 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3080,4 +3080,8 @@ Publish persona Follows you Bot + 3D object + %1$d vertices · %2$d faces + This 3D object could not be read (rule %1$s) + Drag to turn diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt index 8b6d70440f..45979589d1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt new file mode 100644 index 0000000000..c4ebb48e2c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt @@ -0,0 +1,99 @@ +/* + * 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 and the size. The payload itself + * is not part of the key because an addressable object republished under the + * same `d` arrives as a new event id. + */ +@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" +} + +/** + * 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 { + override fun create( + data: SnoObjectToRender, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = SnoFetcher(data) + } + + object SKeyer : Keyer { + override fun key( + data: SnoObjectToRender, + options: Options, + ): String = data.cacheKey + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt new file mode 100644 index 0000000000..679fe9a4ad --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -0,0 +1,122 @@ +/* + * 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.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.FilterQuality +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap +import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload + +/** How many degrees a 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 + +/** + * A Simple Nostr Object the reader can turn. + * + * Unlike [SnoThumbnail] this draws straight to a bitmap instead of going + * through Coil, because a drag produces a new angle every frame and each one + * would otherwise become its own cache entry: at 360dp that is around half a + * megabyte a frame, which would evict the whole image cache in a second of + * dragging. Nothing here is worth caching — the object is the event, and + * re-rasterising it is cheaper than remembering it. + * + * The raster runs on the composition's thread. At the format's ceiling that is + * 512 vertices and 1024 triangles into a few hundred pixels square, which is + * well inside a frame; the limits of §1.8 are what make that safe to assume. + */ +@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) } + val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb() + + BoxWithConstraints( + modifier = + modifier.pointerInput(eventId) { + detectDragGestures { change, dragAmount -> + change.consume() + yaw = wrapDegrees(yaw + dragAmount.x * DEGREES_PER_PIXEL) + pitch = (pitch - dragAmount.y * DEGREES_PER_PIXEL).coerceIn(-MAX_PITCH, MAX_PITCH) + } + }, + contentAlignment = Alignment.Center, + ) { + val pixelSize = with(LocalDensity.current) { minOf(maxWidth, maxHeight).roundToPx() } + if (pixelSize <= 0) return@BoxWithConstraints + + val bitmap = + remember(payload, pixelSize, yaw, pitch, backgroundArgb) { + val pixels = + SnoRasterizer.render( + payload = payload, + width = pixelSize, + height = pixelSize, + yawDegrees = yaw, + pitchDegrees = pitch, + background = backgroundArgb, + ) + PlatformImage.create(pixels, pixelSize, pixelSize).toComposeImageBitmap() + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + filterQuality = FilterQuality.None, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +private fun wrapDegrees(value: Float): Float { + var wrapped = value % 360f + if (wrapped < 0f) wrapped += 360f + return wrapped +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt new file mode 100644 index 0000000000..68ce3aacec --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoThumbnail.kt @@ -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), + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt new file mode 100644 index 0000000000..7d468170e1 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt @@ -0,0 +1,108 @@ +/* + * 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_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.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 object 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. + */ +@Composable +fun SnoObjectCard( + payload: SnoPayload, + eventId: 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 = THUMBNAIL_SIZE, + contentDescription = payload.name.ifBlank { null }, + ) + + Spacer(Modifier.width(12.dp)) + + Column { + 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, + ) + } + } +} + +/** + * 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, + ) + } +} diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt index de4e23f972..d23c4331cc 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.ios.kt @@ -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() diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt index 0756390bef..097506fb80 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/image/CoilImageBridge.jvm.kt @@ -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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index 31af30e2d2..43792cfb50 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -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() From e792a2228b601fb265a50210d94ff13ffd348228 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 22:27:31 +0000 Subject: [PATCH 07/38] test: pin the raster-to-Compose image bridge Compiling proves the expect/actual lines up; this proves the pixels survive the trip through PlatformImage on the JVM actual. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../commons/sno/SnoComposeImageBridgeTest.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt new file mode 100644 index 0000000000..34b6225ef3 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoComposeImageBridgeTest.kt @@ -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") + } +} From e18d176481d2a093a817525b7bb01f6cc37c0b56 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 22:49:06 +0000 Subject: [PATCH 08/38] feat: draw cyberspace avatars, and only the ones that paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind 11333 carries the same SNO payload in a replaceable container, so it now renders like any other object — except that §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. An avatar is the one thing in cyberspace that lands on other people's screens whether they asked for it or not. SnoAvatarEvent extends BaseReplaceableEvent rather than plain Event, which is both what §8.10 means by moving the kind out of the addressable range and what LocalCache needs to key it. The payment check keeps both of §8.10's conditions separate, and the test pins the case a shortcut gets wrong: an avatar owing 16 bits, committed to 30 and mined to 20 reads as 20 through Event.pow(), which clears the 16 it owes while plainly falling short of the 30 its publisher committed to. Not wired: replacing a user's NIP-01 profile picture with their cyberspace avatar across the app. That is a product decision about every feed row, not plumbing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/model/LocalCache.kt | 2 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/SnoAvatar.kt | 81 +++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../composeResources/values/strings.xml | 2 + .../amethyst/commons/ui/note/SnoAvatarCard.kt | 110 +++++++++++++++ .../cyberspace/deck0003Sno/SnoAvatarEvent.kt | 8 +- .../deck0003Sno/SnoAvatarPaidTest.kt | 126 ++++++++++++++++++ 8 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 9a4762c162..a275cc1647 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -145,6 +145,7 @@ 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.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -3806,6 +3807,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is GeocacheCurationListEvent, is GeohashListEvent, is SnoObjectEvent, + is SnoAvatarEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, is UserGraspListEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 605d4f3ab1..6bb004b1c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -213,6 +213,7 @@ 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.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -258,6 +259,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -1373,6 +1375,10 @@ private fun RenderNoteRow( RenderSnoObject(baseNote) } + is SnoAvatarEvent -> { + RenderSnoAvatar(baseNote) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt new file mode 100644 index 0000000000..10e65f43a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt @@ -0,0 +1,81 @@ +/* + * 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.SnoAvatarUnpaidCard +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent +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 and owes no work, so there is + * nothing to show for it either. + */ +@Composable +fun RenderSnoAvatar(baseNote: Note) { + val noteEvent = baseNote.event as? SnoAvatarEvent ?: return + if (noteEvent.isDefaultAvatar()) return + + val shape: SnoPayload? = remember(noteEvent) { if (noteEvent.isPaid()) noteEvent.sno().payloadOrNull() else null } + + if (shape == null) { + SnoAvatarUnpaidCard() + return + } + + 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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index fc01413d31..ddf60814e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -225,6 +225,7 @@ 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.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -265,6 +266,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -1068,6 +1070,8 @@ private fun FullBleedNoteCompose( RenderBirdDetection(baseNote) } else if (noteEvent is SnoObjectEvent) { RenderSnoObject(baseNote) + } else if (noteEvent is SnoAvatarEvent) { + RenderSnoAvatar(baseNote) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 039b93686c..2f6edc531c 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3084,4 +3084,6 @@ %1$d vertices · %2$d faces This 3D object could not be read (rule %1$s) Drag to turn + Cyberspace avatar + This avatar has not paid for its size, so it is not drawn diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt new file mode 100644 index 0000000000..d5054b3199 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt @@ -0,0 +1,110 @@ +/* + * 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_title +import com.vitorpamplona.amethyst.commons.resources.sno_avatar_unpaid +import com.vitorpamplona.amethyst.commons.resources.sno_object_details +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.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 { + 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, + ) + } + } +} + +/** + * 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, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt index cb97c255c3..8e8c6ad188 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.cyberspace.deck0003Sno import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag @@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag * default avatar. * * Replaceable, so relays keep the newest per `(pubkey, kind)` and an identity - * has exactly one avatar. This kind **was** 33331 with a `d` fixed at + * has exactly one avatar — which is why this extends [BaseReplaceableEvent] + * rather than plain `Event`, giving it the fixed-empty-`d` address the local + * store keys replaceables on. This kind **was** 33331 with a `d` fixed at * `"avatar"` before the spec moved it here, and 33331 was then handed to the * standalone objects of DECK-0003 §3.1 — so an old 33331 whose `d` is literally * `avatar` is not a surprise, merely stale. @@ -51,7 +53,7 @@ class SnoAvatarEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { /** True when this identity asked for the default avatar, which owes no work. */ fun isDefaultAvatar(): Boolean = content.isBlank() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt new file mode 100644 index 0000000000..b9b0031b99 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt @@ -0,0 +1,126 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The drawing gate of `CYBERSPACE_V2.md` §8.10: "a client MUST NOT draw an + * avatar event that is not paid, or that carries content it cannot read". + * + * Both conditions are required — the committed target must cover the work the + * payload owes, AND the id must carry that many leading zero bits — and the + * interesting case is the one where only the second fails. + */ +class SnoAvatarPaidTest { + /** A shape that owes exactly the 16-bit floor. */ + private val floorShape = + """{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}""" + + private fun idWithRank(bits: Int): String { + val zeros = bits / 4 + val remainder = bits % 4 + val stopper = + when (remainder) { + 0 -> "f" + 1 -> "4" + 2 -> "2" + else -> "1" + } + val head = "0".repeat(zeros) + stopper + return head + "f".repeat(64 - head.length) + } + + private fun avatar( + content: String, + committed: Int?, + idBits: Int, + ) = SnoAvatarEvent( + idWithRank(idBits), + "11".repeat(32), + 0L, + committed?.let { arrayOf(arrayOf("nonce", "1", it.toString())) } ?: emptyArray(), + content, + "22".repeat(64), + ) + + @Test + fun theIdHelperProducesTheRankItClaims() { + listOf(12, 16, 20, 30).forEach { + assertEquals(it, PoWRankEvaluator.calculatePowRankOf(idWithRank(it)), "rank for $it bits") + } + } + + @Test + fun theFloorShapeOwesSixteenBits() { + assertEquals(16, SnoAvatarWork.required(SnoParser.parse(floorShape).payloadOrNull()!!)) + } + + @Test + fun anAvatarMinedToItsCommitmentIsPaid() { + assertTrue(avatar(floorShape, committed = 16, idBits = 16).isPaid()) + assertTrue(avatar(floorShape, committed = 16, idBits = 24).isPaid(), "over-mining is fine") + } + + @Test + fun emptyContentIsTheDefaultAvatarAndOwesNothing() { + val default = avatar("", committed = null, idBits = 0) + assertTrue(default.isDefaultAvatar()) + assertTrue(default.isPaid()) + } + + @Test + fun anAvatarWithoutANonceTagIsNotPaid() { + // §8.10 requires the tag: committing the target before mining is what + // stops a lucky id being claimed against a lower bar than it was mined + // for, so an uncommitted avatar has not paid however long its id is. + assertFalse(avatar(floorShape, committed = null, idBits = 32).isPaid()) + } + + @Test + fun anAvatarCommittingLessThanItOwesIsNotPaid() { + assertFalse(avatar(floorShape, committed = 8, idBits = 32).isPaid()) + } + + @Test + fun anIdShortOfItsOwnCommitmentIsNotPaid() { + // The case a naive check gets wrong. Event.pow() is + // PoWRankEvaluator.compute(id, committed), which returns + // min(actualRank, committed) — here min(20, 30) = 20, which clears the + // 16 bits the shape owes while the id plainly falls short of the 30 the + // publisher committed to. + val event = avatar(floorShape, committed = 30, idBits = 20) + + assertEquals(20, PoWRankEvaluator.compute(event.id, 30), "the shortcut would say 20...") + assertTrue(20 >= SnoAvatarWork.required(event.sno().payloadOrNull()!!), "...which clears the required 16...") + assertFalse(event.isPaid(), "...but §8.10 needs the id to carry the committed 30") + } + + @Test + fun anAvatarWhoseContentCannotBeReadIsNotPaid() { + assertFalse(avatar("not json at all", committed = 32, idBits = 32).isPaid()) + assertFalse(avatar("""{"v":9}""", committed = 32, idBits = 32).isPaid()) + } +} From b6b7ac8af6ecb391ee6e03143c55010ef90a42cd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 21 Sep 2026 22:49:31 +0000 Subject: [PATCH 09/38] docs: record what landed for DECK-0003 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 36 ++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index ee3b40aa87..f51e006d47 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -1,6 +1,8 @@ # DECK-0003 (SNO — Simple Nostr Objects) — feasibility and implementation plan -_Status: investigation. Nothing built yet._ +_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: (Draft, last updated 2026-09-16, requires `CYBERSPACE_V2.md` `2026-03-16-h34-corrected`). @@ -448,7 +450,37 @@ size that suits its context rather than refusing it." 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. Suggested sequencing +## 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`, 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. + +**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 From 94bc8864d681bc88883a34032f411a640a2e7dc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 00:09:34 +0000 Subject: [PATCH 10/38] feat: render shards, the objects hidden at a place (kind 3330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DECK-0003 §3.2 is two containers for one format, not two formats, so a shard reads through the same parser and draws through the same card as a standalone object. SnoShardEvent adds the container and the C-tag coordinate claim and delegates every rule to SnoParser. What is not implemented is opening a bag. A shard published today is an item inside a kind 33330 bag whose payload is AES-256-GCM ciphertext keyed to a cyberspace region, so reading one means §2 coordinates, §7.2 key derivation and §7.4 discovery scanning — the protocol, not a renderer. §7.6 is explicit that a failed decryption is not an error in the bag. A reader for the pre-deck tag form was written and then removed. Every 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. Rendering the corpus showed what the decoder bought — one black triangle and one black dot, two shapes across 21 events, every colour 0,0,0 — against the cyberspace readme saying v1 drafts "are not a valid basis for new implementations". Reverse- engineering a deprecated encoding no current spec describes was not worth that, so it went. The consequence, stated rather than discovered later: no publicly reachable 3330 renders today. The current ones are sealed in bags and the old ones are v1. Note the two unrelated version numbers. The payload's own `v: 1` (DECK-0003 §2) stays supported because the deck requires it — "a reader MUST support both" — and is the SNO format's version, not Cyberspace v1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 32 ++++++-- .../amethyst/ui/note/NoteCompose.kt | 6 ++ .../amethyst/ui/note/types/SnoShard.kt | 80 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../commons/model/cache/EventCache.kt | 2 + .../cyberspace/deck0003Sno/SnoShardEvent.kt | 81 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../deck0003Sno/SnoShardEventTest.kt | 74 +++++++++++++++++ 8 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index f51e006d47..b7411bee74 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -467,7 +467,7 @@ Built in this order, each step verified before the next: 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`, dispatched from +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. @@ -514,11 +514,31 @@ are where the risk is, and they are testable without a device. 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.** Recommend **defer**, on `CYBERSPACE_V2.md` §7.6 - rather than on the DECK: a current shard is an item inside an AES-256-GCM bag keyed - to a Cyberspace region, so reading one means implementing §2 coordinates, §7.2 key - derivation and §7.4 discovery. That is the protocol, not a renderer. The two 3330s - sitting on relays are v1-era leftovers. +- **D2 — `kind 3330` bag items. Settled: the container ships, the bag does not.** + `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. What is *not* + implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext + keyed to a Cyberspace region, so reading one means §2 coordinates, §7.2 key + derivation and §7.4 discovery scanning. That is the protocol, not a renderer, and + §7.6 is explicit that 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d56050f2de..9398a8c332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -215,6 +215,7 @@ 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 @@ -261,6 +262,7 @@ import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event 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 @@ -1379,6 +1381,10 @@ private fun RenderNoteRow( RenderSnoAvatar(baseNote) } + is SnoShardEvent -> { + RenderSnoShard(baseNote) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt new file mode 100644 index 0000000000..5dcfb459de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt @@ -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.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.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent + +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. What it does not + * do is open a bag: a shard still sealed in its `kind 33330` bag is ciphertext + * until its region key is derived, which is the cyberspace protocol rather than + * a renderer. + */ +@Composable +fun RenderSnoShard(baseNote: Note) { + val noteEvent = baseNote.event as? SnoShardEvent ?: return + val parsed = remember(noteEvent) { noteEvent.shard() } + + 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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 81c090eec2..2d0cec64e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -227,6 +227,7 @@ 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 @@ -268,6 +269,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote 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 @@ -1072,6 +1074,8 @@ private fun FullBleedNoteCompose( RenderSnoObject(baseNote) } else if (noteEvent is SnoAvatarEvent) { RenderSnoAvatar(baseNote) + } else if (noteEvent is SnoShardEvent) { + RenderSnoShard(baseNote) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index e0162f7194..dd4e4cc9c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -140,6 +140,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent 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 @@ -3958,6 +3959,7 @@ open class EventCache : is GitPullRequestEvent, is GitPullRequestUpdateEvent, is GitStatusEvent, + is SnoShardEvent, is ChessGameEvent, is JesterEvent, is HighlightEvent, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt new file mode 100644 index 0000000000..5540f9ed0c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * DECK-0003 §3.2 — an object hidden at a place: a `kind 3330` bag item, whose + * `content` is the payload of §1 exactly as a `kind 33331` object carries it. + * + * Regular, and deliberately so. An item in a bag is a thing someone hid at a + * place and someone else found there; it must be exactly what it was when it + * was found, and its id must keep meaning what it meant. The same payload + * therefore travels under two kinds according to what is being done with it: + * 33331 for an object its author is still working on, 3330 for one that has + * been put somewhere. That is two containers for one format, not two ways of + * writing the format — which is why this class adds a container and a + * coordinate and delegates every rule to [SnoParser]. + * + * **Most shards are not reachable, and that is by design.** A shard is an item + * inside a `kind 33330` bag (`CYBERSPACE_V2.md` §7.6) whose payload is + * AES-256-GCM ciphertext keyed to the region it was hidden in, so finding one + * means deriving that region's key: the coordinate system of §2, the key + * derivation of §7.2 and the discovery scanning of §7.4. Amethyst implements + * none of that and a reader without the key sees base64 and nothing else — §7.6 + * is explicit that a failed decryption "MUST NOT be treated as an error in the + * bag". What this class reads is a shard handed over directly: quoted in a + * note, or fetched by id. + * + * **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its + * `pubkey` is a claim and a client MUST NOT present it as verified authorship. + * Placement is attributable to the bag's author; authorship of an item's + * content only to the item's own pubkey, and only when the item is signed. + */ +@Immutable +class SnoShardEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun shard(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) + + fun shardOrNull(fetchedPalette: SnoPalette? = null): SnoPayload? = shard(fetchedPalette).payloadOrNull() + + /** + * The exact cyberspace coordinate this shard claims, if it carries one. + * + * §7.6: an item MAY carry a `C` tag, which lets a client render it at a + * point rather than somewhere in the region. Where a shard came out of a + * bag that coordinate MUST lie inside the region the bag is encrypted to; + * this class has no bag to check it against, so it only reports the claim. + */ + fun coordinate(): String? = tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1) + + companion object { + const val KIND = 3330 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 1df8103880..87bb758e94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent 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 @@ -651,6 +652,7 @@ class EventFactory { GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) SnoObjectEvent.KIND -> SnoObjectEvent(id, pubKey, createdAt, tags, content, sig) SnoAvatarEvent.KIND -> SnoAvatarEvent(id, pubKey, createdAt, tags, content, sig) + SnoShardEvent.KIND -> SnoShardEvent(id, pubKey, createdAt, tags, content, sig) GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig) GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig) GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt new file mode 100644 index 0000000000..1df9e39cdf --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt @@ -0,0 +1,74 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A `kind 3330` shard reads in either encoding, and the rules of §1 still apply + * to the one the deck specifies. + */ +class SnoShardEventTest { + private fun shard( + content: String = "", + tags: Array> = emptyArray(), + ) = SnoShardEvent("aa".repeat(32), "11".repeat(32), 0L, tags, content, "22".repeat(64)) + + private val payloadJson = + """{"v":2,"name":"hidden","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}""" + + @Test + fun aShardCarriesThePayloadInContent() { + val payload = shard(content = payloadJson).shardOrNull() + assertNotNull(payload) + assertEquals("hidden", payload.name) + assertEquals(2, payload.version) + assertEquals(4, payload.vertexCount) + assertEquals(1, payload.faceCount) + assertEquals(0xFFFF0000.toInt(), payload.colors[0]) + } + + @Test + fun aShardIsHeldToTheSameRulesAsAnObject() { + // The container changed; §1.9 did not. This is one format in two + // containers, not two ways of writing the format. + val result = shard(content = """{"v":2,"name":"x","unit":0,"mode":"wireframe","vertices":[],"colors":[],"faces":[]}""").shard() + assertTrue(result is SnoResult.Invalid) + assertEquals("4", result.rule) + } + + @Test + fun anEmptyShardIsNotAShape() { + assertTrue(shard().shard() is SnoResult.Invalid) + } + + @Test + fun theCoordinateIsReadButIsOnlyAClaim() { + val coordinate = "3d5ffa91f5c8c13d0bc82ecfe2e546020b3d51763333589829c9c3fdc24fe74a" + val withC = shard(content = payloadJson, tags = arrayOf(arrayOf("C", coordinate))) + assertEquals(coordinate, withC.coordinate()) + assertNull(shard(content = payloadJson).coordinate()) + } +} From 0cc0e8c8b8809a26623065f6c7db302f5d8e18af Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 02:10:17 +0000 Subject: [PATCH 11/38] test: pin avatar work to the reference implementation's golden vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8.10 says its two reference implementations are "pinned to one set of golden vectors". We had not checked against them — the ladder was computed from the section's pseudocode, which is the same text both implementations read, so agreement proved nothing about agreement with them. These are that set, from tests/fixtures/avatar_work.json in cyberspace-cli (MIT). All eight pass. The one that earns its place 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 still passes every other vector. "detailed but small" indexes faces past the end of its vertex list, since the formula counts faces and never looks at them. §1.9 refuses that, so the face count is kept and the indices substituted — the same input as far as the formula is concerned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../SnoAvatarWorkGoldenVectorsTest.kt | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt new file mode 100644 index 0000000000..3fbd5f7678 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWorkGoldenVectorsTest.kt @@ -0,0 +1,194 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The avatar-work golden vectors from the reference implementation. + * + * `CYBERSPACE_V2.md` §8.10 says its two reference implementations, `avatar.ts` + * in cyberspace-core and `cyberspace_core/avatar.py` in cyberspace-cli, are + * "pinned to one set of golden vectors". This is that set, read out of + * `tests/fixtures/avatar_work.json` in cyberspace-cli (MIT), so our ladder is + * checked against the implementation the spec points at and not only against + * the pseudocode we both read. + * + * Two of the fixtures need a word: + * - **"half gibson, ticks"** is the case a careless reader gets wrong. Its + * vertex is `[-1, -1, -1]` with ticks `[60, 60, 60]`, which is -0.5 of a unit, + * not -1.5: the whole part is the floor and the ticks count up from it, so the + * ticks must be added to the signed whole before the magnitude is taken. + * - **"detailed but small"** indexes faces past the end of its vertex list, + * because the work formula only counts faces and never looks at them. §1.9 + * would refuse that payload, so this keeps the face count and substitutes + * indices the parser accepts, which is the same input as far as the formula + * is concerned. + */ +class SnoAvatarWorkGoldenVectorsTest { + private data class Vector( + val name: String, + val reach: Double, + val required: Int, + val substitutedFaces: Boolean, + val json: String, + ) + + private val vectors = + listOf( + Vector( + name = "one gibson, plain", + reach = 1.0, + required = 16, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"one gibson, plain\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[0,0,0],[1,0,0],[0,1,0],[0,0,1]],\"colors\":[225,225,225" + + ",225],\"faces\":[[0,1,2],[0,2,3]]}", + ), + Vector( + name = "half gibson, ticks", + reach = 0.5, + required = 16, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"half gibson, ticks\",\"unit\":0,\"mode\":\"points\",\"vertices\":[[0,0,0],[-1,-1,-1]],\"colors\":[225,225],\"faces\":[]," + + "\"ticks\":[[60,60,60],[60,60,60]]}", + ), + Vector( + name = "two gibsons", + reach = 2.0, + required = 18, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"two gibsons\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-2,-2,-2],[-2,-2,2],[-2,2,-2],[-2,2,2],[2,-2,-2],[2,-2,2],[2," + + "2,-2],[2,2,2]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6],[3,7,6],[0,2," + + "4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "four gibsons", + reach = 4.0, + required = 20, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"four gibsons\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-4,-4,-4],[-4,-4,4],[-4,4,-4],[-4,4,4],[4,-4,-4],[4,-4,4],[4" + + ",4,-4],[4,4,4]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6],[3,7,6],[0,2" + + ",4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "sixteen gibsons via unit", + reach = 16.0, + required = 24, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"sixteen gibsons via unit\",\"unit\":2,\"mode\":\"solid\",\"vertices\":[[-4,-4,-4],[-4,-4,4],[-4,4,-4],[-4,4,4],[4,-4,-4]" + + ",[4,-4,4],[4,4,-4],[4,4,4]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4,5],[2,3,6]," + + "[3,7,6],[0,2,4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "a thousand and twenty-four gibsons", + reach = 1024.0, + required = 36, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"a thousand and twenty-four gibsons\",\"unit\":10,\"mode\":\"solid\",\"vertices\":[[-1,-1,-1],[-1,-1,1],[-1,1,-1],[-1,1,1" + + "],[1,-1,-1],[1,-1,1],[1,1,-1],[1,1,1]],\"colors\":[225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,3,2],[4,6,5],[5,6,7],[0,4,1],[1,4," + + "5],[2,3,6],[3,7,6],[0,2,4],[2,6,4],[1,5,3],[3,5,7]]}", + ), + Vector( + name = "detailed but small", + reach = 1.0, + required = 28, + substitutedFaces = true, + json = + "{\"v\":2,\"name\":\"detailed but small\",\"unit\":0,\"mode\":\"solid\",\"vertices\":[[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-" + + "1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0" + + ",0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-" + + "1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1," + + "0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1]" + + ",[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1]," + + "[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1]," + + "[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1" + + ",-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-" + + "1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0],[-1,1,0],[0,1," + + "0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0],[0,0,0],[1,0,0" + + "],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[-1,0,0" + + "],[0,0,0],[1,0,0],[-1,1,0],[0,1,0],[1,1,0],[-1,-1,1],[0,-1,1],[1,-1,1],[-1,0,1],[0,0,1],[1,0,1],[-1,1,1],[0,1,1],[1,1,1],[-1,-1,0],[0,-1,0]]" + + ",\"colors\":[225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225" + + ",225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225,225],\"faces\":[[0,1,2],[1,2,3]" + + ",[2,3,4],[3,4,5],[4,5,6],[5,6,7],[6,7,8],[7,8,9],[8,9,10],[9,10,11],[10,11,12],[11,12,13],[12,13,14],[13,14,15],[14,15,16],[15,16,17],[16,17" + + ",18],[17,18,19],[18,19,20],[19,20,21],[20,21,22],[21,22,23],[22,23,24],[23,24,25],[24,25,26],[25,26,27],[26,27,28],[27,28,29],[28,29,30],[29" + + ",30,31],[30,31,32],[31,32,33],[32,33,34],[33,34,35],[34,35,36],[35,36,37],[36,37,38],[37,38,39],[38,39,40],[39,40,41],[40,41,42],[41,42,43]," + + "[42,43,44],[43,44,45],[44,45,46],[45,46,47],[46,47,48],[47,48,49],[48,49,50],[49,50,51],[50,51,52],[51,52,53],[52,53,54],[53,54,55],[54,55,5" + + "6],[55,56,57],[56,57,58],[57,58,59],[58,59,60],[59,60,61],[60,61,62],[61,62,63],[62,63,64],[63,64,65],[64,65,66],[65,66,67],[66,67,68],[67,6" + + "8,69],[68,69,70],[69,70,71],[70,71,72],[71,72,73],[72,73,74],[73,74,75],[74,75,76],[75,76,77],[76,77,78],[77,78,79],[78,79,80],[79,80,81],[8" + + "0,81,82],[81,82,83],[82,83,84],[83,84,85],[84,85,86],[85,86,87],[86,87,88],[87,88,89],[88,89,90],[89,90,91],[90,91,92],[91,92,93],[92,93,94]" + + ",[93,94,95],[94,95,96],[95,96,97],[96,97,98],[97,98,99],[98,99,100],[99,100,101],[100,101,102],[101,102,103],[102,103,104],[103,104,105],[10" + + "4,105,106],[105,106,107],[106,107,108],[107,108,109],[108,109,110],[109,110,111],[110,111,112],[111,112,113],[112,113,114],[113,114,115],[11" + + "4,115,116],[115,116,117],[116,117,118],[117,118,119],[118,119,120],[119,120,121],[120,121,122],[121,122,123],[122,123,124],[123,124,125],[12" + + "4,125,126],[125,126,127],[126,127,128],[127,128,129],[128,129,130],[129,130,131],[130,131,132],[131,132,133],[132,133,134],[133,134,135],[13" + + "4,135,136],[135,136,137],[136,137,138],[137,138,139],[138,139,140],[139,140,141],[140,141,142],[141,142,143],[142,143,144],[143,144,145],[14" + + "4,145,146],[145,146,147],[146,147,148],[147,148,149],[148,149,150],[149,150,151],[150,151,152],[151,152,153],[152,153,154],[153,154,155],[15" + + "4,155,156],[155,156,157],[156,157,158],[157,158,159],[158,159,160],[159,160,161],[160,161,162],[161,162,163],[162,163,164],[163,164,165],[16" + + "4,165,166],[165,166,167],[166,167,168],[167,168,169],[168,169,170],[169,170,171],[170,171,172],[171,172,173],[172,173,174],[173,174,175],[17" + + "4,175,176],[175,176,177],[176,177,178],[177,178,179],[178,179,180],[179,180,181],[180,181,182],[181,182,183],[182,183,184],[183,184,185],[18" + + "4,185,186],[185,186,187],[186,187,188],[187,188,189],[188,189,190],[189,190,191],[190,191,192],[191,192,193],[192,193,194],[193,194,195],[19" + + "4,195,196],[195,196,197],[196,197,198],[197,198,199],[198,199,0],[199,0,1],[0,1,2],[1,2,3],[2,3,4],[3,4,5],[4,5,6],[5,6,7],[6,7,8],[7,8,9],[" + + "8,9,10],[9,10,11],[10,11,12],[11,12,13],[12,13,14],[13,14,15],[14,15,16],[15,16,17],[16,17,18],[17,18,19],[18,19,20],[19,20,21],[20,21,22],[" + + "21,22,23],[22,23,24],[23,24,25],[24,25,26],[25,26,27],[26,27,28],[27,28,29],[28,29,30],[29,30,31],[30,31,32],[31,32,33],[32,33,34],[33,34,35" + + "],[34,35,36],[35,36,37],[36,37,38],[37,38,39],[38,39,40],[39,40,41],[40,41,42],[41,42,43],[42,43,44],[43,44,45],[44,45,46],[45,46,47],[46,47" + + ",48],[47,48,49],[48,49,50],[49,50,51],[50,51,52],[51,52,53],[52,53,54],[53,54,55],[54,55,56],[55,56,57],[56,57,58],[57,58,59],[58,59,60],[59" + + ",60,61],[60,61,62],[61,62,63],[62,63,64],[63,64,65],[64,65,66],[65,66,67],[66,67,68],[67,68,69],[68,69,70],[69,70,71],[70,71,72],[71,72,73]," + + "[72,73,74],[73,74,75],[74,75,76],[75,76,77],[76,77,78],[77,78,79],[78,79,80],[79,80,81],[80,81,82],[81,82,83],[82,83,84],[83,84,85],[84,85,8" + + "6],[85,86,87],[86,87,88],[87,88,89],[88,89,90],[89,90,91],[90,91,92],[91,92,93],[92,93,94],[93,94,95],[94,95,96],[95,96,97],[96,97,98],[97,9" + + "8,99],[98,99,100],[99,100,101],[100,101,102],[101,102,103],[102,103,104],[103,104,105],[104,105,106],[105,106,107],[106,107,108],[107,108,10" + + "9],[108,109,110],[109,110,111],[110,111,112],[111,112,113]]}", + ), + Vector( + name = "packed zero ticks", + reach = 3.0, + required = 20, + substitutedFaces = false, + json = + "{\"v\":2,\"name\":\"packed zero ticks\",\"unit\":0,\"mode\":\"points\",\"vertices\":[[3,0,0],[0,0,0]],\"colors\":[225,225],\"faces\":[],\"ti" + + "cks\":[-2]}", + ), + ) + + @Test + fun ourLadderMatchesTheReferenceImplementation() { + vectors.forEach { vector -> + val payload = SnoParser.parse(vector.json).payloadOrNull() + assertNotNull(payload, "${vector.name} did not parse") + assertEquals(vector.required, SnoAvatarWork.required(payload), "${vector.name} (reach ${vector.reach})") + } + } + + @Test + fun theWholeSetIsCovered() { + assertEquals(8, vectors.size) + } +} From fdb6630ea29867d7d30bb4ebef979e19368be2a1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 02:11:32 +0000 Subject: [PATCH 12/38] docs: record the reference-implementation comparison All eight avatar golden vectors pass. The Python reference CLI is a week stale on the avatar kind (33331, where the spec and the two current implementations say 11333) and we follow the spec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index b7411bee74..42580406d2 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -239,6 +239,49 @@ mode), `orient` (winding faces outward so a lit bench can paint backfaces dark), 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 From 288ed01105eb7944b4aac1329292697d46448421 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 02:41:01 +0000 Subject: [PATCH 13/38] feat(cli): amy sno, and a conformance harness against the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `amy sno parse|work|verify` — validate a DECK-0003 payload against §1.9, price the proof of work an avatar owes for it, and check whether a kind:11333 avatar has paid. Local, accountless, thin over quartz: the verbs exist so the reader can be diffed against the implementations the spec points at, without a device or a relay. cli/tests/sno/ is that diff. The reference's own fixtures drive it — _rejections() out of decks/sno-reference.py and the golden vectors out of cyberspace-cli — so the cases are theirs, not ours. 26 §1.9 verdicts agree on both the answer and the rule number; all 8 avatar vectors agree. The four places the two differ are asserted as divergences rather than skipped, because a reader that quietly stopped diverging would be as interesting as one that started: - we read the v:2-with-literal-triples cohort, the arbiter refuses it (§5 permits the generosity; three of seven live objects need it); - we refuse a vertex past 64 units, the arbiter accepts it (§8's own open question, and whole*120 overflows an Int); - the Python reference still calls 33331 the avatar kind, a week behind §8.10's move to 11333, so it reads a conformant avatar as not-an-avatar and would price a standalone object as one; - empty content is §8.10's default avatar to us and not-an-avatar to it. Same outcome — the client draws its default — different word. SnoAvatarEvent gains payment(), which reports required/committed/zeros and why, mirroring verify_avatar_work's shape so the two are directly comparable; isPaid() is now that result's ok. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- cli/README.md | 1 + cli/ROADMAP.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 3 + .../amethyst/cli/commands/SnoCommands.kt | 181 +++++++++++ cli/tests/README.md | 11 + cli/tests/sno/.gitignore | 1 + cli/tests/sno/refdriver.py | 106 +++++++ cli/tests/sno/sno-conformance.sh | 284 ++++++++++++++++++ .../cyberspace/deck0003Sno/SnoAvatarEvent.kt | 42 ++- .../deck0003Sno/SnoAvatarPayment.kt | 72 +++++ 10 files changed, 691 insertions(+), 11 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt create mode 100644 cli/tests/sno/.gitignore create mode 100755 cli/tests/sno/refdriver.py create mode 100755 cli/tests/sno/sno-conformance.sh create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt diff --git a/cli/README.md b/cli/README.md index 8556c5cf3d..d031074438 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 ` | 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. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 74645af784..09b80c2072 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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 | 🆕 | | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 3eec899f21..5a82324fea 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -70,6 +70,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 @@ -242,6 +243,7 @@ private suspend fun dispatch(argv: Array): Int { "filter" -> return FilterCommand.run(tail) "nip" -> return NipCommand.run(tail) "kind" -> return KindCommand.run(tail) + "sno" -> return SnoCommands.dispatch(tail) "namecoin" -> return NamecoinCommand.dispatch(tail) } @@ -512,6 +514,7 @@ 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 Simple Nostr Objects (DECK-0003): validate, price, verify an avatar | 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt new file mode 100644 index 0000000000..5e3e29d67f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SnoCommands.kt @@ -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): Int = + route( + "sno", + tail, + "sno ", + 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): 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): 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): 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 = + 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 + } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index caa8f88122..c91ed90712 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -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`. diff --git a/cli/tests/sno/.gitignore b/cli/tests/sno/.gitignore new file mode 100644 index 0000000000..268b1592e6 --- /dev/null +++ b/cli/tests/sno/.gitignore @@ -0,0 +1 @@ +state-sno-conformance/ diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py new file mode 100755 index 0000000000..2981d0c627 --- /dev/null +++ b/cli/tests/sno/refdriver.py @@ -0,0 +1,106 @@ +#!/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":…} + +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") + 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")}) + + +COMMANDS = { + "cases": cmd_cases, + "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]]() diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh new file mode 100755 index 0000000000..a9b4fa6a0a --- /dev/null +++ b/cli/tests/sno/sno-conformance.sh @@ -0,0 +1,284 @@ +#!/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. +# +# Three 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 — the two places we knowingly differ from the §1.9 +# arbiter, pinned so neither can drift quietly. +# +# 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,27p' "${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:[range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]]} + + (if $p.ticks then {ticks:$p.ticks} else {} end)')" + + 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:"11111111111111111111111111111111111111111111111111111111111111111", kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' \ + 2>/dev/null || 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 — pinned, not ignored" + +# These are not in the deck's rejection table, so section 1 never sees them. +# Both are deliberate and documented; a change in either direction is news. + +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. A vertex past 64 units. §1.8 puts that bound on publishers only and lets +# a reader repair instead; §8's third open question admits leaving it off +# readers is unsafe. Neither reference bounds it. We draw strangers' events in +# a feed, and whole*120 overflows an Int long before the text's limit. +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 "divergence-position-bound" "$FAR" false true "we refuse an unbounded vertex; the arbiter accepts it" + +print_summary + +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt index 8e8c6ad188..9fd8b31ef8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -63,12 +63,13 @@ class SnoAvatarEvent( fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) /** - * Whether this avatar has paid for the room it takes up (§8.10). + * Whether this avatar has paid for the room it takes up, and why not when + * it has not (§8.10). * - * Both conditions are required: the committed target must cover the work the - * payload owes, **and** the id must actually carry that many leading zero - * bits. Committing the target before mining is what stops a lucky id being - * claimed against a lower bar than it was mined for (NIP-13). + * Both conditions are required: the committed target must cover the work + * the payload owes, **and** the id must actually carry that many leading + * zero bits. Committing the target before mining is what stops a lucky id + * being claimed against a lower bar than it was mined for (NIP-13). * * Note that `Event.pow()` cannot stand in for this. It is * `PoWRankEvaluator.compute(id, commitedPoW)`, which returns @@ -77,14 +78,33 @@ class SnoAvatarEvent( * committed 30 and an id carrying 20 gives 20, which clears 16 while * failing the second condition outright. */ - fun isPaid(): Boolean { - if (isDefaultAvatar()) return true - val payload = sno().payloadOrNull() ?: return false - val committed = tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } ?: return false - if (committed < SnoAvatarWork.required(payload)) return false - return PoWRankEvaluator.calculatePowRankOf(id) >= committed + fun payment(): SnoAvatarPayment { + val zeros = PoWRankEvaluator.calculatePowRankOf(id) + if (isDefaultAvatar()) { + return SnoAvatarPayment(true, 0, null, zeros, SnoAvatarPayment.Reason.DEFAULT_AVATAR) + } + + val payload = + sno().payloadOrNull() + ?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR) + + val required = SnoAvatarWork.required(payload) + val committed = + tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } + ?: return SnoAvatarPayment(false, required, null, zeros, SnoAvatarPayment.Reason.NO_NONCE) + + if (committed < required) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNDER_COMMITTED) + } + if (zeros < committed) { + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNPAID) + } + return SnoAvatarPayment(true, required, committed, zeros, SnoAvatarPayment.Reason.OK) } + /** Whether a client may draw this avatar. See [payment] for why not. */ + fun isPaid(): Boolean = payment().ok + companion object { const val KIND = 11333 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt new file mode 100644 index 0000000000..46572a9494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import androidx.compose.runtime.Immutable + +/** + * Why an avatar may or may not be drawn (`CYBERSPACE_V2.md` §8.10). + * + * The fields and the [reason] vocabulary deliberately mirror + * `verify_avatar_work` in the reference implementations, so the two can be + * diffed directly rather than compared by eye. + */ +@Immutable +data class SnoAvatarPayment( + /** Whether a client may draw this avatar. */ + val ok: Boolean, + /** The leading zero bits this shape owes, or 0 when the shape is unreadable. */ + val required: Int, + /** The target the publisher committed to in its `nonce` tag, if it wrote one. */ + val committed: Int?, + /** The leading zero bits the event id actually carries. */ + val zeros: Int, + val reason: Reason, +) { + enum class Reason( + val code: String, + ) { + OK("ok"), + + /** + * Empty content, which §8.10 defines as the default avatar: it owes no + * work and there is nothing to draw but the client's own default. + * + * The reference implementations answer `not-an-avatar` here, because + * their check begins by parsing the content and empty is not JSON. The + * two agree on what happens — the default gets drawn — and differ only + * in what they call it. + */ + DEFAULT_AVATAR("default-avatar"), + + /** Not kind 11333, or content that cannot be read as a payload. */ + NOT_AN_AVATAR("not-an-avatar"), + + /** No `nonce` tag, so nothing was committed to before mining (NIP-13). */ + NO_NONCE("no-nonce"), + + /** The committed target does not cover the work the shape owes. */ + UNDER_COMMITTED("under-committed"), + + /** The id does not carry the zeros its own commitment promised. */ + UNPAID("unpaid"), + } +} From 7edcc18083264054ba7d036df6dca7bb1131c34d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 13:01:55 +0000 Subject: [PATCH 14/38] fix: overflow, a frozen drag, and a shard we should not complain about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of the branch. Seven findings, all real, all fixed. The one that mattered: both of §1's run-length forms say "a negative integer -N", and both were read by negating the entry. -Int.MIN_VALUE is Int.MIN_VALUE — still negative — so the guards assuming a positive count did not hold. `"ticks":[-2147483648,[1,2,3]]` drove the write cursor below zero and indexed out of the buffer, uncaught, inside composition: a hostile kind 33331/3330/11333 event in a feed crashed the app. The same hole in facecolors swallowed a run instead of refusing it, and abs(Int.MIN_VALUE) let a vertex of -2147483648 past the ±64-unit bound, where *120 overflowed to exactly 0 and the payload parsed valid with the coordinate silently moved to the origin. All three are now bounded before they are negated, which also expresses the real rule: a run longer than the list it repeats into is invalid whatever its sign. SnoParserOverflowTest pins each, with payloads a stranger can publish. The drag rasterised full-resolution on the composition thread — around 48ms a frame at the 1080px a 360.dp viewer asks for on a 3x screen, which the KDoc had wrongly called "a few hundred pixels square". Now capped at 512px and moved to Dispatchers.Default, keeping the previous frame on screen while the next is drawn, so a heavy object makes the turn coarse rather than freezing the gesture. This is what the plan asked for and the first pass skipped. A sealed kind 3330 rendered "could not be read (rule json)" at its finder — contradicting §7.6, which SnoShardEvent's own KDoc quotes: failing to open a bag says nothing about the bag. Both 3330s reachable on a relay are sealed. isSealed() names the case and the renderer draws nothing for it. Also: the harness synthesised a 65-character pubkey behind a dead fallback, and refdriver's vectors path missed the env guard its siblings have. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/ui/note/types/SnoShard.kt | 6 + cli/tests/sno/refdriver.py | 2 + cli/tests/sno/sno-conformance.sh | 3 - .../commons/sno/ui/SnoObjectViewer.kt | 80 ++++++++----- .../cyberspace/deck0003Sno/SnoAvatarWork.kt | 7 +- .../cyberspace/deck0003Sno/SnoParser.kt | 25 +++- .../cyberspace/deck0003Sno/SnoShardEvent.kt | 11 ++ .../deck0003Sno/SnoParserOverflowTest.kt | 108 ++++++++++++++++++ .../deck0003Sno/SnoShardEventTest.kt | 9 +- 9 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt index 5dcfb459de..e2c01b9b03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt @@ -52,6 +52,12 @@ private val VIEWER_HEIGHT = 360.dp @Composable fun RenderSnoShard(baseNote: Note) { 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 parsed = remember(noteEvent) { noteEvent.shard() } when (parsed) { diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py index 2981d0c627..f84fb122f1 100755 --- a/cli/tests/sno/refdriver.py +++ b/cli/tests/sno/refdriver.py @@ -64,6 +64,8 @@ def cmd_cases(): 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"]}) diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index a9b4fa6a0a..d607f1f349 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -200,9 +200,6 @@ avatar_event() { # kind, id-bits, committed|none, content [[ "$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:"11111111111111111111111111111111111111111111111111111111111111111", kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' \ - 2>/dev/null || 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)}' } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt index 679fe9a4ad..83c4534d6c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -26,14 +26,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -42,6 +44,8 @@ import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage import com.vitorpamplona.amethyst.commons.service.image.toComposeImageBitmap import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** How many degrees a drag of one pixel turns the object. */ private const val DEGREES_PER_PIXEL = 0.5f @@ -49,6 +53,18 @@ 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 +/** + * The widest raster a drag will produce, whatever the viewer is given. + * + * Fill cost is quadratic in this number and a drag pays it every frame. At a + * viewer of 360.dp on a 3x screen the natural size is 1080px, which measured + * around 48ms a frame for an object near the format's ceiling — a third of the + * budget for a 60Hz frame spent on one image. 512 keeps the same object around + * 10ms and is still more pixels than the viewer shows after `ContentScale.Fit` + * on most phones. + */ +private const val MAX_RASTER_PX = 512 + /** * A Simple Nostr Object the reader can turn. * @@ -59,9 +75,10 @@ private const val MAX_PITCH = 89f * dragging. Nothing here is worth caching — the object is the event, and * re-rasterising it is cheaper than remembering it. * - * The raster runs on the composition's thread. At the format's ceiling that is - * 512 vertices and 1024 triangles into a few hundred pixels square, which is - * well inside a frame; the limits of §1.8 are what make that safe to assume. + * The raster runs off the composition's thread and is capped at + * [MAX_RASTER_PX] a side, because neither is optional at the format's ceiling: + * 512 vertices and 1024 triangles at the size a viewer actually asks for + * measured around 48ms a frame, which a drag pays on every angle. */ @Composable fun SnoObjectViewer( @@ -86,31 +103,40 @@ fun SnoObjectViewer( }, contentAlignment = Alignment.Center, ) { - val pixelSize = with(LocalDensity.current) { minOf(maxWidth, maxHeight).roundToPx() } - if (pixelSize <= 0) return@BoxWithConstraints + val onScreenPx = with(LocalDensity.current) { minOf(maxWidth, maxHeight).roundToPx() } + if (onScreenPx <= 0) return@BoxWithConstraints + val rasterPx = minOf(onScreenPx, MAX_RASTER_PX) - val bitmap = - remember(payload, pixelSize, yaw, pitch, backgroundArgb) { - val pixels = - SnoRasterizer.render( - payload = payload, - width = pixelSize, - height = pixelSize, - yawDegrees = yaw, - pitchDegrees = pitch, - background = backgroundArgb, - ) - PlatformImage.create(pixels, pixelSize, pixelSize).toComposeImageBitmap() + // Held across angles rather than recomputed in composition: the previous + // frame stays on screen while the next one is drawn, so a slow object + // makes the turn coarse instead of freezing the gesture. + var bitmap by remember(eventId) { mutableStateOf(null) } + + LaunchedEffect(payload, rasterPx, yaw, pitch, backgroundArgb) { + bitmap = + withContext(Dispatchers.Default) { + val pixels = + SnoRasterizer.render( + payload = payload, + width = rasterPx, + height = rasterPx, + yawDegrees = yaw, + pitchDegrees = pitch, + background = backgroundArgb, + ) + 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(), + ) } - - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Image( - bitmap = bitmap, - contentDescription = contentDescription, - contentScale = ContentScale.Fit, - filterQuality = FilterQuality.None, - modifier = Modifier.fillMaxSize(), - ) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt index 05244827c6..5c902d6264 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarWork.kt @@ -54,9 +54,12 @@ object SnoAvatarWork { /** The leading zero bits [payload] must have been mined to, as an avatar. */ fun required(payload: SnoPayload): Int { - var farthestTicks = 0 + // Widened before negating: the parser bounds positions long before this + // runs, but a magnitude that is its own negative would price an avatar + // at nothing, and this is the one number that decides what gets drawn. + var farthestTicks = 0L for (tick in payload.positions) { - val magnitude = if (tick < 0) -tick else tick + val magnitude = if (tick < 0) -tick.toLong() else tick.toLong() if (magnitude > farthestTicks) farthestTicks = magnitude } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt index 3808407532..84dcb2ebbe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt @@ -32,7 +32,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.intOrNull -import kotlin.math.abs import kotlin.math.roundToInt /** What [SnoParser] made of a payload. */ @@ -132,9 +131,16 @@ object SnoParser { if (triple.size != 3) return SnoResult.Invalid("6", "vertex $i is not three integers") for (axis in 0..2) { val whole = triple[axis].asIntOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers") - if (abs(whole) > SnoPayload.MAX_EXTENT) return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + // Range-checked rather than `abs`-checked, because abs(Int.MIN_VALUE) + // is itself negative: a vertex of -2147483648 would pass the bound, + // overflow `* 120` to 0, and parse as a silently rewritten coordinate. + if (whole > SnoPayload.MAX_EXTENT || whole < -SnoPayload.MAX_EXTENT) { + return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + } val total = whole * SnoPayload.TICKS_PER_UNIT + ticks[i * 3 + axis] - if (abs(total) > SnoPayload.MAX_TICKS_FROM_ORIGIN) return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + if (total > SnoPayload.MAX_TICKS_FROM_ORIGIN || total < -SnoPayload.MAX_TICKS_FROM_ORIGIN) { + return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + } // §2: a `v: 1` payload has +Z away from the viewer, so its Z is // negated on read, which renders the object exactly as its author // built it. A `v: 2` payload is read as written. @@ -234,7 +240,8 @@ object SnoParser { ): Int { var needed = declared for (tick in positions) { - val units = (abs(tick) + SnoPayload.TICKS_PER_UNIT - 1) / SnoPayload.TICKS_PER_UNIT + val magnitude = if (tick < 0) -tick.toLong() else tick.toLong() + val units = ((magnitude + SnoPayload.TICKS_PER_UNIT - 1) / SnoPayload.TICKS_PER_UNIT).toInt() if (units > needed) needed = units } return needed @@ -272,7 +279,11 @@ object SnoParser { } is JsonPrimitive -> { val run = entry.asIntOrNull() ?: return null - if (run >= 0) return null + // Bounded before it is negated. `-Int.MIN_VALUE` is itself + // negative, which would drive `written` below zero and index + // out of the buffer on the next triple; and a run longer than + // the vertex list is invalid anyway, so one test does both. + if (run >= 0 || run < -vertexCount) return null val count = -run if (written + count > vertexCount) return null written += count @@ -305,6 +316,10 @@ object SnoParser { val value = (entry as? JsonPrimitive).asIntOrNull() ?: return null if (value < 0) { if (previous < 0) return null + // Bounded before it is negated, as in expandTicks: `-Int.MIN_VALUE` + // is negative, so an unguarded run would be swallowed rather than + // refused. A run longer than the face list is invalid regardless. + if (value < -faceCount) return null val count = -value if (written + count > faceCount) return null repeat(count) { out[written++] = previous } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt index 5540f9ed0c..d33af0b6bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -61,6 +61,17 @@ class SnoShardEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * True when this shard carries no payload of its own. + * + * Almost every 3330 that reaches a client this way is one: a shard's + * geometry travels inside its bag's ciphertext, and what is left on a relay + * is the item without it. §7.6 is explicit that not being able to read a + * bag "MUST NOT be treated as an error in the bag", so this is a thing to + * be quiet about rather than a malformed payload to complain of. + */ + fun isSealed(): Boolean = content.isBlank() + fun shard(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) fun shardOrNull(fetchedPalette: SnoPalette? = null): SnoPayload? = shard(fetchedPalette).payloadOrNull() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt new file mode 100644 index 0000000000..2767b96bc6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserOverflowTest.kt @@ -0,0 +1,108 @@ +/* + * 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.quartz.cyberspace.deck0003Sno + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Integer overflow in the run-length encodings and the position bound. + * + * Both of §1's run-length forms say "a **negative** integer -N", and both were + * read by negating the entry. `-Int.MIN_VALUE` is `Int.MIN_VALUE` — still + * negative — so the guards that assumed a positive count did not hold, and + * `abs()` on the position bound had the same hole. + * + * These payloads reach the parser straight off a relay and the parse runs + * inside composition, so the ticks case was an uncaught crash in a feed from a + * hostile kind 33331, 3330 or 11333 event, and the other two were silent + * corruption. Every case here is a payload a stranger can publish. + */ +class SnoParserOverflowTest { + private fun payload( + vertices: String = "[[0,0,0],[2,0,0],[1,0,2],[1,2,1]]", + colors: String = "[238,235,239,225]", + ticks: String = "", + extra: String = "", + ) = """{"v":2,"name":"t","unit":0,"mode":"solid","vertices":$vertices$ticks,"colors":$colors,"faces":[[0,1,2]]$extra}""" + + @Test + fun aTicksRunOfIntMinValueIsRefusedRatherThanCrashing() { + // Negating this gives itself, so `written` went negative and the next + // triple indexed out of the buffer. + val result = SnoParser.parse(payload(ticks = ""","ticks":[-2147483648,[1,2,3]]""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("7", result.rule) + } + + @Test + fun aTicksRunLongerThanTheVertexListIsRefused() { + assertTrue(SnoParser.parse(payload(ticks = ""","ticks":[-5]""")) is SnoResult.Invalid) + assertTrue(SnoParser.parse(payload(ticks = ""","ticks":[-2147483647]""")) is SnoResult.Invalid) + } + + @Test + fun aFaceColourRunOfIntMinValueIsRefused() { + // This one parsed Valid: the run was swallowed whole and the expansion + // never reached the length check. + val result = SnoParser.parse(payload(extra = ""","facecolors":[0,-2147483648]""")) + assertTrue(result is SnoResult.Invalid) + assertEquals("8c", result.rule) + } + + @Test + fun aVertexOfIntMinValueIsRefusedRatherThanRewritten() { + // abs(Int.MIN_VALUE) is negative, so the ±64-unit bound passed; then + // `* 120` overflowed to exactly 0 and the payload parsed Valid with the + // coordinate silently moved to the origin. + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[-2147483648,2,1]]")) + assertTrue(result is SnoResult.Invalid) + assertEquals("bound", result.rule) + } + + @Test + fun aVertexOfIntMaxValueIsRefused() { + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[2147483647,2,1]]")) + assertTrue(result is SnoResult.Invalid) + assertEquals("bound", result.rule) + } + + @Test + fun theOrdinaryRunLengthFormsStillWork() { + // The guards must not have cost the encodings they protect. + val whole = SnoParser.parse(payload(ticks = ""","ticks":[-4]""")).payloadOrNull() + assertNotNull(whole) + assertEquals(0, whole.tickAt(0, 0)) + + val flat = SnoParser.parse(payload(extra = ""","facecolors":[238]""")).payloadOrNull() + assertNotNull(flat) + assertEquals(1, flat.faceColors?.size) + + val mixed = SnoParser.parse(payload(ticks = ""","ticks":[[40,0,0],-3]""")).payloadOrNull() + assertNotNull(mixed) + // tickAt is the TOTAL: vertex 0 is [0,0,0] plus a 40-tick remainder, + // and vertex 1 is [2,0,0] with the run's zero remainder, so 2 units. + assertEquals(40, mixed.tickAt(0, 0)) + assertEquals(2 * SnoPayload.TICKS_PER_UNIT, mixed.tickAt(1, 0)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt index 1df9e39cdf..65d4d4fb7b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEventTest.kt @@ -60,7 +60,14 @@ class SnoShardEventTest { } @Test - fun anEmptyShardIsNotAShape() { + fun anEmptyShardIsSealedRatherThanBroken() { + // §7.6: a reader that cannot open a bag has learned nothing about the + // bag, so an item without its payload is not an error to report. A + // client draws nothing for one instead of complaining at its finder. + assertTrue(shard().isSealed()) + assertTrue(!shard(content = payloadJson).isSealed()) + + // It still has no shape to hand back, which is a separate question. assertTrue(shard().shard() is SnoResult.Invalid) } From f7ba313aaeab26159bcb723443ba3e53deee9f89 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 13:40:57 +0000 Subject: [PATCH 15/38] fix: the card's detail line clipped mid-word, and a test that sees it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered the card offscreen through ImageComposeScene — no display, no device — and it read "12 vertices · 12", cut mid-string. The text column had no width of its own, so it overflowed the row rather than sharing it, and the detail line had maxLines=1 with no overflow, so it hard-clipped instead of ellipsizing. Both cards fixed: the column takes weight(1f) and the detail line ellipsizes. SnoObjectCardRenderTest is the test whose absence let that through, and the frozen drag before it. Everything underneath — parser, rasterizer, image bridge — was green the whole time; none of it could see the layer above. This one renders a flat gold object and counts its pixels on the card, so it fails if the object stops reaching the screen through Coil, the fetcher or the platform image bridge. 1.8s, and checked that it fails when it should. One thing it appeared to catch was mine, not the code's: the title looked too dark until the harness wrapped the card in a themed Surface. The card inherits the app's content colour, which is right; the harness wasn't providing one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/commons/ui/note/SnoAvatarCard.kt | 3 +- .../amethyst/commons/ui/note/SnoObjectCard.kt | 3 +- .../commons/sno/SnoObjectCardRenderTest.kt | 128 ++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt index d5054b3199..c1023488ec 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt @@ -71,7 +71,7 @@ fun SnoAvatarCard( Spacer(Modifier.width(12.dp)) - Column { + Column(Modifier.weight(1f)) { Text( text = name ?: payload.name.ifBlank { stringResource(Res.string.sno_avatar_title) }, style = MaterialTheme.typography.titleMedium, @@ -83,6 +83,7 @@ fun SnoAvatarCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt index 7d468170e1..d8f389521f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt @@ -72,7 +72,7 @@ fun SnoObjectCard( Spacer(Modifier.width(12.dp)) - Column { + Column(Modifier.weight(1f)) { Text( text = payload.name.ifBlank { stringResource(Res.string.sno_object_title) }, style = MaterialTheme.typography.titleMedium, @@ -84,6 +84,7 @@ fun SnoObjectCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt new file mode 100644 index 0000000000..2d0a970130 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.sno + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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 coil3.ImageLoader +import coil3.PlatformContext +import coil3.compose.setSingletonImageLoaderFactory +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoParser +import org.jetbrains.skia.EncodedImageFormat +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. + var goldPixels = 0 + repeat(POLLS) { + val pixels = scene.render().toPixels(width, height) + goldPixels = pixels.count { it.isGold() } + if (goldPixels > 0) return@repeat + 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() + } + } + + /** 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 org.jetbrains.skia.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 + } +} From 9f76a2dfab606597c7adab051bc3cc39fb7a5ab6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 17:52:00 +0000 Subject: [PATCH 16/38] perf: 4x off the rasterizer's inner loop, plus seven audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rasterizer was measured rather than assumed, and it was slow: an object at the format's ceiling cost 146ms a frame at 512px and 628ms at 1080. Each pixel recomputed three edge functions from scratch — six multiplies and four subtractions — inside the triangle's bounding box. Edge functions are linear in x and y, so they are now evaluated once at the corner of a span and stepped: three adds a pixel. The sign of the signed area is folded into the coefficients instead of being tested per pixel, which is also what keeps both sides of a face drawn (§1.4). ceiling (512v/1004f) 512px 146.44 -> 35.89 ms 4.1x ceiling (512v/1004f) 1080px 628.16 -> 158.43 4.0x cube (8v/12f) 512px 5.97 -> 3.23 1.8x What matters more is what a real object costs, which nothing had ever measured: the largest one on the network draws in 0.25ms at thumbnail size and 3ms in the viewer. The 54ms figure is an adversarial payload — 512 vertices scattered so every triangle spans the frame — which §1.8 permits and a stranger can publish, and which is what the viewer's off-thread raster and 512px cap exist to survive. SnoRasterBenchmark keeps both numbers honest with loose ceilings that catch an order-of-magnitude regression without flaking. The audit's other findings: - the render test's poll loop used `return@repeat`, which is a continue, so it asserted on the last frame rather than the first good one; - SnoAvatarPayment now carries the payload it priced, so drawing a paid avatar parses the content once rather than twice on the way to a frame; - the two-sided branch in fillTriangle was unreachable — wC = 1 - wA - wB cannot leave all three non-positive — and is gone with the rewrite; - a nonce tag committing to nothing now reads uncommitted-nonce rather than no-nonce; same verdict, honest diagnostic; - the harness synthesised faces for fixtures with too few vertices to hold one, dividing by zero in jq and inventing a divergence; - an inline fully-qualified JsonElement, against the house style rule; - sno_object_turn_hint was unused and would have gone out for translation. Checked the rewrite against the seven live objects by eye as well as by test: pixel-identical to before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../amethyst/ui/note/types/SnoAvatar.kt | 4 +- cli/tests/sno/sno-conformance.sh | 9 +- .../amethyst/commons/sno/SnoRasterizer.kt | 71 +++++++----- .../commons/prodbench/SnoRasterBenchmark.kt | 104 ++++++++++++++++++ .../composeResources/values/strings.xml | 1 - .../commons/sno/SnoObjectCardRenderTest.kt | 9 +- .../cyberspace/deck0003Sno/SnoAvatarEvent.kt | 19 +++- .../deck0003Sno/SnoAvatarPayment.kt | 17 +++ .../deck0003Sno/SnoPaletteEventReader.kt | 3 +- 9 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt index 10e65f43a6..53a515f79e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt @@ -52,7 +52,9 @@ fun RenderSnoAvatar(baseNote: Note) { val noteEvent = baseNote.event as? SnoAvatarEvent ?: return if (noteEvent.isDefaultAvatar()) return - val shape: SnoPayload? = remember(noteEvent) { if (noteEvent.isPaid()) noteEvent.sno().payloadOrNull() else null } + // 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. + val shape: SnoPayload? = remember(noteEvent) { noteEvent.payment().let { if (it.ok) it.payload else null } } if (shape == null) { SnoAvatarUnpaidCard() diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index d607f1f349..10ebbdba9d 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -157,9 +157,16 @@ else mode:(if (($p.faces // []) | length) > 0 then "solid" else "points" end), vertices:$p.vertices, colors:[range($nv) | 225], - faces:[range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]]} + 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt index 8f020e84da..72f71bae38 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt @@ -73,6 +73,9 @@ object SnoRasterizer { 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 + /** * @param background an opaque ARGB fill, or 0 for a transparent buffer. * @return `width * height` ARGB pixels, row-major. @@ -248,7 +251,7 @@ object SnoRasterizer { val cy = screen.ys[ic] val area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) - if (abs(area) < 1e-6f) return + 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)))) @@ -256,39 +259,55 @@ object SnoRasterizer { 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 + val az = screen.zs[ia] val bz = screen.zs[ib] val cz = screen.zs[ic] - val inverseArea = 1f / area + + 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) { - val y = py + 0.5f - val rowBase = py * width + var edgeA = rowA + var edgeB = rowB + var at = py * width + left for (px in left..right) { - val x = px + 0.5f - var wA = ((bx - x) * (cy - y) - (by - y) * (cx - x)) * inverseArea - var wB = ((cx - x) * (ay - y) - (cy - y) * (ax - x)) * inverseArea - var wC = 1f - wA - wB - if (wA < 0f || wB < 0f || wC < 0f) { - if (wA > 0f || wB > 0f || wC > 0f) continue - // Wound the other way: the weights are all non-positive, so - // flip them rather than dropping the face. - wA = -wA - wB = -wB - wC = -wC - val sum = wA + wB + wC - if (sum <= 0f) continue - wA /= sum - wB /= sum - wC /= sum + 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] = blend(colorA, colorB, colorC, wA, wB, wC) + } } - - val z = wA * az + wB * bz + wC * cz - val at = rowBase + px - if (z <= depth[at]) continue - depth[at] = z - pixels[at] = blend(colorA, colorB, colorC, wA, wB, wC) + edgeA += stepAx + edgeB += stepBx + at++ } + rowA += stepAy + rowB += stepBy } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt new file mode 100644 index 0000000000..986b5f311a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt @@ -0,0 +1,104 @@ +/* + * 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.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, + ): Double { + repeat(WARMUP) { SnoRasterizer.render(payload, size, size) } + val start = System.nanoTime() + repeat(RUNS) { SnoRasterizer.render(payload, size, size) } + return (System.nanoTime() - start) / 1e6 / RUNS + } + + @Test + fun aRealObjectIsCheapToDraw() { + val thumbnail = millisPerFrame(real, THUMBNAIL_PX) + val viewer = millisPerFrame(real, VIEWER_PX) + println("real (118v/94f): ${THUMBNAIL_PX}px ${fmt(thumbnail)} ms, ${VIEWER_PX}px ${fmt(viewer)} 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") + } + + 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 + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 2f6edc531c..b27e9d4bad 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3083,7 +3083,6 @@ 3D object %1$d vertices · %2$d faces This 3D object could not be read (rule %1$s) - Drag to turn Cyberspace avatar This avatar has not paid for its size, so it is not drawn diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt index 2d0a970130..e1df2e0389 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt @@ -34,6 +34,7 @@ import coil3.compose.setSingletonImageLoaderFactory 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 @@ -84,11 +85,13 @@ class SnoObjectCardRenderTest { // 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 - repeat(POLLS) { + for (poll in 0 until POLLS) { val pixels = scene.render().toPixels(width, height) goldPixels = pixels.count { it.isGold() } - if (goldPixels > 0) return@repeat + if (goldPixels > 0) break Thread.sleep(POLL_MILLIS) } @@ -110,7 +113,7 @@ class SnoObjectCardRenderTest { } /** The rendered frame as ARGB, through PNG so no Skia pixel layout is assumed. */ - private fun org.jetbrains.skia.Image.toPixels( + private fun Image.toPixels( width: Int, height: Int, ): IntArray { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt index 9fd8b31ef8..bc229e9efd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.cyberspace.deck0003Sno import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip13Pow.hasPoWTag import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag @@ -89,17 +90,27 @@ class SnoAvatarEvent( ?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR) val required = SnoAvatarWork.required(payload) + // `parseCommitment` answers null both for "no nonce tag" and for "a + // nonce tag that committed to nothing". The verdict is the same either + // way, but the reason is what a caller reads, so tell them apart. val committed = tags.firstNotNullOfOrNull { PoWTag.parseCommitment(it) } - ?: return SnoAvatarPayment(false, required, null, zeros, SnoAvatarPayment.Reason.NO_NONCE) + ?: return SnoAvatarPayment( + ok = false, + required = required, + committed = null, + zeros = zeros, + reason = if (hasPoWTag()) SnoAvatarPayment.Reason.UNCOMMITTED_NONCE else SnoAvatarPayment.Reason.NO_NONCE, + payload = payload, + ) if (committed < required) { - return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNDER_COMMITTED) + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNDER_COMMITTED, payload) } if (zeros < committed) { - return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNPAID) + return SnoAvatarPayment(false, required, committed, zeros, SnoAvatarPayment.Reason.UNPAID, payload) } - return SnoAvatarPayment(true, required, committed, zeros, SnoAvatarPayment.Reason.OK) + return SnoAvatarPayment(true, required, committed, zeros, SnoAvatarPayment.Reason.OK, payload) } /** Whether a client may draw this avatar. See [payment] for why not. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt index 46572a9494..323eb5cfb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPayment.kt @@ -40,6 +40,15 @@ data class SnoAvatarPayment( /** The leading zero bits the event id actually carries. */ val zeros: Int, val reason: Reason, + /** + * The shape this verdict is about, when it could be read at all. + * + * Pricing an avatar means parsing it, so the parse is handed back rather + * than thrown away: a caller that draws a paid avatar would otherwise parse + * the same content a second time, and on Android that second parse lands on + * the composition thread. + */ + val payload: SnoPayload? = null, ) { enum class Reason( val code: String, @@ -63,6 +72,14 @@ data class SnoAvatarPayment( /** No `nonce` tag, so nothing was committed to before mining (NIP-13). */ NO_NONCE("no-nonce"), + /** + * A `nonce` tag that names no target. NIP-13 allows the bare two-element + * form, which mines without committing — and §8.10 needs the commitment, + * so this is unpaid for the same reason [NO_NONCE] is, by a different + * route worth telling apart when reading a verdict. + */ + UNCOMMITTED_NONCE("uncommitted-nonce"), + /** The committed target does not cover the work the shape owes. */ UNDER_COMMITTED("under-committed"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt index 77a8f49dd6..41db05e9eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPaletteEventReader.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.cyberspace.deck0003Sno import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.intOrNull @@ -93,7 +94,7 @@ object SnoPaletteEventReader { return SnoPalette(colors) } - private fun channel(element: kotlinx.serialization.json.JsonElement): Int? { + private fun channel(element: JsonElement): Int? { val primitive = element as? JsonPrimitive ?: return null if (primitive.isString) return null val value = primitive.intOrNull ?: return null From eef8a8ddb7412142c4b81cf1312427e7ab728af8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 19:01:10 +0000 Subject: [PATCH 17/38] docs: why not just draw it on a Canvas, measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The obvious objection to a CPU rasterizer is that Skia is right there. Measured at 512px on the same meshes, a Canvas is 1.6-2.7x faster on software skiko, and on Android it would be GPU-accelerated and far better than that. It is still the wrong trade, for reasons that cost correctness rather than speed: a Canvas has no depth buffer, so triangles must be sorted back to front every frame — exact for a convex mesh, wrong for interpenetrating ones, which lattice geometry invites because §1.2's promise is that objects authored to share an edge do. And drawPath fills a path with one colour, so every face would flatten; two of the seven objects on the network are smooth three-colour gradients. drawVertices keeps those but is hardware-accelerated only from API 29 against a minSdk of 26, and measured no better than the CPU path for small meshes. The speed is not needed either way: a real object is 0.25ms at thumbnail size and 3ms per viewer frame, off-thread. Recorded so the next person does not have to re-derive it, with the condition that would change the answer — a measurement on real hardware, not in a container. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index 42580406d2..c83403395f 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -452,6 +452,50 @@ triangles into a 384×384 buffer with a `FloatArray` depth buffer. That is well 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.3 Two surfaces, and only one of them needs to be fast | Surface | Renderer | Cache | From e294a56dea11d832134b19bf36aa2db5e1cf4d92 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 22 Sep 2026 19:29:30 +0000 Subject: [PATCH 18/38] feat: the viewer can move and zoom, and stops rendering soft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These objects exist to be turned and looked at, and the first pass shipped the viewer half-built and capped. It only rotated. No zoom, no pan, on an object whose entire purpose is being inspected from angles — and the plan had said "drag to rotate, pinch to zoom" before only half of it got built. One finger now turns it, two move and scale it, a double tap puts it back. It also 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, a real object at full 1080px costs 4.86ms. The cap was defending against a number that no longer existed, and charging a soft image for it. The cap is adaptive now: 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 closely; the frame you stop on is the one that has to be sharp. That covers the adversarial object without a fixed ceiling too: coarse while it moves, paying its 177ms once when it settles. Zoom and pan never re-raster — they are a graphicsLayer transform of the frame already drawn, which is free per frame, since only rotation changes which faces point at the viewer. The re-raster on settle is what sharpens a deep zoom instead of leaving it an upscaled bitmap. The viewer's composition path now has its own render test; it shares nothing with the card's but the rasterizer underneath. Gestures are the part that cannot be tested headless and have not been touched by a finger yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 27 ++++ .../commons/prodbench/SnoRasterBenchmark.kt | 5 +- .../commons/sno/ui/SnoObjectViewer.kt | 138 +++++++++++++----- .../commons/sno/SnoObjectCardRenderTest.kt | 40 +++++ 4 files changed, 174 insertions(+), 36 deletions(-) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index c83403395f..69127ef9d6 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -496,6 +496,33 @@ 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. + +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.3 Two surfaces, and only one of them needs to be fast | Surface | Renderer | Cache | diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt index 986b5f311a..45009e8944 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt @@ -77,7 +77,10 @@ class SnoRasterBenchmark { fun aRealObjectIsCheapToDraw() { val thumbnail = millisPerFrame(real, THUMBNAIL_PX) val viewer = millisPerFrame(real, VIEWER_PX) - println("real (118v/94f): ${THUMBNAIL_PX}px ${fmt(thumbnail)} ms, ${VIEWER_PX}px ${fmt(viewer)} ms") + 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") diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt index 83c4534d6c..e413a3a0e6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -21,7 +21,11 @@ package com.vitorpamplona.amethyst.commons.sno.ui import androidx.compose.foundation.Image -import androidx.compose.foundation.gestures.detectDragGestures +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 @@ -36,8 +40,11 @@ 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 com.vitorpamplona.amethyst.commons.blurhash.PlatformImage @@ -47,38 +54,49 @@ import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** How many degrees a drag of one pixel turns the object. */ +/** 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 -/** - * The widest raster a drag will produce, whatever the viewer is given. - * - * Fill cost is quadratic in this number and a drag pays it every frame. At a - * viewer of 360.dp on a 3x screen the natural size is 1080px, which measured - * around 48ms a frame for an object near the format's ceiling — a third of the - * budget for a 60Hz frame spent on one image. 512 keeps the same object around - * 10ms and is still more pixels than the viewer shows after `ContentScale.Fit` - * on most phones. - */ -private const val MAX_RASTER_PX = 512 +private const val MIN_ZOOM = 0.5f +private const val MAX_ZOOM = 8f /** - * A Simple Nostr Object the reader can turn. + * The raster size while a gesture is in flight. * - * Unlike [SnoThumbnail] this draws straight to a bitmap instead of going - * through Coil, because a drag produces a new angle every frame and each one - * would otherwise become its own cache entry: at 360dp that is around half a - * megabyte a frame, which would evict the whole image cache in a second of - * dragging. Nothing here is worth caching — the object is the event, and - * re-rasterising it is cheaper than remembering it. + * 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. * - * The raster runs off the composition's thread and is capped at - * [MAX_RASTER_PX] a side, because neither is optional at the format's ceiling: - * 512 vertices and 1024 triangles at the size a viewer actually asks for - * measured around 48ms a frame, which a drag pays on every angle. + * 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 + +/** + * 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. + * + * 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( @@ -90,26 +108,68 @@ fun SnoObjectViewer( ) { 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) } val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb() BoxWithConstraints( modifier = - modifier.pointerInput(eventId) { - detectDragGestures { change, dragAmount -> - change.consume() - yaw = wrapDegrees(yaw + dragAmount.x * DEGREES_PER_PIXEL) - pitch = (pitch - dragAmount.y * DEGREES_PER_PIXEL).coerceIn(-MAX_PITCH, MAX_PITCH) - } - }, + 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 - val rasterPx = minOf(onScreenPx, MAX_RASTER_PX) + + // 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 one is drawn, so a slow object - // makes the turn coarse instead of freezing the gesture. + // 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(null) } LaunchedEffect(payload, rasterPx, yaw, pitch, backgroundArgb) { @@ -134,7 +194,15 @@ fun SnoObjectViewer( bitmap = drawn, contentDescription = contentDescription, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = zoom, + scaleY = zoom, + translationX = panX, + translationY = panY, + ), ) } } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt index e1df2e0389..13533a9532 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoObjectCardRenderTest.kt @@ -22,15 +22,18 @@ 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 @@ -104,6 +107,43 @@ class SnoObjectCardRenderTest { } } + @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 From b253189f86f177cbe23b3135d3f79668eb3a9b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:16:57 +0000 Subject: [PATCH 19/38] feat: vertices in every mode, and a light switch on the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the author's own current renderer. `cyberspace-viewer` turned out not to be it — last commit 2023-08-25, three years before DECK-0003, drawing v1 wireframe Constructs, with no mention of shards anywhere. ONOSENDAI's `src/scene/ShardMesh.tsx` is, and it draws a shard two ways: unlit in the world, which is what we already matched exactly, and lit on the workshop bench, which is the mode our viewer actually corresponds to. Two gaps came out of the comparison. §1.5's "a client SHOULD also draw the vertices as points in every mode" was half-implemented: points and lines mode had them, and a solid did not, which is the one case the sentence is about. Two profiles now, as the reference keeps: where the vertices are the shape they are 0.3 units across clamped to 1.6-10px, and under a solid or a wireframe they are 0.07 units clamped to 0.7-2.4px at 55% over what is already there — a hint they are there, never a second shape competing with the faces. They read the depth buffer and do not write it, 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: §1.4a has already decided the vertex colours are not what that object shows. And the viewer was a bench using the world's shading. §4 permits lighting in as many words — "A client MAY light an object instead, and many will" — on two conditions, normals per face and flat from the triangle's own vertices, and never smoothed across a shared vertex. Both are met. Unlit, a cube of one colour is a flat hexagon however far you turn it, which is the whole argument for the switch. It defaults off, so the feed and a freshly opened viewer both show §4's default reading and an object looks the same everywhere it is quoted. Lighting needs the faces wound outward first, because nothing on the wire says which way round a face is and half a stamped block's triangles wind inward. 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 turned as a whole by casting rays from its own faces 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. One divergence from that port, with a test: the fallback for a patch whose rays see nothing is its signed volume, and the divergence theorem's volume only ignores where it is measured from for a *closed* surface. Measured from the origin, an open plate gets the volume of the cone between the two — large, and signed by which side of the origin the plate sits on, so the same plate would be lit on top above the origin and underneath below it. Measured about the patch's own middle that cone is flat, the volume is zero, and a plate falls through to facing up wherever §1.8 let the author put it. The winding pass is quadratic in the face count and is by far the most expensive thing here: 0.63ms for the largest real object, 27.6ms for an adversarial one at §1.8's ceiling. It depends on the object and not the angle, so it is computed once, off the composition thread, and held across a turn. The frame is what had to stay cheap, and did: 512px of a real object is 1.21ms flat and 1.62ms lit. Most of that came from hoisting the single-colour case out of the pixel loop while adding the light. A face whose three corners agree — a stamped block, or any face carrying its own colour — now settles its colour once for the whole triangle instead of testing for equality at every pixel. A real object at 1080px went 8.04 to 6.81ms in the same run that added the shading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 78 +++ .../amethyst/commons/sno/SnoFaceWinding.kt | 450 ++++++++++++++++++ .../amethyst/commons/sno/SnoLighting.kt | 149 ++++++ .../amethyst/commons/sno/SnoRasterizer.kt | 258 +++++++++- .../amethyst/commons/sno/SnoLightingTest.kt | 259 ++++++++++ .../amethyst/commons/sno/SnoRasterizerTest.kt | 49 +- .../commons/prodbench/SnoRasterBenchmark.kt | 32 +- .../composeResources/values/strings.xml | 2 + .../commons/sno/ui/SnoObjectViewer.kt | 66 ++- 9 files changed, 1318 insertions(+), 25 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index 69127ef9d6..890df1232e 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -523,6 +523,84 @@ 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.3 Two surfaces, and only one of them needs to be fast | Surface | Renderer | Cache | diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt new file mode 100644 index 0000000000..3dc359d546 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFaceWinding.kt @@ -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>(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() + val queue = ArrayDeque() + 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, + 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() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt new file mode 100644 index 0000000000..06b7be637c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLighting.kt @@ -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 + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt index 72f71bae38..4eff817f91 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt @@ -28,6 +28,7 @@ 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. @@ -54,6 +55,11 @@ import kotlin.math.sin * 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). */ @@ -66,20 +72,58 @@ object SnoRasterizer { private const val MARGIN = 0.08f /** - * Below this many pixels across, a solid object is drawn as points too, so - * it "remains visible when it is smaller on screen than a triangle" (§1.5). + * 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. * @return `width * height` ARGB pixels, row-major. */ + @Suppress("detekt.LongParameterList") fun render( payload: SnoPayload, width: Int, @@ -87,6 +131,7 @@ object SnoRasterizer { yawDegrees: Float = DEFAULT_YAW_DEGREES, pitchDegrees: Float = DEFAULT_PITCH_DEGREES, background: Int = TRANSPARENT, + lighting: SnoLighting? = null, ): IntArray { require(width > 0 && height > 0) { "a raster needs a positive size" } @@ -98,14 +143,21 @@ object SnoRasterizer { val drawTriangles = payload.mode == SnoMode.SOLID && payload.faceCount > 0 if (drawTriangles) { - drawFaces(payload, screen, pixels, depth, width, height) + drawFaces(payload, screen, pixels, depth, width, height, lighting) } - if (payload.mode == SnoMode.LINES) { + val drawLines = payload.mode == SnoMode.LINES && payload.vertexCount > 1 + if (drawLines) { drawEdges(payload, screen, pixels, depth, width, height) } - if (payload.mode == SnoMode.POINTS || payload.mode == SnoMode.LINES || !drawTriangles || screen.spanPixels < TOO_SMALL_FOR_TRIANGLES) { - drawPoints(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 } @@ -179,7 +231,7 @@ object SnoRasterizer { ys[i] = halfHeight - (ys[i] - centreY) * scale } - return Projection(xs, ys, zs, largest * scale) + return Projection(xs, ys, zs, largest * scale, scale * SnoPayload.TICKS_PER_UNIT) } private class Projection( @@ -188,8 +240,11 @@ object SnoRasterizer { 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, @@ -197,12 +252,19 @@ object SnoRasterizer { 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) { - val a = payload.faces[face * 3] - val b = payload.faces[face * 3 + 1] - val c = payload.faces[face * 3 + 2] + // 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) @@ -218,6 +280,8 @@ object SnoRasterizer { flat ?: payload.colors[a], flat ?: payload.colors[b], flat ?: payload.colors[c], + lighting?.outside?.get(face) ?: UNLIT, + lighting?.inside?.get(face) ?: UNLIT, ) } } @@ -242,6 +306,8 @@ object SnoRasterizer { colorA: Int, colorB: Int, colorC: Int, + shadeOutside: Float, + shadeInside: Float, ) { val ax = screen.xs[ia] val ay = screen.ys[ia] @@ -278,6 +344,27 @@ object SnoRasterizer { 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] @@ -299,7 +386,7 @@ object SnoRasterizer { val z = wA * az + wB * bz + wC * cz if (z > depth[at]) { depth[at] = z - pixels[at] = blend(colorA, colorB, colorC, wA, wB, wC) + pixels[at] = if (interpolate) blend(colorA, colorB, colorC, wA, wB, wC, shade) else solid } } edgeA += stepAx @@ -390,6 +477,13 @@ object SnoRasterizer { } } + /** + * 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, @@ -397,12 +491,125 @@ object SnoRasterizer { 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) { - plot(pixels, depth, width, height, screen.xs[i].roundToInt(), screen.ys[i].roundToInt(), screen.zs[i], payload.colors[i]) + 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, @@ -420,7 +627,12 @@ object SnoRasterizer { pixels[at] = color } - /** Barycentric interpolation between three opaque colours. */ + /** + * 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, @@ -428,14 +640,24 @@ object SnoRasterizer { wA: Float, wB: Float, wC: Float, + shade: Float, ): Int { - if (a == b && b == c) return a - val r = ((a shr 16 and 0xFF) * wA + (b shr 16 and 0xFF) * wB + (c shr 16 and 0xFF) * wC).roundToInt() - val g = ((a shr 8 and 0xFF) * wA + (b shr 8 and 0xFF) * wB + (c shr 8 and 0xFF) * wC).roundToInt() - val bl = ((a and 0xFF) * wA + (b and 0xFF) * wB + (c and 0xFF) * wC).roundToInt() + 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, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt new file mode 100644 index 0000000000..52138e5128 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoLightingTest.kt @@ -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 = 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+") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt index b9ca53c1fb..e4c4521303 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt @@ -100,7 +100,12 @@ class SnoRasterizerTest { ) val lit = pixels.filter { it != 0 } assertTrue(lit.isNotEmpty()) - assertTrue(lit.all { it == 0xFFFFFFFF.toInt() }, "every covered pixel should be the face colour, flat") + 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 @@ -143,8 +148,41 @@ class SnoRasterizerTest { @Test fun pointsModeDrawsTheVerticesAndNotTheTriangle() { val pixels = SnoRasterizer.render(triangle(mode = "points"), dim, dim, yawDegrees = 0f, pitchDegrees = 0f) - assertTrue(pixels.litCount() <= 3, "three vertices should light at most three pixels, lit ${pixels.litCount()}") - assertTrue(pixels.litCount() >= 1) + // 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 @@ -210,7 +248,10 @@ class SnoRasterizerTest { 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) - assertEquals(1, pixels.litCount()) + // 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 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt index 45009e8944..22dda059f1 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/prodbench/SnoRasterBenchmark.kt @@ -20,6 +20,7 @@ */ 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 @@ -66,13 +67,21 @@ class SnoRasterBenchmark { private fun millisPerFrame( payload: SnoPayload, size: Int, + lighting: SnoLighting? = null, ): Double { - repeat(WARMUP) { SnoRasterizer.render(payload, size, size) } + repeat(WARMUP) { SnoRasterizer.render(payload, size, size, lighting = lighting) } val start = System.nanoTime() - repeat(RUNS) { SnoRasterizer.render(payload, size, size) } + 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) @@ -96,6 +105,25 @@ class SnoRasterBenchmark { 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 { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index b27e9d4bad..3090f043a7 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3085,4 +3085,6 @@ This 3D object could not be read (rule %1$s) Cyberspace avatar This avatar has not paid for its size, so it is not drawn + Lit; tap to draw the object flat + Flat; tap to light the object diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt index e413a3a0e6..db06cd50f3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -29,6 +29,10 @@ 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 @@ -47,12 +51,21 @@ 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.SnoRasterizer +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoMode import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload import kotlinx.coroutines.Dispatchers 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 @@ -82,6 +95,10 @@ private const val GESTURE_RASTER_PX = 384 */ 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. * @@ -93,6 +110,13 @@ private const val MAX_RESTING_RASTER_PX = 1440 * 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 @@ -112,6 +136,11 @@ fun SnoObjectViewer( 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( @@ -172,7 +201,7 @@ fun SnoObjectViewer( // the turn coarse instead of freezing the gesture. var bitmap by remember(eventId) { mutableStateOf(null) } - LaunchedEffect(payload, rasterPx, yaw, pitch, backgroundArgb) { + LaunchedEffect(payload, rasterPx, yaw, pitch, backgroundArgb, lit) { bitmap = withContext(Dispatchers.Default) { val pixels = @@ -183,6 +212,7 @@ fun SnoObjectViewer( yawDegrees = yaw, pitchDegrees = pitch, background = backgroundArgb, + lighting = if (lit) lighting.of(payload) else null, ) PlatformImage.create(pixels, rasterPx, rasterPx).toComposeImageBitmap() } @@ -206,9 +236,43 @@ fun SnoObjectViewer( ) } } + + // 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 From 0d542e1acf9aad4c3e5fb07092b1ebfcb7d677ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:11:06 +0000 Subject: [PATCH 20/38] feat: a unit is a size, a palette is fetched, and a far vertex is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following through on the comparison with the author's own client. Four things it surfaced that we did not, and one decision of ours that it showed was wrong. `unit` was never on screen. §1.6 makes it the exponent on a model unit and CYBERSPACE_V2 §9.2 fixes the base unit at 2^-33 metres, so the same geometry at unit 0 and unit 40 is a molecule and a mountain — and a card that reads "8 vertices · 12 faces" for both has said nothing about the only field that separates them. CyberspaceScale turns the exponent into a length, using sno-core/scale.ts's measure table and its three-significant-figure rounding so an object reads the same size in both clients. The test covers 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. An object naming an external palette was drawn in the wrong colours. The reader and the parser's fetchedPalette had been there from the start with nothing ever calling them, so every §1.3a reference resolved to the built-in 256 in silence. WithSnoPalette loads the named event by its own id, as §1.3a's pinning rule requires, and re-parses. 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 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. That fetch exposed a real bug next to it. The thumbnail's Coil key was sno:::, on the reasoning that an addressable object republished under the same d arrives as a new id: true of geometry, false of colour. One 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, which is what everyone is before they adopt a shape, and drawing nothing for it means the note vanishes out of the feed, which reads as a fault. It gets the wireframe icosahedron the reference puts in its place, built as an ordinary payload so it goes through the same rasterizer. Its faces are derived rather than 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 place it in its region; we have no region, but the tag still carries the plane, which says whether the thing was hidden at a place on Earth or at one with no physical counterpart. That is bit 0, and the decoding stops there on purpose: the axes are §2.3 plus an 85-bit decimal printer, and three twenty-six-digit integers tell a reader with no world nothing, while the reading that would mean something is §9.7's 96-digit decimal arithmetic with a hand-rolled trig series, carried so every client agrees on where a place on Earth is. There is no Earth here to agree about. And the ±64-unit rejection is gone. §1.8 states the position bound as an obligation on publishers and gives a reader the 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 for rejecting — that we draw strangers' events in a feed — defended nothing: 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, and it is now bounded where it 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. The conformance harness pinned that divergence, so it now pins the agreement instead, and pins the narrow one that replaced it. 11 of 11 against the reference implementations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 117 +++++++++-- .../amethyst/ui/note/NoteCompose.kt | 6 +- .../amethyst/ui/note/types/SnoAvatar.kt | 66 ++++--- .../amethyst/ui/note/types/SnoObject.kt | 64 +++--- .../amethyst/ui/note/types/SnoPalettes.kt | 101 ++++++++++ .../amethyst/ui/note/types/SnoShard.kt | 84 ++++++-- .../loggedIn/threadview/ThreadFeedView.kt | 6 +- cli/tests/sno/sno-conformance.sh | 31 ++- .../amethyst/commons/sno/SnoDefaultAvatar.kt | 186 ++++++++++++++++++ .../commons/sno/SnoDefaultAvatarTest.kt | 106 ++++++++++ .../composeResources/values/strings.xml | 5 + .../amethyst/commons/sno/SnoFetcher.kt | 24 ++- .../amethyst/commons/ui/note/SnoAvatarCard.kt | 58 ++++++ .../amethyst/commons/ui/note/SnoObjectCard.kt | 33 +++- .../amethyst/commons/sno/SnoFetcherKeyTest.kt | 80 ++++++++ .../quartz/cyberspace/CyberspaceCoordinate.kt | 78 ++++++++ .../quartz/cyberspace/CyberspaceScale.kt | 125 ++++++++++++ .../cyberspace/deck0003Sno/SnoAvatarEvent.kt | 6 +- .../cyberspace/deck0003Sno/SnoParser.kt | 43 ++-- .../cyberspace/deck0003Sno/SnoPayload.kt | 37 ++-- .../cyberspace/deck0003Sno/SnoShardEvent.kt | 13 ++ .../cyberspace/CyberspaceCoordinateTest.kt | 58 ++++++ .../quartz/cyberspace/CyberspaceScaleTest.kt | 80 ++++++++ .../deck0003Sno/SnoParserAcceptanceTest.kt | 39 +++- 24 files changed, 1310 insertions(+), 136 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index 890df1232e..b3a08da011 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -125,7 +125,10 @@ the only palette-event reader worth writing. 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." We must clamp defensively (see §5). + 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. @@ -601,6 +604,85 @@ 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:::`, 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 | @@ -628,11 +710,13 @@ size that suits its context rather than refusing it." 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. -- **Clamp the position bound that the spec leaves open.** §1.8 puts the 64-unit bound - on publishers only and §8.3 admits a reader obligation is missing. Amethyst should - reject (or clamp to) vertices beyond ±64 units — a payload at 2^50 units is valid - per the text and is a denial of service against our own projection math. Log it as - a deliberate divergence in the KDoc. +- **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. @@ -665,7 +749,8 @@ Built in this order, each step verified before the next: **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. +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 @@ -739,13 +824,17 @@ are where the risk is, and they are testable without a device. - **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). A deliberate divergence, and now a confirmed - one: neither `sno-reference.py` nor `sno-core`'s `fromPayload` bounds how far a - vertex may lie from the origin — both just grow the extent, which is what §8's - third open question admits. sno-core even has a `validPoint(p, extent)` helper and - does not call it on the read path. Recommend clamping anyway and documenting it as - a divergence; we render strangers' events in a feed, which is not the position - either of those readers is in. +- **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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9398a8c332..32c895e4f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1374,15 +1374,15 @@ private fun RenderNoteRow( } is SnoObjectEvent -> { - RenderSnoObject(baseNote) + RenderSnoObject(baseNote, accountViewModel) } is SnoAvatarEvent -> { - RenderSnoAvatar(baseNote) + RenderSnoAvatar(baseNote, accountViewModel) } is SnoShardEvent -> { - RenderSnoShard(baseNote) + RenderSnoShard(baseNote, accountViewModel) } is ChessGameEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt index 53a515f79e..9bfc6b2ac4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt @@ -33,8 +33,11 @@ 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 @@ -44,40 +47,57 @@ private val VIEWER_HEIGHT = 360.dp * * §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 and owes no work, so there is - * nothing to show for it either. + * 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) { +fun RenderSnoAvatar( + baseNote: Note, + accountViewModel: AccountViewModel, +) { val noteEvent = baseNote.event as? SnoAvatarEvent ?: return - if (noteEvent.isDefaultAvatar()) 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. - val shape: SnoPayload? = remember(noteEvent) { noteEvent.payment().let { if (it.ok) it.payload else null } } - - if (shape == null) { - SnoAvatarUnpaidCard() + if (noteEvent.isDefaultAvatar()) { + SnoAvatarDefaultCard(noteEvent.id) return } - var turning by remember(noteEvent) { mutableStateOf(false) } + // 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() } - SnoAvatarCard( - payload = shape, - eventId = noteEvent.id, - name = noteEvent.nameTag(), - onClick = { turning = true }, - ) + 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 (turning) { - Dialog(onDismissRequest = { turning = false }) { - SnoObjectViewer( + if (shape == null) { + SnoAvatarUnpaidCard() + } else { + var turning by remember(noteEvent) { mutableStateOf(false) } + + SnoAvatarCard( payload = shape, eventId = noteEvent.id, - contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null }, - modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT), + 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), + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt index a7d691a1ad..638579782c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoObject.kt @@ -34,7 +34,9 @@ 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 @@ -42,35 +44,51 @@ 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 card is drawn from what - * the note already carries: no fetch, no decode of an external asset, and no - * network access at all. A payload that fails §1.9 is not drawn — the deck - * requires that — and the rule it broke is shown instead of nothing. + * 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) { +fun RenderSnoObject( + baseNote: Note, + accountViewModel: AccountViewModel, +) { val noteEvent = baseNote.event as? SnoObjectEvent ?: return - val parsed = remember(noteEvent) { noteEvent.sno() } + // 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() } - when (parsed) { - is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) - is SnoResult.Valid -> { - var turning by remember(noteEvent) { mutableStateOf(false) } + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.sno(palette) } - SnoObjectCard( - payload = parsed.payload, - eventId = noteEvent.id, - onClick = { turning = true }, - ) + when (parsed) { + is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) + is SnoResult.Valid -> { + var turning by remember(noteEvent) { mutableStateOf(false) } - 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), - ) + 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), + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt new file mode 100644 index 0000000000..7072fa516f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoPalettes.kt @@ -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) } }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt index e2c01b9b03..121a17a8f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt @@ -31,11 +31,19 @@ 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 @@ -50,7 +58,10 @@ private val VIEWER_HEIGHT = 360.dp * a renderer. */ @Composable -fun RenderSnoShard(baseNote: Note) { +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 @@ -58,29 +69,64 @@ fun RenderSnoShard(baseNote: Note) { // draw. Both 3330s reachable on a relay today are this. if (noteEvent.isSealed()) return - val parsed = remember(noteEvent) { noteEvent.shard() } + val first = remember(noteEvent) { noteEvent.shard() } + val place = placeOf(noteEvent) - when (parsed) { - is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) - is SnoResult.Valid -> { - var turning by remember(noteEvent) { mutableStateOf(false) } + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + val parsed = remember(noteEvent, palette) { if (palette == null) first else noteEvent.shard(palette) } - SnoObjectCard( - payload = parsed.payload, - eventId = noteEvent.id, - onClick = { turning = true }, - ) + when (parsed) { + is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) + is SnoResult.Valid -> { + var turning by remember(noteEvent) { mutableStateOf(false) } - 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), - ) + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 2d0cec64e0..21932f980d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -1071,11 +1071,11 @@ private fun FullBleedNoteCompose( } else if (noteEvent is BirdDetectionEvent) { RenderBirdDetection(baseNote) } else if (noteEvent is SnoObjectEvent) { - RenderSnoObject(baseNote) + RenderSnoObject(baseNote, accountViewModel) } else if (noteEvent is SnoAvatarEvent) { - RenderSnoAvatar(baseNote) + RenderSnoAvatar(baseNote, accountViewModel) } else if (noteEvent is SnoShardEvent) { - RenderSnoShard(baseNote) + RenderSnoShard(baseNote, accountViewModel) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index 10ebbdba9d..c6c09a86f8 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -16,8 +16,9 @@ # through `amy sno work` and `avatar_work()`. # 3. Avatar payment — synthesised events through `amy sno verify` and # `verify_avatar_work()`. -# 4. Reader divergence — the two places we knowingly differ from the §1.9 -# arbiter, pinned so neither can drift quietly. +# 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. # # Divergences we already know about are asserted as divergences, not ignored: # a reader that silently stopped diverging would be just as interesting as one @@ -247,10 +248,11 @@ check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an- # ---- 4. the two places our reader knowingly differs ------------------------ -banner "4. reader divergence — pinned, not ignored" +banner "4. reader divergence and agreement — pinned, not ignored" -# These are not in the deck's rejection table, so section 1 never sees them. -# Both are deliberate and documented; a change in either direction is news. +# 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" @@ -275,12 +277,21 @@ check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, not 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. A vertex past 64 units. §1.8 puts that bound on publishers only and lets -# a reader repair instead; §8's third open question admits leaving it off -# readers is unsafe. Neither reference bounds it. We draw strangers' events in -# a feed, and whole*120 overflows an Int long before the text's limit. +# 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 "divergence-position-bound" "$FAR" false true "we refuse an unbounded vertex; the arbiter accepts it" +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" print_summary diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt new file mode 100644 index 0000000000..26ea69a8b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatar.kt @@ -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, + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt new file mode 100644 index 0000000000..431098e5fd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoDefaultAvatarTest.kt @@ -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() + 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") + } + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3090f043a7..cef2937c4c 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3087,4 +3087,9 @@ This avatar has not paid for its size, so it is not drawn Lit; tap to draw the object flat Flat; tap to light the object + 1 unit = %1$s + Hidden in dataspace · %1$s + Hidden in ideaspace · %1$s + Default avatar + No shape published, so everyone sees this one diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt index c4ebb48e2c..b781b40a36 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcher.kt @@ -36,9 +36,15 @@ 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 and the size. The payload itself - * is not part of the key because an addressable object republished under the - * same `d` arrives as a new event id. + * 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( @@ -49,7 +55,17 @@ data class SnoObjectToRender( val pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES, val background: Int = 0, ) { - val cacheKey: String get() = "sno:$eventId:$size:$yawDegrees:$pitchDegrees:$background" + 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) } /** diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt index c1023488ec..9437e60b02 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt @@ -34,12 +34,17 @@ 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 @@ -85,6 +90,59 @@ fun SnoAvatarCard( 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, + ) } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt index d8f389521f..a8614172b1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoObjectCard.kt @@ -35,11 +35,13 @@ 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 @@ -48,14 +50,25 @@ private val THUMBNAIL_SIZE = 96.dp /** * A Simple Nostr Object in a feed or a thread (DECK-0003 kind 33331). * - * The object 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 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) @@ -86,6 +99,22 @@ fun SnoObjectCard( 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, + ) + } } } } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt new file mode 100644 index 0000000000..051d95bf2c --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoFetcherKeyTest.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt new file mode 100644 index 0000000000..4fbbc0445d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt @@ -0,0 +1,78 @@ +/* + * 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.quartz.cyberspace + +/** Which of the two overlapping coordinate spaces a place is in (§2.4). */ +enum class CyberspacePlane { + /** Maps to physical reality through GPS (§9). */ + DATASPACE, + + /** No physical counterpart; purely abstract positions. */ + IDEASPACE, +} + +/** + * The little of `CYBERSPACE_V2.md` §2 a client without a world can use. + * + * A coordinate is a 256-bit integer, written as 32 lowercase bytes of hex: the + * three 85-bit axes interleaved `XYZXYZ…` with the plane bit in the least + * significant position (§2.2). An item hidden at a place carries one in a `C` + * tag (§7.6). + * + * Only the plane bit is read here, and that is a deliberate stopping point + * rather than a half-finished decoder. Pulling X, Y and Z back out is the + * twenty lines of §2.3 plus an 85-bit decimal printer, and what it would put on + * screen is three twenty-six-digit integers, which tell a reader with no world + * to place them in exactly nothing. The reading that *would* mean something — + * the GPS position under a dataspace coordinate — is §9.7, and that is 96-digit + * decimal arithmetic with a hand-rolled deterministic trig series, carried so + * that every client agrees on where a place on Earth is. Amethyst has no Earth + * to agree about. So it reports the plane, which is one bit and says whether + * the thing was hidden somewhere real, and hands the coordinate itself over + * intact for a client that does. + */ +object CyberspaceCoordinate { + /** 32 bytes, as §7.6 and §8 write it. */ + const val HEX_LENGTH = 64 + + /** True when this is a coordinate at all: 64 lowercase hex characters. */ + fun isWellFormed(hex: String): Boolean { + if (hex.length != HEX_LENGTH) return false + for (c in hex) { + val ok = (c in '0'..'9') || (c in 'a'..'f') + if (!ok) return false + } + return true + } + + /** + * The plane a coordinate names, or null when the string is not one. + * + * Bit 0 of the 256-bit integer, which is the low bit of the last hex digit + * (§2.2: "Bit `0` (LSB): plane bit `P`"). + */ + fun planeOf(hex: String): CyberspacePlane? { + if (!isWellFormed(hex)) return null + val last = hex[HEX_LENGTH - 1] + val nibble = if (last in '0'..'9') last - '0' else last - 'a' + 10 + return if (nibble and 1 == 1) CyberspacePlane.IDEASPACE else CyberspacePlane.DATASPACE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt new file mode 100644 index 0000000000..845845ad94 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScale.kt @@ -0,0 +1,125 @@ +/* + * 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.quartz.cyberspace + +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.roundToLong + +/** + * How big one model unit actually is. + * + * DECK-0003 §1.6 says a `unit` of `u` means one model unit is `2^u` of the + * application's base unit, and leaves what that base unit *is* to the + * application. In Cyberspace it is the gibson, and `CYBERSPACE_V2.md` §9.2 and + * §9.7 fix the gibson at `2^-33` metres — about the width of a hydrogen atom. + * Those two facts together are the only thing standing between the integer in + * the payload and a size a reader can picture. + * + * They matter more than they look. Two objects with byte-identical geometry at + * `unit: 0` and `unit: 40` are a molecule and a mountain, and a client that + * prints "8 vertices · 12 faces" for both has told the reader nothing about + * the one field that separates them. The reference workshop puts this on + * screen as a scale ladder (`sno-core/scale.ts`); the numbers and the unit + * thresholds here are that file's, so the same object reads the same size in + * both. + * + * Nothing here is normative and nothing downstream depends on the exact + * wording: it is a length, rendered the way a person reads lengths. + */ +object CyberspaceScale { + /** The gibson in metres: `2^-33` (§9.2). */ + private const val GIBSON_METRES = 1.1641532182693481e-10 + + /** The largest `unit` DECK-0003 §1.8 allows, and the range these cover. */ + private const val MAX_UNIT = 84 + + private class Measure( + /** Used below this many metres. */ + val limit: Double, + val symbol: String, + val perMetre: Double, + ) + + // µ is the micro sign; written as an escape so the file stays + // unambiguous about which of the two lookalike code points it carries. + private val MEASURES = + listOf( + Measure(1e-9, "pm", 1e12), + Measure(1e-6, "nm", 1e9), + Measure(1e-3, "µm", 1e6), + Measure(1e-2, "mm", 1e3), + Measure(1e-1, "cm", 1e2), + Measure(1e3, "m", 1.0), + Measure(1e6, "km", 1e-3), + Measure(1e9, "Mm", 1e-6), + Measure(1.496e11, "AU", 1.0 / 1.496e11), + ) + + /** One model unit in metres, at this scale exponent. */ + fun unitInMetres(unit: Int): Double { + var metres = GIBSON_METRES + repeat(unit.coerceIn(0, MAX_UNIT)) { metres *= 2.0 } + return metres + } + + /** + * One model unit as a length a person reads: `"116 pm"`, `"1 m"`, `"2 AU"`. + * + * Three significant figures, in the largest measure the length fits inside, + * with nothing trailing that carries no information. + */ + fun describeUnit(unit: Int): String { + val metres = unitInMetres(unit) + val measure = MEASURES.firstOrNull { metres < it.limit } ?: MEASURES.last() + return significant(metres * measure.perMetre) + " " + measure.symbol + } + + /** + * A positive number to three significant figures, without a trailing zero + * or a trailing point. + * + * Built from a Long rather than a platform formatter, because there is no + * locale-independent one in common Kotlin and a size that renders as + * "1,91 µm" for half the world and "1.91 µm" for the other half is a + * number two readers cannot compare. + */ + private fun significant(value: Double): String { + if (value <= 0.0 || !value.isFinite()) return "0" + val exponent = floor(log10(value)).toInt() + + // Four digits or more: three of them carry the figure and the rest are + // zeros, so 1876 reads 1880 and 15060 reads 15100. + if (exponent >= 2) { + var step = 1L + repeat((exponent - 2).coerceAtMost(17)) { step *= 10L } + return ((value / step).roundToLong() * step).toString() + } + + val places = (2 - exponent).coerceAtMost(12) + var scale = 1L + repeat(places) { scale *= 10L } + val scaled = (value * scale).roundToLong() + val whole = scaled / scale + val fraction = (scaled % scale).toString().padStart(places, '0').trimEnd('0') + return if (fraction.isEmpty()) whole.toString() else "$whole.$fraction" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt index bc229e9efd..8429225d3a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt @@ -58,7 +58,7 @@ class SnoAvatarEvent( /** True when this identity asked for the default avatar, which owes no work. */ fun isDefaultAvatar(): Boolean = content.isBlank() - fun sno(): SnoResult = SnoParser.parse(content) + fun sno(fetchedPalette: SnoPalette? = null): SnoResult = SnoParser.parse(content, fetchedPalette) /** The `name` tag: the shape's name for humans. */ fun nameTag(): String? = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1) @@ -79,14 +79,14 @@ class SnoAvatarEvent( * committed 30 and an id carrying 20 gives 20, which clears 16 while * failing the second condition outright. */ - fun payment(): SnoAvatarPayment { + fun payment(fetchedPalette: SnoPalette? = null): SnoAvatarPayment { val zeros = PoWRankEvaluator.calculatePowRankOf(id) if (isDefaultAvatar()) { return SnoAvatarPayment(true, 0, null, zeros, SnoAvatarPayment.Reason.DEFAULT_AVATAR) } val payload = - sno().payloadOrNull() + sno(fetchedPalette).payloadOrNull() ?: return SnoAvatarPayment(false, 0, null, zeros, SnoAvatarPayment.Reason.NOT_AN_AVATAR) val required = SnoAvatarWork.required(payload) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt index 84dcb2ebbe..865fba4816 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParser.kt @@ -32,6 +32,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull import kotlin.math.roundToInt /** What [SnoParser] made of a payload. */ @@ -124,27 +125,32 @@ object SnoParser { // before it can be assembled. Absent ticks means every position is whole. val ticks = expandTicks(root["ticks"], vertices.size) ?: return SnoResult.Invalid("7", "ticks do not expand to one remainder per vertex") - // Rule 6, and the position bound this reader adds (see MAX_TICKS_FROM_ORIGIN). + // Rule 6. §1.8 puts the position bound on publishers — "a publisher MUST + // NOT write a vertex further than 64 model units from the origin" — and + // lets a reader either reject such a payload or "repair it by growing + // the extent". Both references repair: `sno-core`'s `fromPayload` does + // not check a position at all and lets `neededExtent` grow past its own + // `MAX_EXTENT`, and `sno-reference.py` does not check either. So this + // repairs too, under rule 9 below, and the only bound left here is the + // one the lattice itself imposes (see MAX_TICKS_FROM_ORIGIN). val positions = IntArray(vertices.size * 3) for (i in 0 until vertices.size) { val triple = vertices[i] as? JsonArray ?: return SnoResult.Invalid("6", "vertex $i is not an array") if (triple.size != 3) return SnoResult.Invalid("6", "vertex $i is not three integers") for (axis in 0..2) { - val whole = triple[axis].asIntOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers") - // Range-checked rather than `abs`-checked, because abs(Int.MIN_VALUE) - // is itself negative: a vertex of -2147483648 would pass the bound, - // overflow `* 120` to 0, and parse as a silently rewritten coordinate. - if (whole > SnoPayload.MAX_EXTENT || whole < -SnoPayload.MAX_EXTENT) { - return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") - } + // Read and multiplied as a Long, because the product is what has + // to fit: a whole of Int.MIN_VALUE would otherwise overflow `* + // 120` to 0 and parse as a silently rewritten coordinate, and + // `abs` would not catch it either, being its own negative there. + val whole = triple[axis].asLongOrNull() ?: return SnoResult.Invalid("6", "vertex $i is not three integers") val total = whole * SnoPayload.TICKS_PER_UNIT + ticks[i * 3 + axis] if (total > SnoPayload.MAX_TICKS_FROM_ORIGIN || total < -SnoPayload.MAX_TICKS_FROM_ORIGIN) { - return SnoResult.Invalid("bound", "vertex $i lies beyond ${SnoPayload.MAX_EXTENT} units from the origin") + return SnoResult.Invalid("bound", "vertex $i lies past what the lattice can hold exactly") } // §2: a `v: 1` payload has +Z away from the viewer, so its Z is // negated on read, which renders the object exactly as its author // built it. A `v: 2` payload is read as written. - positions[i * 3 + axis] = if (axis == 2 && version == 1) -total else total + positions[i * 3 + axis] = (if (axis == 2 && version == 1) -total else total).toInt() } } @@ -206,7 +212,11 @@ object SnoParser { // Rule 9. `extent` is repaired rather than validated: out of range becomes // the default, then it grows to contain the data. The data wins and the // hint is corrected, so an object is never refused for disagreeing with - // its own extent (§1.8). + // its own extent (§1.8) — and, since rule 6 no longer turns away a + // vertex past 64 units, this is also where such a vertex is repaired. + // The grown extent may therefore exceed MAX_EXTENT, exactly as the + // reference's `neededExtent` does; MAX_EXTENT bounds what a payload may + // *declare*, not what its geometry may need. val declared = root["extent"].asIntOrNull() val repaired = if (declared != null && declared >= SnoPayload.MIN_EXTENT && declared <= SnoPayload.MAX_EXTENT) declared else SnoPayload.DEFAULT_EXTENT val extent = grownExtent(repaired, positions) @@ -415,6 +425,17 @@ object SnoParser { return (clamped * 255.0).roundToInt() } + /** + * A JSON integer as a Long, or null when the token is not one — including a + * whole number too large for a Long, which is not a coordinate anybody can + * hold and is rejected the same way a string would be. + */ + private fun JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + return primitive.longOrNull + } + private fun JsonElement?.asIntOrNull(): Int? { val primitive = this as? JsonPrimitive ?: return null if (primitive.isString) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt index db7bdc4cdd..c1341e707c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoPayload.kt @@ -49,7 +49,11 @@ class SnoPayload( val name: String, /** Scale exponent: one model unit is `2^unit` of the application's base unit (§1.6). */ val unit: Int, - /** Grid half-width in model units, repaired and grown to contain the data (§1.8). */ + /** + * Grid half-width in model units, repaired and grown to contain the data + * (§1.8). A declared extent is only honoured inside `1..`[MAX_EXTENT]; the + * grown one has no ceiling, because the geometry is what it is. + */ val extent: Int, val mode: SnoMode, /** Three total-tick coordinates per vertex: X, Y, Z. */ @@ -91,20 +95,25 @@ class SnoPayload( /** * The farthest a vertex may lie from the origin on any axis, in ticks. * - * DECK-0003 §1.8 states this bound as an obligation on publishers and - * lets a reader "reject such a payload" or "repair it by growing the - * extent"; §8's third open question admits that leaving it off readers - * is unsafe as a general rule, since "a payload of 512 vertices at 2^50 - * units is valid under this text and will produce a grid no renderer - * wants". Neither reference implementation bounds it — ONOSENDAI grows - * the extent without a ceiling, and `sno-reference.py` does not check. + * This is a representation limit and not a rule of the format. §1.8 + * puts the position bound on publishers — "a publisher MUST NOT write a + * vertex further than `64` model units (`7680` ticks) from the origin + * on any axis" — and offers a reader two ways to answer one that does: + * "A reader MAY reject such a payload and MAY instead repair it by + * growing the extent." Both references repair. `sno-core`'s + * `fromPayload` never looks at a position's magnitude and lets + * `neededExtent` grow the extent past its own `MAX_EXTENT`, and + * `sno-reference.py` does not check either. So Amethyst repairs, and an + * object that reaches past the grid is drawn rather than refused; what + * is left here is only the point at which the lattice stops being able + * to say where a vertex is. * - * Amethyst rejects, which is the deliberate divergence, because it - * draws strangers' events in a feed and because `whole * 120` overflows - * an Int well before 2^50 units, so the bound is a representation limit - * here and not only a matter of taste. Nothing on the network is near - * it: the widest object published so far reaches 8 units. + * That point is `Int.MAX_VALUE` ticks, about 17.9 million model units, + * because a position is a total tick count in an Int — the exact + * integer §1.2 requires every weld, comparison and hash to run on. Past + * it there is no coordinate to repair, only one that would wrap, so + * that is a rejection rather than a judgement about size. */ - const val MAX_TICKS_FROM_ORIGIN = MAX_EXTENT * TICKS_PER_UNIT + const val MAX_TICKS_FROM_ORIGIN = Int.MAX_VALUE.toLong() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt index d33af0b6bf..b8c80f7ad6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.cyberspace.deck0003Sno import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.cyberspace.CyberspaceCoordinate +import com.vitorpamplona.quartz.cyberspace.CyberspacePlane import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -86,6 +88,17 @@ class SnoShardEvent( */ fun coordinate(): String? = tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1) + /** + * The plane the claimed coordinate lies in, or null when there is no `C` + * tag or it is not a coordinate. + * + * One bit of §2.2, and the only part of a coordinate a client with no world + * can turn into something a reader understands: whether the shard was + * hidden at a place on Earth or at one that has no physical counterpart. + * See [CyberspaceCoordinate] for why it stops there. + */ + fun plane(): CyberspacePlane? = coordinate()?.let { CyberspaceCoordinate.planeOf(it) } + companion object { const val KIND = 3330 } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt new file mode 100644 index 0000000000..102db79c64 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.cyberspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** §2.2's plane bit, and what counts as a coordinate at all. */ +class CyberspaceCoordinateTest { + private val zeros = "0".repeat(64) + + @Test + fun thePlaneIsTheLeastSignificantBit() { + // §2.2: "Bit 0 (LSB): plane bit P", and §2.4: 0 is dataspace, 1 is + // ideaspace. The LSB of a hex string is the low bit of its last digit, + // so every odd digit is ideaspace and every even one dataspace. + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros)) + assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "1")) + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "e")) + assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "f")) + // The other 255 bits are the axes and say nothing about the plane. + assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf("f".repeat(63) + "0")) + } + + @Test + fun anythingThatIsNotThirtyTwoBytesOfLowercaseHexIsNotACoordinate() { + assertTrue(CyberspaceCoordinate.isWellFormed(zeros)) + assertFalse(CyberspaceCoordinate.isWellFormed("")) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1))) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros + "0")) + // §7.6 and §8 both say lowercase; an uppercase one is somebody else's + // convention and is not silently accepted. + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "A")) + assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "g")) + assertNull(CyberspaceCoordinate.planeOf("not a coordinate")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt new file mode 100644 index 0000000000..61b6a4278b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceScaleTest.kt @@ -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.quartz.cyberspace + +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * One model unit as a length, checked against the reference the whole way up. + * + * Every value DECK-0003 §1.8 permits for `unit` — 0 to 84 — is here, taken + * from `sno-core/scale.ts` run over the same range, because the point of the + * readout is that the same object reads the same size in both clients. The + * boundaries are where the two could drift: where the measure changes, where a + * figure rounds up past its own magnitude, and `unit: 28`, which is 3.125 cm + * exactly — a tie, and the one entry where a language's default rounding shows. + * ECMAScript's `toPrecision` takes the larger, so 3.13, and `roundToLong` here + * rounds half up and agrees. + */ +class CyberspaceScaleTest { + private val reference = + ( + "116 pm,233 pm,466 pm,931 pm,1.86 nm,3.73 nm,7.45 nm,14.9 nm,29.8 nm,59.6 nm,119 nm," + + "238 nm,477 nm,954 nm,1.91 µm,3.81 µm,7.63 µm,15.3 µm,30.5 µm,61 µm," + + "122 µm,244 µm,488 µm,977 µm,1.95 mm,3.91 mm,7.81 mm,1.56 cm,3.13 cm,6.25 cm," + + "0.125 m,0.25 m,0.5 m,1 m,2 m,4 m,8 m,16 m,32 m,64 m,128 m,256 m,512 m,1.02 km,2.05 km," + + "4.1 km,8.19 km,16.4 km,32.8 km,65.5 km,131 km,262 km,524 km,1.05 Mm,2.1 Mm,4.19 Mm," + + "8.39 Mm,16.8 Mm,33.6 Mm,67.1 Mm,134 Mm,268 Mm,537 Mm,0.00718 AU,0.0144 AU,0.0287 AU," + + "0.0574 AU,0.115 AU,0.23 AU,0.459 AU,0.919 AU,1.84 AU,3.67 AU,7.35 AU,14.7 AU,29.4 AU," + + "58.8 AU,118 AU,235 AU,470 AU,941 AU,1880 AU,3760 AU,7530 AU,15100 AU" + ).split(",") + + @Test + fun everyUnitReadsAsTheReferenceReadsIt() { + assertEquals(SnoPayload.MAX_UNIT + 1, reference.size, "the table should cover every unit the deck allows") + for (unit in reference.indices) { + assertEquals(reference[unit], CyberspaceScale.describeUnit(unit), "unit $unit") + } + } + + @Test + fun theAnchorsAreTheOnesTheSpecStates() { + // §9.3: "Cantor Height 33 = 1 meter", and 34 is the two metres the + // scale was calibrated on. + assertEquals("1 m", CyberspaceScale.describeUnit(33)) + assertEquals("2 m", CyberspaceScale.describeUnit(34)) + // §9.2: a gibson is 2^-33 m, "roughly the size of a hydrogen atom". + assertEquals("116 pm", CyberspaceScale.describeUnit(0)) + assertEquals(1.0, CyberspaceScale.unitInMetres(33), 1e-12) + } + + @Test + fun aUnitOutsideTheDeckIsHeldAtTheEdgeRatherThanOverflowing() { + // The parser rejects these long before here, so this is only about not + // producing a nonsense string if some other caller asks. + assertEquals(CyberspaceScale.describeUnit(0), CyberspaceScale.describeUnit(-5)) + assertEquals(CyberspaceScale.describeUnit(SnoPayload.MAX_UNIT), CyberspaceScale.describeUnit(9999)) + assertTrue(CyberspaceScale.unitInMetres(9999).isFinite()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt index 808c4b2d63..c6369615ec 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoParserAcceptanceTest.kt @@ -293,13 +293,16 @@ class SnoParserAcceptanceTest { } @Test - fun aVertexBeyondTheBoundIsRefused() { - // Amethyst's own rule, and a deliberate divergence: §1.8 puts the bound on - // publishers and lets readers repair instead, which neither reference - // implementation does either way. - val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]")) - assertTrue(result is SnoResult.Invalid) - assertEquals("bound", result.rule) + fun aVertexBeyondTheGridGrowsItRatherThanFailing() { + // §1.8 puts the position bound on publishers — a vertex further than 64 + // model units from the origin is theirs not to write — and gives a + // reader the choice: "A reader MAY reject such a payload and MAY + // instead repair it by growing the extent." Both references repair, so + // this does too, and the repair is visible in the extent. + val payload = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[65,2,1]]")).payloadOrNull() + assertNotNull(payload) + assertEquals(65, payload.extent, "the extent should have grown past MAX_EXTENT to hold the vertex") + assertEquals(65 * SnoPayload.TICKS_PER_UNIT, payload.tickAt(3, 0), "and the coordinate should be untouched") } @Test @@ -307,6 +310,28 @@ class SnoParserAcceptanceTest { assertNotNull(SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[64,2,1]]")).payloadOrNull()) } + @Test + fun aVertexPastWhatTheLatticeHoldsIsStillRefused() { + // The one bound left is the lattice's own: a position is a total tick + // count in an Int, and past Int.MAX_VALUE ticks there is no coordinate + // to repair, only one that would wrap. + val justInside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895697,2,1]]")).payloadOrNull() + assertNotNull(justInside) + assertEquals(17895697 * SnoPayload.TICKS_PER_UNIT, justInside.tickAt(3, 0)) + + val justOutside = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[17895698,2,1]]")) + assertTrue(justOutside is SnoResult.Invalid) + assertEquals("bound", justOutside.rule) + } + + @Test + fun aWholeTooLargeForALongIsRefusedRatherThanRounded() { + // A JSON integer has no width. One past a Long would come back as a + // Double through a lazier reader and land somewhere near the origin. + val result = SnoParser.parse(payload(vertices = "[[0,0,0],[2,0,0],[1,0,2],[99999999999999999999,2,1]]")) + assertTrue(result is SnoResult.Invalid) + } + private fun payload( version: Int = 2, name: String = "tetra", From 0423a0331bc0690eba9f93695416b4cda279c9cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:09:02 +0000 Subject: [PATCH 21/38] =?UTF-8?q?docs:=20what=20=C2=A77=20actually=20costs?= =?UTF-8?q?,=20and=20why=20position=20is=20not=20the=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the record on kind 33330 bags. The plan said opening one needs "§7.4 discovery scanning", and D2 and SnoShardEvent's KDoc both leaned on Amethyst having no position in cyberspace to stand and 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; the half that finds things is a hint naming an aligned box and a sweep of the candidate regions inside it, priced at 2^((Hx-h)+(Hy-h)+(Hz-h)) and nothing else. A client with no avatar could open a hinted bag. So the reason is the work, and the plan now carries it measured rather than asserted. A region key is three Cantor axis roots, each an O(2^h) fold of BigInts that double in width every level. The spec publishes its own figures in §7.7 — 1.3 ms per key at height 8, 30 ms at h12, 816 ms at h16 on one desktop core — and measuring the same fold here gives 4 ms, 92 ms and 2.7 s per axis at h12, h16 and h20, which brackets them. Growth is about 2.2x per height in both, not 2, because a pairing doubles the leaves and the operand width together. Extrapolated, the spec's human scale at h34 is days per key and a 174 GB root against §9.3's stated 185 GB, which is why cyberspace-core caps at height 20 and throws above it. Also reconciled the two rendering runs in §4.2b and §4.2d, which quote 4.86 ms and 6.81 ms for the same object at 1080px. Both are real, minutes apart on a shared machine; the note says to read ratios within a run rather than absolutes across them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 60 +++++++++++++++++-- .../cyberspace/deck0003Sno/SnoShardEvent.kt | 19 ++++-- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index b3a08da011..a9c1ab4376 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -162,11 +162,55 @@ 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.6 / §8.6 — the bag, and why `kind 3330` is out of reach +### 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 @@ -513,6 +557,10 @@ the first pass shipped it half-built and capped: 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 @@ -796,10 +844,12 @@ are where the risk is, and they are testable without a device. 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. What is *not* implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext - keyed to a Cyberspace region, so reading one means §2 coordinates, §7.2 key - derivation and §7.4 discovery scanning. That is the protocol, not a renderer, and - §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in the - bag". + keyed to a Cyberspace region, so reading one means §2 coordinates, §4's Cantor + trees, §7.2 key derivation and a §7.7 sweep. That is the protocol, not a renderer, + and §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in + the bag". Note that "we have no position in cyberspace" is *not* the reason — + §7.7 is explicit that a seeker's position never enters the cost of a sweep. The + reason is §2.2's: the compute, and the protocol surface behind it. **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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt index b8c80f7ad6..2fef0a61fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -42,12 +42,19 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * **Most shards are not reachable, and that is by design.** A shard is an item * inside a `kind 33330` bag (`CYBERSPACE_V2.md` §7.6) whose payload is * AES-256-GCM ciphertext keyed to the region it was hidden in, so finding one - * means deriving that region's key: the coordinate system of §2, the key - * derivation of §7.2 and the discovery scanning of §7.4. Amethyst implements - * none of that and a reader without the key sees base64 and nothing else — §7.6 - * is explicit that a failed decryption "MUST NOT be treated as an error in the - * bag". What this class reads is a shard handed over directly: quoted in a - * note, or fetched by id. + * means deriving that region's key: the coordinate system of §2, the Cantor + * trees of §4, the derivation of §7.2 and a sweep of the hinted box of §7.7. + * + * Not for want of somewhere to stand — §7.7 is explicit that "the seeker's own + * position never enters this cost, because §7.1 makes looking and walking + * equivalent", so a client with no avatar could open a hinted bag. What stops + * it is the work: a key is three `O(2^h)` folds of BigInts that double in width + * every level, which the spec measures at 816 ms per key at height 16 on a + * desktop core and which grows about 2.2x per height above that. Amethyst + * implements none of it and a reader without the key sees base64 and nothing + * else — §7.6 is explicit that a failed decryption "MUST NOT be treated as an + * error in the bag". What this class reads is a shard handed over directly: + * quoted in a note, or fetched by id. * * **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its * `pubkey` is a claim and a client MUST NOT present it as verified authorship. From 4b793ec61099eb009530b74760e7ed183b1f384a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:10:39 +0000 Subject: [PATCH 22/38] feat: cyberspace coordinates, and a plan for opening region bags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses SNO's D2. §7.7 turns out to say that a seeker's position never enters the cost of finding a bag — "looking and walking are equivalent" — so a client with no avatar can open one; what was left was whether the work is affordable, and measured on this JVM a region key is 1.2 ms at height 8 against the spec's own 1.3 ms on a desktop core. A sweep also decomposes per axis, so a box of gap G costs 3*2^(G/3) tree builds and 2^G combines rather than 2^G whole keys. The corner anyone actually hides in is cheap. quartz/plans/2026-09-22-cyberspace-region-bags.md carries the design: three layers (protocol, amy verbs, a card), the measured cost table the budget comes from, and the rule that falls out of it — price the hint before building a single tree, because a bag can carry a gap-30 hint precisely to burn a day of a reader's battery. Every sweep is a tap, including the gap-0 case the spec calls a destination rather than a search, so a reader never learns that some bags open themselves and a hostile one cannot hide inside that expectation. First step lands here: §2.2 in full. The three 85-bit axes are interleaved XYZXYZ from the top with the plane bit at the bottom, which is what makes nearby coordinates share a prefix and therefore what makes an aligned Cantor subtree a region everyone agrees on without communicating. An axis splits at 64 into a 21-bit high half and an unsigned low Long — enough for the layout, the plane, §10's sector shift and alignedBase, which is exactly what a sweep enumerates, and no big integer until §4's trees need one. Pinned entirely by vectors the spec publishes: the six GPS consensus locks of §9.8 round-tripped, the ideaspace point of §7.7 decoded to the axes that section states in prose (2^84 + 12345, 3*2^80 + 777, 2^85 - 1 - 4242), its sector tags, and london aligned to height 11 reproducing the london_h5_box11 hint coordinate byte for byte. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../2026-09-22-cyberspace-region-bags.md | 205 ++++++++++++++++++ quartz/plans/README.md | 1 + .../quartz/cyberspace/CyberspaceCoordinate.kt | 198 +++++++++++++++-- .../cyberspace/CyberspaceCoordinateTest.kt | 118 +++++++++- 4 files changed, 497 insertions(+), 25 deletions(-) create mode 100644 quartz/plans/2026-09-22-cyberspace-region-bags.md diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md new file mode 100644 index 0000000000..b72bf98860 --- /dev/null +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -0,0 +1,205 @@ +# 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 # x, y, z, plane +amy cyberspace region # region_n, key, lookup_id +amy cyberspace hint # box, candidates, estimated cost +amy cyberspace sweep [--budget] # the lookup_ids, or the key when found +amy cyberspace open --key # 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 `expect`/`actual` bignum and the height refusal. + `amy cyberspace region`, diffed against `cyberspace-cli`. +3. **Hints.** Parse, validate, price. `amy cyberspace hint`. The three golden vectors. +4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace + open`, round-tripped against the reference CLI's `encrypt`. +5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. +6. **The card**, last, once every number it quotes is measured on a device. + +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. diff --git a/quartz/plans/README.md b/quartz/plans/README.md index da40e77b17..e56dc4d372 100644 --- a/quartz/plans/README.md +++ b/quartz/plans/README.md @@ -11,6 +11,7 @@ _Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 | [2026-07-04-small-req-floor.md](2026-07-04-small-req-floor.md) | Small-REQ dispatch floor: decomposed, inline fast path tried and reverted (no wire-level win); floor is transport-side. | | [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-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 | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt index 4fbbc0445d..6d351690ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinate.kt @@ -30,29 +30,94 @@ enum class CyberspacePlane { } /** - * The little of `CYBERSPACE_V2.md` §2 a client without a world can use. + * One 85-bit axis value, split at 64 bits because no primitive holds it. * - * A coordinate is a 256-bit integer, written as 32 lowercase bytes of hex: the - * three 85-bit axes interleaved `XYZXYZ…` with the plane bit in the least - * significant position (§2.2). An item hidden at a place carries one in a `C` - * tag (§7.6). + * [high] is bits 64 to 84, so at most 21 bits and always positive. [low] is bits 0 + * to 63 as a raw Long, which is to say unsigned: an axis near the top of its range + * has its sign bit set and is not a negative number. Nothing here compares or adds + * axes, so that distinction stays contained; the moment arithmetic is needed — the + * Cantor trees of §4 — this becomes a big integer at the boundary and nowhere + * earlier, the same rule the SNO lattice follows. + */ +class CyberspaceAxis( + val high: Long, + val low: Long, +) { + /** + * The sector this axis falls in: the axis shifted right by 30 (§10). + * + * 85 bits less 30 is 55, so a sector index is one of the few things about a + * coordinate that does fit a Long, which is why the network writes them as + * decimal `X`, `Y`, `Z` and `S` tag values and why they are worth extracting + * when the axis itself is not. + */ + fun sector(): Long = (high shl (Long.SIZE_BITS - CyberspaceCoordinate.SECTOR_SHIFT)) or (low ushr CyberspaceCoordinate.SECTOR_SHIFT) + + /** + * This axis with its low [height] bits cleared: the base of the aligned subtree + * of that height containing it (§4.5, `base = (v >> h) << h`). + */ + fun alignedBase(height: Int): CyberspaceAxis { + if (height <= 0) return this + if (height >= CyberspaceCoordinate.AXIS_BITS) return CyberspaceAxis(0L, 0L) + if (height >= Long.SIZE_BITS) { + val keep = height - Long.SIZE_BITS + return CyberspaceAxis((high shr keep) shl keep, 0L) + } + return CyberspaceAxis(high, (low ushr height) shl height) + } + + override fun equals(other: Any?): Boolean = other is CyberspaceAxis && other.high == high && other.low == low + + override fun hashCode(): Int = high.hashCode() * 31 + low.hashCode() +} + +/** A place: three axes and the plane they are in (§2.1). */ +class CyberspacePoint( + val x: CyberspaceAxis, + val y: CyberspaceAxis, + val z: CyberspaceAxis, + val plane: CyberspacePlane, +) { + /** + * The `S` tag's value: the three sector indices joined by hyphens, in axis + * order, exactly as §7.7's golden vectors write it. + */ + fun sector(): String = "${x.sector()}-${y.sector()}-${z.sector()}" + + fun alignedBase(height: Int) = CyberspacePoint(x.alignedBase(height), y.alignedBase(height), z.alignedBase(height), plane) + + override fun equals(other: Any?): Boolean = other is CyberspacePoint && other.x == x && other.y == y && other.z == z && other.plane == plane + + override fun hashCode(): Int = ((x.hashCode() * 31 + y.hashCode()) * 31 + z.hashCode()) * 31 + plane.ordinal +} + +/** + * `CYBERSPACE_V2.md` §2: a place as a 256-bit integer, written as 32 lowercase + * bytes of hex. * - * Only the plane bit is read here, and that is a deliberate stopping point - * rather than a half-finished decoder. Pulling X, Y and Z back out is the - * twenty lines of §2.3 plus an 85-bit decimal printer, and what it would put on - * screen is three twenty-six-digit integers, which tell a reader with no world - * to place them in exactly nothing. The reading that *would* mean something — - * the GPS position under a dataspace coordinate — is §9.7, and that is 96-digit - * decimal arithmetic with a hand-rolled deterministic trig series, carried so - * that every client agrees on where a place on Earth is. Amethyst has no Earth - * to agree about. So it reports the plane, which is one bit and says whether - * the thing was hidden somewhere real, and hands the coordinate itself over - * intact for a client that does. + * The three 85-bit axes are **interleaved** rather than packed, `XYZXYZ…` from the + * top with the plane bit at the bottom (§2.2), so that coordinates which are close + * in space share a bit prefix — which is what makes an aligned Cantor subtree (§4.5) + * a region everyone agrees on without communicating, and therefore what makes §7's + * location-based keys work at all. + * + * Bit 0 is the plane. Bits `1, 4, 7, …` are Z, bits `2, 5, 8, …` are Y and bits + * `3, 6, 9, …` are X, each 85 of them, which is `85 × 3 + 1 = 256` exactly. + * + * An item hidden at a place carries one of these in a `C` tag (§7.6); a hint names + * an aligned box with one (§7.7). */ object CyberspaceCoordinate { /** 32 bytes, as §7.6 and §8 write it. */ const val HEX_LENGTH = 64 + /** Bits per axis (§2.1). */ + const val AXIS_BITS = 85 + + /** A sector is an axis shifted right by this (§10). */ + const val SECTOR_SHIFT = 30 + /** True when this is a coordinate at all: 64 lowercase hex characters. */ fun isWellFormed(hex: String): Boolean { if (hex.length != HEX_LENGTH) return false @@ -66,13 +131,104 @@ object CyberspaceCoordinate { /** * The plane a coordinate names, or null when the string is not one. * - * Bit 0 of the 256-bit integer, which is the low bit of the last hex digit - * (§2.2: "Bit `0` (LSB): plane bit `P`"). + * Bit 0 of the 256-bit integer, which is the low bit of the last hex digit. + * Kept separate from [decode] because it is the one part of a coordinate a + * client with no world can use on its own, and it costs a character. */ fun planeOf(hex: String): CyberspacePlane? { if (!isWellFormed(hex)) return null - val last = hex[HEX_LENGTH - 1] - val nibble = if (last in '0'..'9') last - '0' else last - 'a' + 10 - return if (nibble and 1 == 1) CyberspacePlane.IDEASPACE else CyberspacePlane.DATASPACE + return if (nibble(hex, HEX_LENGTH - 1) and 1 == 1) CyberspacePlane.IDEASPACE else CyberspacePlane.DATASPACE + } + + /** The three axes and the plane, or null when the string is not a coordinate. */ + fun decode(hex: String): CyberspacePoint? { + if (!isWellFormed(hex)) return null + + var xHigh = 0L + var xLow = 0L + var yHigh = 0L + var yLow = 0L + var zHigh = 0L + var zLow = 0L + + for (i in 0 until AXIS_BITS) { + val z = bit(hex, 1 + i * 3) + val y = bit(hex, 2 + i * 3) + val x = bit(hex, 3 + i * 3) + if (i < Long.SIZE_BITS) { + xLow = xLow or (x shl i) + yLow = yLow or (y shl i) + zLow = zLow or (z shl i) + } else { + val at = i - Long.SIZE_BITS + xHigh = xHigh or (x shl at) + yHigh = yHigh or (y shl at) + zHigh = zHigh or (z shl at) + } + } + + return CyberspacePoint( + CyberspaceAxis(xHigh, xLow), + CyberspaceAxis(yHigh, yLow), + CyberspaceAxis(zHigh, zLow), + planeOf(hex)!!, + ) + } + + /** The coordinate a point writes as, the inverse of [decode]. */ + fun encode(point: CyberspacePoint): String { + val nibbles = IntArray(HEX_LENGTH) + if (point.plane == CyberspacePlane.IDEASPACE) nibbles[HEX_LENGTH - 1] = 1 + + for (i in 0 until AXIS_BITS) { + setBit(nibbles, 1 + i * 3, axisBit(point.z, i)) + setBit(nibbles, 2 + i * 3, axisBit(point.y, i)) + setBit(nibbles, 3 + i * 3, axisBit(point.x, i)) + } + + val out = StringBuilder(HEX_LENGTH) + for (n in nibbles) out.append(HEX[n]) + return out.toString() + } + + private const val HEX = "0123456789abcdef" + + /** Bit [i] of an axis, as 0 or 1. */ + private fun axisBit( + axis: CyberspaceAxis, + i: Int, + ): Long = + if (i < Long.SIZE_BITS) { + (axis.low ushr i) and 1L + } else { + (axis.high ushr (i - Long.SIZE_BITS)) and 1L + } + + /** + * Bit [n] of the 256-bit integer the hex spells, counting from the least + * significant. The string is big-endian, so bit `n` lives in the nibble `n / 4` + * places from the end, at position `n % 4` inside it. + */ + private fun bit( + hex: String, + n: Int, + ): Long = ((nibble(hex, HEX_LENGTH - 1 - n / 4) ushr (n % 4)) and 1).toLong() + + private fun setBit( + nibbles: IntArray, + n: Int, + value: Long, + ) { + if (value == 0L) return + val at = HEX_LENGTH - 1 - n / 4 + nibbles[at] = nibbles[at] or (1 shl (n % 4)) + } + + private fun nibble( + hex: String, + at: Int, + ): Int { + val c = hex[at] + return if (c in '0'..'9') c - '0' else c - 'a' + 10 } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt index 102db79c64..1d4c6a0425 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceCoordinateTest.kt @@ -23,24 +23,133 @@ package com.vitorpamplona.quartz.cyberspace import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -/** §2.2's plane bit, and what counts as a coordinate at all. */ +/** + * §2.2's interleave, against the spec's own consensus locks. + * + * Every vector here is published: the six GPS golden vectors of §9.8 and the three + * hint vectors of §7.7, which between them pin the bit layout, the plane bit, the + * sector shift of §10 and the aligned base of §4.5. None of them was produced by + * this code. + */ class CyberspaceCoordinateTest { private val zeros = "0".repeat(64) + /** §9.8, spec version `2026-03-16-h34-corrected`, altitude 0. */ + private val goldenVectors = + mapOf( + "origin_equator_prime" to "e000000000000000000001200041040208048040000000000000000000000000", + "equator_east_90" to "e000000000000000000000480010410082012010000000000000000000000000", + "equator_west_90" to "c492492492492492492492012482082410480490000000000000000000000000", + "north_pole" to "e000000000000000000000900004924920020000820000920100824920800020", + "london" to "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", + "nyc" to "c4924924924924924924921f79235dae293ada913e78294253a235239a332854", + ) + + @Test + fun everyGoldenVectorSurvivesTheRoundTrip() { + // The mapping that produced these is §9.7's, which this does not implement. + // What it does implement is §2.2, and a coordinate that decodes to three + // axes and back to a different string has the bit layout wrong. + for ((name, hex) in goldenVectors) { + val point = CyberspaceCoordinate.decode(hex) + assertNotNull(point, name) + assertEquals(hex, CyberspaceCoordinate.encode(point), name) + } + } + + @Test + fun theKnownPointOfTheIdeaspaceVectorDecodesToItsStatedAxes() { + // §7.7 states this one's axes outright: "The ideaspace point is + // x = 2^84 + 12345, y = 3 * 2^80 + 777, z = 2^85 - 1 - 4242 on plane 1." + val point = CyberspaceCoordinate.decode("a4b64924924924924924924924924924924924924924924924924d84b60d9c8f") + assertNotNull(point) + assertEquals(CyberspacePlane.IDEASPACE, point.plane) + + // 2^84 is bit 84, the top bit of an 85-bit axis: high = 2^20, low = 12345. + assertEquals(1L shl 20, point.x.high) + assertEquals(12345L, point.x.low) + // 3 * 2^80 sets bits 80 and 81, which are bits 16 and 17 of the high half. + assertEquals((1L shl 16) or (1L shl 17), point.y.high) + assertEquals(777L, point.y.low) + // 2^85 - 1 is every bit set; less 4242. + assertEquals((1L shl 21) - 1, point.z.high) + assertEquals(-1L - 4242L, point.z.low, "the low half runs unsigned and this one has its top bit set") + } + + @Test + fun theSectorsAreTheOnesTheHintVectorsCarry() { + // §7.7's `london_h5_box11`: the hint coordinate and the sector tags a bag + // carrying it MUST also carry. A sector is the axis shifted right by 30 + // (§10), which is the one part of an axis that fits a Long. + val london = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000") + assertNotNull(london) + assertEquals(18014398541305938L, london.x.sector()) + assertEquals(18014398549232983L, london.y.sector()) + assertEquals(18014398509410999L, london.z.sector()) + assertEquals("18014398541305938-18014398549232983-18014398509410999", london.sector()) + + // `ideaspace_h8_y_open` fixes X and Z only; its Y height of 40 is above the + // sector shift, so no `Y` tag, which is a rule about the hint rather than + // about the axis — the axis still has a sector and this checks the two the + // vector publishes. + val idea = CyberspaceCoordinate.decode("a4b64924924924924924924924924924924924924924924924924d8000000001") + assertNotNull(idea) + assertEquals(18014398509481984L, idea.x.sector()) + assertEquals(36028797018963967L, idea.z.sector()) + } + + @Test + fun theHintCoordinateIsThePointWithItsLowBitsCleared() { + // §7.7: the hint's coordinate "MUST be the aligned base: the low `H` bits of + // each axis MUST be zero". `london_h5_box11` is the §9.8 london vector at + // heights 11, 11, 11, so aligning london to 11 must reproduce it exactly. + val london = CyberspaceCoordinate.decode(goldenVectors.getValue("london")) + assertNotNull(london) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", + CyberspaceCoordinate.encode(london.alignedBase(11)), + ) + // And `london_h5_x_exact` is the same point at heights 5, 14, 14 — an + // unequal box, so it is not a single alignedBase call, but X aligned to 5 + // is what its exact axis claims. + assertEquals(london.x.alignedBase(5), CyberspaceCoordinate.decode(goldenVectors.getValue("london"))!!.x.alignedBase(5)) + } + + @Test + fun aligningPastTheAxisEmptiesIt() { + val london = CyberspaceCoordinate.decode(goldenVectors.getValue("london"))!! + // §7.7: "A height of 85 leaves an axis open: the base is 0". + assertEquals(0L, london.x.alignedBase(85).high) + assertEquals(0L, london.x.alignedBase(85).low) + // The boundary at 64, where an axis stops fitting one Long. + assertEquals(0L, london.x.alignedBase(64).low) + assertEquals(london.x.high, london.x.alignedBase(64).high) + assertEquals(london.x, london.x.alignedBase(0)) + } + @Test fun thePlaneIsTheLeastSignificantBit() { - // §2.2: "Bit 0 (LSB): plane bit P", and §2.4: 0 is dataspace, 1 is - // ideaspace. The LSB of a hex string is the low bit of its last digit, - // so every odd digit is ideaspace and every even one dataspace. + // §2.2: "Bit 0 (LSB): plane bit P", and §2.4: 0 is dataspace, 1 ideaspace. assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros)) assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "1")) assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "e")) assertEquals(CyberspacePlane.IDEASPACE, CyberspaceCoordinate.planeOf(zeros.dropLast(1) + "f")) // The other 255 bits are the axes and say nothing about the plane. assertEquals(CyberspacePlane.DATASPACE, CyberspaceCoordinate.planeOf("f".repeat(63) + "0")) + // And the plane survives a round trip on its own. + assertEquals("f".repeat(63) + "1", CyberspaceCoordinate.encode(CyberspaceCoordinate.decode("f".repeat(63) + "1")!!)) + } + + @Test + fun theOriginIsEveryAxisAtZero() { + val point = CyberspaceCoordinate.decode(zeros) + assertNotNull(point) + assertEquals(0L, point.x.high + point.x.low + point.y.high + point.y.low + point.z.high + point.z.low) + assertEquals(zeros, CyberspaceCoordinate.encode(point)) } @Test @@ -54,5 +163,6 @@ class CyberspaceCoordinateTest { assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "A")) assertFalse(CyberspaceCoordinate.isWellFormed(zeros.dropLast(1) + "g")) assertNull(CyberspaceCoordinate.planeOf("not a coordinate")) + assertNull(CyberspaceCoordinate.decode("not a coordinate")) } } From a1f97a045213c4530e5d7c1750437cc753dfb4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 00:57:49 +0000 Subject: [PATCH 23/38] feat: Cantor roots and region keys, matching the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the region-bag plan. Amethyst now derives the same §7.2 keys the rest of the network derives: the conformance harness is 12 of 12, its new section comparing 16 region keys and their coordinate decodes against cyberspace-cli — §9.8's london, nyc and origin plus §7.7's ideaspace point, at heights 0, 1, 4 and 8. A key is a consensus value, so that agreement is the whole point: a byte of difference is a bag they hid that we cannot open. CantorTree is §4.5 and §4.6 — the aligned subtree whose boundaries arithmetic fixes rather than anyone's movement, which is what lets two people in the same neighbourhood compute the same root without communicating, which is what makes §7 work at all. Folded leaf by leaf against a stack of partial roots rather than a level at a time: same root either way, but a level holds every leaf at once and the stack holds height + 1 numbers. It refuses above height 20 as both references do, because one height further is twice the leaves and a root twice as wide and the number is a stranger's to choose. The big integer went the long way round and the plan now records why. It was written portable and used everywhere, on the argument that two implementations of a consensus value is two chances to disagree. Measurement reversed that: portable Kotlin is 3 to 10 times slower than java.math.BigInteger on the operands a Cantor tree reaches, because multiplyToLen is a HotSpot intrinsic, and §7's feasibility is a number. So UBigInt is an expect class wrapping BigInteger on jvmAndroid and PortableUBigInt on nativeMain, which covers Apple and Linux together. A wrapper and not a typealias, for one reason: 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. An alias would have fed that byte to SHA-256 and produced a key nobody else derives. What makes two implementations safe is that the disagreement is testable. PortableUBigIntDifferentialTest runs every operation against BigInteger over random inputs at fifteen widths from 0 to 352,000 bits, straddling the Karatsuba threshold both ways, and asserts the two actuals agree with each other — including on the bytes, which is where the alias would have gone wrong silently. CantorTreeBenchmark folds a whole subtree both ways and compares the roots, which is where a stack off-by-one would live rather than in the arithmetic, and keeps the cost on the record because the budget model the UI will quote is read straight off it. Also amy cyberspace coord|region, so the comparison runs in a shell script rather than only in a test, and one correction to the plan's cost model: the combine dominates above about height 8, roughly ten times an axis root at the same height, so a sweep priced by its tree builds under-quotes badly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../com/vitorpamplona/amethyst/cli/Main.kt | 3 + .../cli/commands/CyberspaceCommands.kt | 157 ++++++++++ cli/tests/sno/refdriver.py | 34 +++ cli/tests/sno/sno-conformance.sh | 43 +++ .../2026-09-22-cyberspace-region-bags.md | 62 +++- .../quartz/cyberspace/CantorTree.kt | 131 +++++++++ .../quartz/cyberspace/RegionKey.kt | 110 +++++++ .../quartz/utils/bigint/PortableUBigInt.kt | 276 ++++++++++++++++++ .../quartz/utils/bigint/UBigInt.kt | 84 ++++++ .../quartz/cyberspace/RegionKeyTest.kt | 146 +++++++++ .../quartz/utils/bigint/UBigInt.jvmAndroid.kt | 79 +++++ .../quartz/cyberspace/CantorTreeBenchmark.kt | 122 ++++++++ .../bigint/PortableUBigIntDifferentialTest.kt | 197 +++++++++++++ .../quartz/utils/bigint/UBigInt.native.kt | 63 ++++ 14 files changed, 1505 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt create mode 100644 quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5a82324fea..c7c06b1450 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.cli.commands.BuzzCommands import com.vitorpamplona.amethyst.cli.commands.ConcordCommands 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 @@ -244,6 +245,7 @@ private suspend fun dispatch(argv: Array): Int { "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) } @@ -515,6 +517,7 @@ private fun printUsage() { | 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 Simple Nostr Objects (DECK-0003): validate, price, verify an avatar + | cyberspace Cyberspace places and region keys (CYBERSPACE_V2 §2, §7.2) | 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt new file mode 100644 index 0000000000..99535387a7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt @@ -0,0 +1,157 @@ +/* + * 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.CyberspaceCoordinate +import com.vitorpamplona.quartz.cyberspace.CyberspacePlane +import com.vitorpamplona.quartz.cyberspace.RegionKey + +/** + * `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) + | + |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 + """.trimMargin() + + suspend fun dispatch(tail: Array): Int = + route( + "cyberspace", + tail, + "cyberspace ", + mapOf( + "coord" to { rest -> coord(rest) }, + "region" to { rest -> region(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): 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): 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 + } + + /** 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() + } +} diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py index f84fb122f1..045ff659a7 100755 --- a/cli/tests/sno/refdriver.py +++ b/cli/tests/sno/refdriver.py @@ -12,6 +12,7 @@ and payment reference), and just re-emits what they answer. 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":…} Paths come from $CYBERSPACE_DIR and $CYBERSPACE_CLI_DIR. """ @@ -94,8 +95,41 @@ def cmd_verify(): 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", + }) + + COMMANDS = { "cases": cmd_cases, + "region": cmd_region, "vectors": cmd_vectors, "verdict": cmd_verdict, "work": cmd_work, diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index c6c09a86f8..91edb41cb6 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -293,6 +293,49 @@ check_verdict "agreement-position-repair" "$FAR" true true "both repair a vertex 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 + print_summary grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md index b72bf98860..0378f50c80 100644 --- a/quartz/plans/2026-09-22-cyberspace-region-bags.md +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -193,8 +193,8 @@ is not offered as a button at all — it is reported as out of reach. 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 `expect`/`actual` bignum and the height refusal. - `amy cyberspace region`, diffed against `cyberspace-cli`. +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. 4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace open`, round-tripped against the reference CLI's `encrypt`. @@ -203,3 +203,61 @@ is not offered as a button at all — it is reported as out of reach. 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt new file mode 100644 index 0000000000..46668ba924 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTree.kt @@ -0,0 +1,131 @@ +/* + * 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.quartz.cyberspace + +import com.vitorpamplona.quartz.utils.bigint.UBigInt + +/** + * `CYBERSPACE_V2.md` §4.5 and §4.6: the Cantor number of an aligned subtree. + * + * A region is not a box somebody drew, it is an **aligned** subtree: its base is + * a multiple of `2^h` and it covers exactly `2^h` leaves, so the boundaries are + * fixed by arithmetic rather than by anyone's movement. That is the whole reason + * §7's location keys work — "Two people standing in the same neighbourhood will + * compute the same Cantor root without ever communicating, because they are both + * computing the root of the same aligned subtree." + * + * The root is that subtree's leaves paired bottom-up until one number is left. + * It is `O(2^h)` and there is no closed form; the numbers double in width at + * every level, so a root at height `h` runs to about `86 · 2^h` bits and the + * cost grows by roughly 2.2x per height — twice the leaves times wider operands. + * That is not an implementation detail to optimise away, it is the *point*: §7.1 + * makes the work the price of admission, and "looking and walking cost the same" + * only because this is expensive. + */ +object CantorTree { + /** + * The tallest subtree this will build, matching `cyberspace-core`'s + * `DEFAULT_MAX_COMPUTE_HEIGHT` and `cyberspace-cli`'s `max_compute_height`. + * + * Both references refuse rather than try, and so does this: one height past + * it is twice the leaves and a root twice as wide, and the difference + * between a request that takes a minute and one that takes the afternoon is + * a single integer a stranger chose. A caller that wants more says so. + */ + const val DEFAULT_MAX_COMPUTE_HEIGHT = 20 + + /** + * §4.6: `cantor_pair(a, b) = (a + b)(a + b + 1) / 2 + b`. + * + * A bijection on pairs of non-negative integers, which is what makes a root + * identify one region and no other. The halving is exact because `s(s + 1)` + * is a product of consecutive integers and therefore even. + */ + fun cantorPair( + a: UBigInt, + b: UBigInt, + ): UBigInt { + val sum = a + b + return (sum * (sum + UBigInt.ONE)).shiftRight(1) + b + } + + /** + * The root of the aligned subtree of [height] whose lowest leaf is [base]. + * + * Folded leaf by leaf against a stack of partial roots rather than a level + * at a time. The root is the same either way — a pairing at level `k` joins + * the same two subtrees in the same order whichever way the tree is walked — + * but a level at a time holds every leaf at once, a million of them at + * height 20, while the stack holds at most `height + 1` numbers, which + * together come to about one level's worth. + */ + fun subtreeRoot( + base: UBigInt, + height: Int, + maxComputeHeight: Int = DEFAULT_MAX_COMPUTE_HEIGHT, + ): UBigInt { + require(height >= 0) { "height must be >= 0" } + require(height <= maxComputeHeight) { "height $height exceeds maxComputeHeight $maxComputeHeight" } + if (height == 0) return base + + val values = arrayOfNulls(height + 1) + val levels = IntArray(height + 1) + var top = 0 + + val leaves = 1L shl height + for (i in 0 until leaves) { + var value = base + UBigInt.of(i) + var level = 0 + while (top > 0 && levels[top - 1] == level) { + top-- + value = cantorPair(values[top]!!, value) + level++ + } + values[top] = value + levels[top] = level + top++ + } + return values[0]!! + } + + /** + * §4.5: the height of the smallest aligned subtree holding both values — + * `bit_length(v1 XOR v2)`, which is how far up the tree they first meet. + */ + fun lcaHeight( + a: CyberspaceAxis, + b: CyberspaceAxis, + ): Int { + val high = a.high xor b.high + val low = a.low xor b.low + return if (high != 0L) Long.SIZE_BITS + bitLength(high) else bitLength(low) + } + + private fun bitLength(value: Long): Int { + var bits = 0 + var v = value + while (v != 0L) { + bits++ + v = v ushr 1 + } + return bits + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt new file mode 100644 index 0000000000..a690d70f1b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKey.kt @@ -0,0 +1,110 @@ +/* + * 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.quartz.cyberspace + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** What deriving a region at one height produces (`CYBERSPACE_V2.md` §7.2). */ +class RegionKeyMaterial( + val height: Int, + /** §4.7's stable spatial region integer. */ + val regionN: UBigInt, + /** The 32 bytes a bag's payload is encrypted under. Not to be published. */ + val decryptionKey: ByteArray, + /** The `d` tag a bag carrying this region is addressed by. Safe to publish. */ + val lookupId: HexKey, +) + +/** + * `CYBERSPACE_V2.md` §7.2 — turning a place into a key. + * + * ``` + * region_bytes = int_to_bytes_be_min(region_n) + * location_decryption_key = sha256(region_bytes) + * lookup_id = sha256(location_decryption_key) + * ``` + * + * **Two layers, and the second one is the design.** The lookup id is published + * so that people can find the content; it is a hash *of* the key, so seeing it + * buys nothing without the region preimage. §7.2: "Seeing `lookup_id` does not + * allow deriving `location_decryption_key` without the region preimage. The + * lookup ID is safe to publish; the decryption key requires work." + * + * Note what is deliberately absent: the temporal axis that hop proofs use (§5) + * is not part of this. Location identifiers stay a stable function of space, so + * they do not change when somebody walks through. + */ +object RegionKey { + /** + * §7.4: the region integer for the aligned cube of [height] holding [point]. + * + * The three axes are independent all the way to the combine, which is why a + * §7.7 sweep of a box costs one tree per distinct base *per axis* rather + * than one per candidate region. + */ + fun regionN( + point: CyberspacePoint, + height: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): UBigInt { + val base = point.alignedBase(height) + val x = CantorTree.subtreeRoot(base.x.toUBigInt(), height, maxComputeHeight) + val y = CantorTree.subtreeRoot(base.y.toUBigInt(), height, maxComputeHeight) + val z = CantorTree.subtreeRoot(base.z.toUBigInt(), height, maxComputeHeight) + // §4.7: region_n = pi(pi(cantor_x, cantor_y), cantor_z). + return CantorTree.cantorPair(CantorTree.cantorPair(x, y), z) + } + + /** The key and the lookup id a region integer yields. */ + fun derive( + regionN: UBigInt, + height: Int = 0, + ): RegionKeyMaterial { + val key = sha256(regionN.toMinimalBytes()) + return RegionKeyMaterial(height, regionN, key, sha256(key).toHexKey()) + } + + /** Both halves at once, for the common case. */ + fun at( + point: CyberspacePoint, + height: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): RegionKeyMaterial = derive(regionN(point, height, maxComputeHeight), height) +} + +/** + * This axis as a number the Cantor tree can add to. + * + * The one conversion from the split representation into arbitrary precision, + * done at the boundary where arithmetic starts and nowhere earlier — the same + * rule the SNO lattice follows for the same reason. + */ +fun CyberspaceAxis.toUBigInt(): UBigInt { + val bytes = ByteArray(16) + for (i in 0..7) { + bytes[i] = (high ushr (56 - i * 8)).toByte() + bytes[8 + i] = (low ushr (56 - i * 8)).toByte() + } + return UBigInt.ofBytes(bytes) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt new file mode 100644 index 0000000000..ad3684671e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigInt.kt @@ -0,0 +1,276 @@ +/* + * 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.quartz.utils.bigint + +/** + * A non-negative integer of arbitrary size, in portable Kotlin: the engine + * behind [UBigInt] everywhere Java's is not available. + * + * On JVM and Android [UBigInt] wraps `java.math.BigInteger`, whose + * `multiplyToLen` is a HotSpot intrinsic and which measured 3 to 10 times + * faster than this on the operands a Cantor tree reaches. That speed matters — + * `CYBERSPACE_V2.md` §7's whole feasibility argument is a number — but Apple + * and Linux have nothing to wrap, so this exists and has to be exactly as + * correct, because what it computes becomes a decryption key (§7.2). A limb + * handled differently here is an object that opens on a desktop and not on a + * phone. + * + * That risk is discharged by measurement rather than by argument: + * `PortableUBigIntDifferentialTest` runs every operation against + * `java.math.BigInteger` over random inputs at the sizes the tree reaches, and + * `CantorTreeBenchmark` folds a whole subtree both ways and compares the roots. + * + * **Unsigned by construction.** Nothing here ever goes negative: the Cantor + * pairing of two non-negative numbers is non-negative, and [subtract] is used + * only inside Karatsuba where the result is known to be. That removes sign + * handling, two's complement, and the whole class of bug where a leading zero + * byte does or does not appear. + * + * Limbs are 32 bits each, little-endian — `limbs[0]` is the least significant — + * and normalised so the top limb is never zero. Zero is the empty array. + */ +internal class PortableUBigInt internal constructor( + internal val limbs: IntArray, +) : Comparable { + /** How many bits this number occupies; 0 for zero. */ + val bitLength: Int + get() { + if (limbs.isEmpty()) return 0 + val top = limbs[limbs.size - 1] + return limbs.size * Int.SIZE_BITS - leadingZeros(top) + } + + val isZero: Boolean get() = limbs.isEmpty() + + operator fun plus(other: PortableUBigInt): PortableUBigInt { + if (isZero) return other + if (other.isZero) return this + val long = if (limbs.size >= other.limbs.size) limbs else other.limbs + val short = if (limbs.size >= other.limbs.size) other.limbs else limbs + val out = IntArray(long.size + 1) + var carry = 0L + for (i in long.indices) { + val sum = (long[i].toLong() and MASK) + (if (i < short.size) short[i].toLong() and MASK else 0L) + carry + out[i] = sum.toInt() + carry = sum ushr Int.SIZE_BITS + } + out[long.size] = carry.toInt() + return normalised(out) + } + + /** + * `this - other`, which the caller guarantees is not negative. + * + * Only Karatsuba calls this, on `(a0 + a1)(b0 + b1) - z0 - z2`, which is a + * cross term and cannot go below zero. A borrow off the end would mean the + * multiplication itself was wrong, so it is an error rather than a wrap. + */ + internal fun subtract(other: PortableUBigInt): PortableUBigInt { + if (other.isZero) return this + val out = IntArray(limbs.size) + var borrow = 0L + for (i in limbs.indices) { + val diff = (limbs[i].toLong() and MASK) - (if (i < other.limbs.size) other.limbs[i].toLong() and MASK else 0L) - borrow + out[i] = diff.toInt() + borrow = if (diff < 0) 1L else 0L + } + check(borrow == 0L) { "unsigned subtract went below zero" } + return normalised(out) + } + + operator fun times(other: PortableUBigInt): PortableUBigInt { + if (isZero || other.isZero) return ZERO + if (limbs.size < KARATSUBA_LIMBS || other.limbs.size < KARATSUBA_LIMBS) { + return schoolbook(limbs, other.limbs) + } + return karatsuba(this, other) + } + + /** This number with its low [bits] bits dropped. */ + fun shiftRight(bits: Int): PortableUBigInt { + require(bits >= 0) { "shift must not be negative" } + if (bits == 0 || isZero) return this + val wholeLimbs = bits / Int.SIZE_BITS + if (wholeLimbs >= limbs.size) return ZERO + val withinLimb = bits % Int.SIZE_BITS + val out = IntArray(limbs.size - wholeLimbs) + if (withinLimb == 0) { + limbs.copyInto(out, 0, wholeLimbs, limbs.size) + } else { + for (i in out.indices) { + val low = limbs[i + wholeLimbs] ushr withinLimb + val high = if (i + wholeLimbs + 1 < limbs.size) limbs[i + wholeLimbs + 1] shl (Int.SIZE_BITS - withinLimb) else 0 + out[i] = low or high + } + } + return normalised(out) + } + + /** + * The `int_to_bytes_be_min` of the reference implementation: big-endian, no + * leading zero byte, and a single zero byte for zero. + * + * This exact shape is what §7.2 hashes, so a spare leading byte — which is + * what `java.math.BigInteger.toByteArray()` adds whenever the top bit is + * set — would silently produce a different key for one number in two. + */ + fun toMinimalBytes(): ByteArray { + if (isZero) return byteArrayOf(0) + val bytes = (bitLength + 7) / 8 + val out = ByteArray(bytes) + for (i in 0 until bytes) { + val limb = limbs[i / 4] + out[bytes - 1 - i] = (limb ushr ((i % 4) * 8)).toByte() + } + return out + } + + override fun compareTo(other: PortableUBigInt): Int { + if (limbs.size != other.limbs.size) return if (limbs.size < other.limbs.size) -1 else 1 + for (i in limbs.indices.reversed()) { + val a = limbs[i].toLong() and MASK + val b = other.limbs[i].toLong() and MASK + if (a != b) return if (a < b) -1 else 1 + } + return 0 + } + + override fun equals(other: Any?): Boolean = other is PortableUBigInt && limbs.contentEquals(other.limbs) + + override fun hashCode(): Int = limbs.contentHashCode() + + override fun toString(): String = "PortableUBigInt($bitLength bits)" + + companion object { + private const val MASK = 0xFFFFFFFFL + + /** + * Below this many limbs on either side, schoolbook wins: Karatsuba's + * three sub-products and their adds cost more than the `n * m` limb + * multiplications they save. The exact crossover is not sensitive — + * anything in the tens works — and this one is measured, not guessed. + */ + private const val KARATSUBA_LIMBS = 40 + + val ZERO = PortableUBigInt(IntArray(0)) + val ONE = PortableUBigInt(intArrayOf(1)) + + fun of(value: Long): PortableUBigInt { + require(value >= 0) { "negative values have no place on this lattice" } + if (value == 0L) return ZERO + val high = (value ushr Int.SIZE_BITS).toInt() + return if (high == 0) PortableUBigInt(intArrayOf(value.toInt())) else PortableUBigInt(intArrayOf(value.toInt(), high)) + } + + /** An unsigned big-endian magnitude, the inverse of [toMinimalBytes]. */ + fun ofBytes(bytes: ByteArray): PortableUBigInt { + if (bytes.isEmpty()) return ZERO + val out = IntArray((bytes.size + 3) / 4) + for (i in bytes.indices) { + val fromEnd = bytes.size - 1 - i + out[i / 4] = out[i / 4] or ((bytes[fromEnd].toInt() and 0xFF) shl ((i % 4) * 8)) + } + return normalised(out) + } + + private fun normalised(limbs: IntArray): PortableUBigInt { + var size = limbs.size + while (size > 0 && limbs[size - 1] == 0) size-- + if (size == 0) return ZERO + return PortableUBigInt(if (size == limbs.size) limbs else limbs.copyOf(size)) + } + + private fun leadingZeros(value: Int): Int { + if (value == 0) return Int.SIZE_BITS + var count = 0 + var v = value + while (v > 0) { + v = v shl 1 + count++ + } + return count + } + + private fun schoolbook( + a: IntArray, + b: IntArray, + ): PortableUBigInt { + val out = IntArray(a.size + b.size) + for (i in a.indices) { + val ai = a[i].toLong() and MASK + if (ai == 0L) continue + var carry = 0L + for (j in b.indices) { + val at = i + j + val product = ai * (b[j].toLong() and MASK) + (out[at].toLong() and MASK) + carry + out[at] = product.toInt() + carry = product ushr Int.SIZE_BITS + } + var at = i + b.size + while (carry != 0L) { + val sum = (out[at].toLong() and MASK) + carry + out[at] = sum.toInt() + carry = sum ushr Int.SIZE_BITS + at++ + } + } + return normalised(out) + } + + /** + * `a * b` in three half-width products instead of four. + * + * Splitting both at `half` limbs, `a = a1·B + a0` and `b = b1·B + b0`, + * the product is `z2·B² + z1·B + z0` where `z2 = a1·b1`, `z0 = a0·b0` + * and the cross term `z1 = (a0 + a1)(b0 + b1) − z2 − z0`, which is one + * multiplication rather than two. That turns the exponent from 2 to + * about 1.585, and on the numbers a Cantor tree reaches — megabytes by + * height 16 — it is the difference between a key and a coffee break. + */ + private fun karatsuba( + a: PortableUBigInt, + b: PortableUBigInt, + ): PortableUBigInt { + val half = maxOf(a.limbs.size, b.limbs.size) / 2 + val a0 = a.low(half) + val a1 = a.high(half) + val b0 = b.low(half) + val b1 = b.high(half) + + val z0 = a0 * b0 + val z2 = a1 * b1 + val z1 = ((a0 + a1) * (b0 + b1)).subtract(z2).subtract(z0) + + return z2.shiftLeftLimbs(half * 2) + z1.shiftLeftLimbs(half) + z0 + } + } + + private fun low(limbCount: Int): PortableUBigInt = if (limbs.size <= limbCount) this else normalised(limbs.copyOfRange(0, limbCount)) + + private fun high(limbCount: Int): PortableUBigInt = if (limbs.size <= limbCount) ZERO else normalised(limbs.copyOfRange(limbCount, limbs.size)) + + private fun shiftLeftLimbs(limbCount: Int): PortableUBigInt { + if (isZero || limbCount == 0) return this + val out = IntArray(limbs.size + limbCount) + limbs.copyInto(out, limbCount) + return PortableUBigInt(out) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt new file mode 100644 index 0000000000..a81cbe97a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.kt @@ -0,0 +1,84 @@ +/* + * 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.quartz.utils.bigint + +/** + * A non-negative integer of arbitrary size, with only the operations Cantor + * pairing needs (`CYBERSPACE_V2.md` §4.6). + * + * Two implementations, for one reason: speed on the platforms people use, and + * an implementation at all on the ones they might. JVM and Android wrap + * `java.math.BigInteger`, whose `multiplyToLen` is a HotSpot intrinsic and + * which measured 3 to 10 times faster than portable Kotlin on the operands a + * Cantor tree reaches — and §7's feasibility is a number, so that factor is the + * difference between a search a reader will wait for and one they will not. + * Apple and Linux have nothing to wrap and get [PortableUBigInt]. + * + * Two implementations of a **consensus value** would normally be a bad trade: + * §7.2 turns this into a decryption key, so a carry handled differently on one + * platform is an object that opens on a desktop and not on a phone. What makes + * it safe is that the disagreement is testable, and tested — every operation + * against `java.math.BigInteger` on random inputs at the sizes that matter, and + * a whole subtree folded both ways with the roots compared. + * + * Unsigned throughout: nothing on this lattice is negative, so there is no sign + * to carry and no two's complement to get wrong. + */ +expect class UBigInt : Comparable { + /** How many bits this number occupies; 0 for zero. */ + val bitLength: Int + + val isZero: Boolean + + operator fun plus(other: UBigInt): UBigInt + + operator fun times(other: UBigInt): UBigInt + + /** This number with its low [bits] bits dropped. */ + fun shiftRight(bits: Int): UBigInt + + /** + * The reference's `int_to_bytes_be_min`: big-endian, no leading zero byte, + * and a single zero byte for zero. + * + * This exact shape is what §7.2 hashes. `java.math.BigInteger.toByteArray()` + * is two's complement and prepends `0x00` whenever the top bit is set — + * half of all numbers — so the JVM actual strips it, and a test pins that + * it did. + */ + fun toMinimalBytes(): ByteArray + + override fun compareTo(other: UBigInt): Int + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int + + companion object { + val ZERO: UBigInt + val ONE: UBigInt + + fun of(value: Long): UBigInt + + /** An unsigned big-endian magnitude, the inverse of [toMinimalBytes]. */ + fun ofBytes(bytes: ByteArray): UBigInt + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt new file mode 100644 index 0000000000..8fa81b7469 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionKeyTest.kt @@ -0,0 +1,146 @@ +/* + * 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.quartz.cyberspace + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * §4's Cantor roots and §7.2's key derivation, against the reference + * implementation. + * + * Every expectation below came out of `cyberspace-cli`'s own + * `compute_subtree_cantor`, `cantor_pair` and `int_to_bytes_be_min`, run over + * the four coordinates at five heights each. They are keys: if this file passes, + * a bag the reference hid is one Amethyst can open, and if it fails they are two + * different networks that happen to share a spec. + * + * The **key** is the vector rather than `region_n` because it pins `region_n` + * exactly in 32 bytes — a root at height 10 is eleven kilobytes of hex — and + * because it also pins the byte encoding, which is the part most likely to + * drift: the reference writes minimal big-endian, and a platform big integer + * that adds a sign byte produces a different key for one number in two. + */ +class RegionKeyTest { + /** `name | height | key | lookup_id`, from the reference. */ + private val reference = + listOf( + "london|0|c9fed7928825bb4192386ad339bfe73df70de5918e4395f29a97762458bfb972|c55b1e044f016744c45b6dc8080e6e3825f9d88b412e2d1a6becad4de8c5f5ef", + "london|1|6ea9f6b310442cced2ae706468aa90c9c5e217483b727b819c212c231f99b17f|24f83bebbf580937f066df5283abe7386f9abbc38f567b8442fdc7e591ffb053", + "london|4|314ade123f63601cc69a0addf455ea5c2d84ea848b84285aee5a00941e316ab4|a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916", + "london|8|28939afc70712ce271b74ac7f5b9ed8339c955311cc289c516c63fa6e7a7a489|188ae4d5dccd60a27207f1d46a83959e002ec99cdeec717873ed5dc1a0fd5f69", + "london|10|83f219e105c3011e0f1b06f1f0fc053b437c2edb5826ff707dbb2cc8ef7dd550|a94422ee1b5423639dac61c203ee3c308c47b86470065587f38abf6a359d946e", + "nyc|0|1232b3a383492a4051fc7ee67d0332b42fcd113e834f05203f54c709e0f9bc78|80c73abdf82ab69d3e6795b13e0271321bf7ae1348007adf909b6b1e5f80ecfa", + "nyc|1|847cec141e1f645d296bd3c80bcc32f8b5ef60868f34533958d8917c117d8204|ee1802e91efeea676cc2955d9af0405dba74ad247c1833334efd2d52efb6cb04", + "nyc|4|bdb14b2c226797a79a2a808cbbf6126ddb5eb4bce27c8880c9bd668c853db4d1|4b3b0c9c1103ca97cff6124dce1752cdfec03a7358e28f6dd734ceb21e32ed3e", + "nyc|8|71cadf83822c69daafff9ea77ccfe4fb3b9d113b6f2e3bcd5f270862145807a6|1cf5bb35aa98c110b37add7d0bdfbd42474fe7c0cf523f569b06225ab8b0761b", + "nyc|10|a947d0322e2afad8c4852e2045c00f277bee246f41477212067b9c01f9cb9192|d464d48d1b85eb216d94b3b56654e8ce1a0f7b5c3e6a643096c88632e477aca4", + "origin|0|692329a8a9893517bfaaa0c1e27f597bc6b498d1371a4c866aa89424f54fe633|6eaedee401f51b2d54c51f86a603888dc1decb14dc872d26a0ffd6f4a4369136", + "origin|1|cb140125e63937b960fe6ed4a8ef6c9e801918c53a6b03b74897f245e01d12e4|21263dc0e2d46b450aa471aa34114ce21682818c5fbb285b06713f0ad3b26485", + "origin|4|a41a79fe0e41b1d5519fa71ab36fa0249f3411da72b810471e22274b09647de2|cb5e37e49dbdd02bae07347220635443739704d4177e84fa5b8ff8efbd2d8ce3", + "origin|8|fd1c6fc0d687db7edfc8747d5e844b79b3bfff1ebe206305e6f73d6ad27697fa|83ffb82c33758464c11482736525f14273eacaa4b0946119fb7202c04a7d30ec", + "origin|10|7d439417987c3b4c4cdc3bc9d76e1e09986817b9a5f6119d4f8fb4e858572ba3|5e6d4f1d57f9bc65bb07b6aeac02dd39f9a893790bb9788887d9d5f1fdad50c7", + "ideaspace|0|9d8de1257072196c30d90d540154a8259f1804dddc7dbcb3d99499cc48dec355|3350bd0e23d4b80b6a0e1070169f3e3252ce2d41e1b09134e14fea60fe7aa122", + "ideaspace|1|1f98a44ace1dbc67c7c45f5de8b010f6c551ba80f4022e9958c5eccf3640746c|ab1fda3988d871b25d45b93e28f971284e57da83ffb3e0e03c03fcbe2ede1ca8", + "ideaspace|4|bd26b0a550956d90161c21bc96e0adeb3ac262eea59345705eb26adf63345e56|c9d141d4f23590036f9cd3b82de1cd00faa512dc11937e5fc30b18e9ad382150", + "ideaspace|8|ee7c2c7af8c082705533436aa577e28f9020dc2eaf3bab4425c13560df67d306|2e82777cc6a759f52e76004b312806c1ddd2ee7642260f2cc3e52f1873b6aaef", + "ideaspace|10|da3f756d61498b59f00cc179a7c643e30d24395fc437091445f686bce64994fb|36b601485a2d28eb41b21bb20e05782106ae8753982196de5f95c3e03dad66a2", + ) + + private val coordinates = + mapOf( + "london" to "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", + "nyc" to "c4924924924924924924921f79235dae293ada913e78294253a235239a332854", + "origin" to "e000000000000000000001200041040208048040000000000000000000000000", + "ideaspace" to "a4b64924924924924924924924924924924924924924924924924d84b60d9c8f", + ) + + @Test + fun everyRegionKeyMatchesTheReference() { + for (row in reference) { + val (name, height, key, lookupId) = row.split("|") + val point = CyberspaceCoordinate.decode(coordinates.getValue(name)) + assertNotNull(point, name) + + val material = RegionKey.at(point, height.toInt()) + assertEquals(key, material.decryptionKey.toHexKey(), "$name at height $height: the key") + assertEquals(lookupId, material.lookupId, "$name at height $height: the lookup id") + } + } + + private operator fun List.component4() = this[3] + + @Test + fun theCantorPairIsTheOneTheSpecWrites() { + // §4.6: cantor_pair(a, b) = (a + b)(a + b + 1) / 2 + b, worked by hand. + // (3 + 5) * 9 / 2 + 5 = 41. + assertEquals(UBigInt.of(41), CantorTree.cantorPair(UBigInt.of(3), UBigInt.of(5))) + assertEquals(UBigInt.of(0), CantorTree.cantorPair(UBigInt.ZERO, UBigInt.ZERO)) + // It is a bijection, so no two pairs share a value. The classic witness + // is that it is not symmetric. + assertTrue(CantorTree.cantorPair(UBigInt.of(5), UBigInt.of(3)) != CantorTree.cantorPair(UBigInt.of(3), UBigInt.of(5))) + } + + @Test + fun aHeightOfZeroIsTheBaseItself() { + // The reference returns `base` unchanged at height 0, so a region of one + // leaf is that leaf's own number and costs nothing. + assertEquals(UBigInt.of(12345), CantorTree.subtreeRoot(UBigInt.of(12345), 0)) + } + + @Test + fun theSpecsOwnOneDimensionalExampleHolds() { + // §4.5: "LCA(0, 3) => subtree [0..3] => root = 228", and the same root + // for LCA(1, 2) and LCA(0, 2) — all three movements see one region, + // "which is exactly what enables location-based discovery". + assertEquals(UBigInt.of(228), CantorTree.subtreeRoot(UBigInt.ZERO, 2)) + } + + @Test + fun aSubtreeTallerThanTheCeilingIsRefusedRatherThanAttempted() { + // Both references raise instead of trying, and for the same reason: one + // height past the ceiling is twice the leaves and a root twice as wide, + // and the number is a stranger's to choose. + assertFailsWith { + CantorTree.subtreeRoot(UBigInt.ZERO, CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT + 1) + } + assertFailsWith { CantorTree.subtreeRoot(UBigInt.ZERO, -1) } + // And a caller that means it can say so. + assertEquals(UBigInt.of(228), CantorTree.subtreeRoot(UBigInt.ZERO, 2, maxComputeHeight = 2)) + } + + @Test + fun theLcaHeightIsTheBitLengthOfTheDifference() { + // §4.5: "h = find_lca_height(v1, v2)", bit_length(v1 XOR v2). Moving + // from 0 to 5 gives 3, from 4 to 7 gives 2. + fun axis(v: Long) = CyberspaceAxis(0L, v) + assertEquals(3, CantorTree.lcaHeight(axis(0), axis(5))) + assertEquals(2, CantorTree.lcaHeight(axis(4), axis(7))) + assertEquals(0, CantorTree.lcaHeight(axis(9), axis(9))) + // Across the 64-bit split, where an axis stops fitting one Long. + assertEquals(65, CantorTree.lcaHeight(CyberspaceAxis(1L, 0L), CyberspaceAxis(0L, 0L))) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt new file mode 100644 index 0000000000..77099599bc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.jvmAndroid.kt @@ -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.quartz.utils.bigint + +import java.math.BigInteger + +/** + * [UBigInt] over `java.math.BigInteger`, which is what makes a region key + * affordable on the two platforms Amethyst actually ships to. + * + * 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 prepends a `0x00` sign byte whenever the top bit is + * set. Aliasing would put that byte into a SHA-256 for one number in two and + * produce a key nobody else derives. The allocation per operation is nothing + * against multiplications of megabyte operands. + */ +actual class UBigInt internal constructor( + internal val raw: BigInteger, +) : Comparable { + actual val bitLength: Int get() = raw.bitLength() + + actual val isZero: Boolean get() = raw.signum() == 0 + + actual operator fun plus(other: UBigInt): UBigInt = UBigInt(raw.add(other.raw)) + + actual operator fun times(other: UBigInt): UBigInt = UBigInt(raw.multiply(other.raw)) + + actual fun shiftRight(bits: Int): UBigInt { + require(bits >= 0) { "shift must not be negative" } + return UBigInt(raw.shiftRight(bits)) + } + + actual fun toMinimalBytes(): ByteArray { + if (raw.signum() == 0) return byteArrayOf(0) + val bytes = raw.toByteArray() + // Two's complement grows a leading zero exactly when the magnitude's + // top bit is set; the reference never writes one. + return if (bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + } + + actual override fun compareTo(other: UBigInt): Int = raw.compareTo(other.raw) + + actual override fun equals(other: Any?): Boolean = other is UBigInt && raw == other.raw + + actual override fun hashCode(): Int = raw.hashCode() + + override fun toString(): String = "UBigInt($bitLength bits)" + + actual companion object { + actual val ZERO: UBigInt = UBigInt(BigInteger.ZERO) + actual val ONE: UBigInt = UBigInt(BigInteger.ONE) + + actual fun of(value: Long): UBigInt { + require(value >= 0) { "negative values have no place on this lattice" } + return UBigInt(BigInteger.valueOf(value)) + } + + actual fun ofBytes(bytes: ByteArray): UBigInt = if (bytes.isEmpty()) ZERO else UBigInt(BigInteger(1, bytes)) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt new file mode 100644 index 0000000000..3f0e032cd2 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cyberspace/CantorTreeBenchmark.kt @@ -0,0 +1,122 @@ +/* + * 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.quartz.cyberspace + +import com.vitorpamplona.quartz.utils.bigint.UBigInt +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a region key costs, and what writing the arithmetic cost against + * aliasing Java's. + * + * §7's whole feasibility argument is a number, and the plan's budget model — + * what a §7.7 sweep may be offered without asking — is read straight off it. A + * change that quietly makes a key five times slower does not break a test + * anywhere else; it makes a card lie about how long a search will take. So the + * number lives here. + * + * The comparison against `java.math.BigInteger` is the price of portability. + * [UBigInt] exists because a `expect`/`actual` would need a hand-written Apple + * and Linux implementation regardless, and two implementations of a consensus + * value is two chances to disagree — but Java's Toom-Cook is real and this + * measures how much of it was given up. + */ +class CantorTreeBenchmark { + private fun javaPair( + a: BigInteger, + b: BigInteger, + ): BigInteger { + val s = a.add(b) + return s.multiply(s.add(BigInteger.ONE)).shiftRight(1).add(b) + } + + private fun javaRoot( + base: BigInteger, + height: Int, + ): BigInteger { + if (height == 0) return base + val values = arrayOfNulls(height + 2) + val levels = IntArray(height + 2) + var top = 0 + for (i in 0 until (1L shl height)) { + var v = base.add(BigInteger.valueOf(i)) + var lvl = 0 + while (top > 0 && levels[top - 1] == lvl) { + top-- + v = javaPair(values[top]!!, v) + lvl++ + } + values[top] = v + levels[top] = lvl + top++ + } + return values[0]!! + } + + private fun millis(block: () -> Unit): Double { + val start = System.nanoTime() + block() + return (System.nanoTime() - start) / 1e6 + } + + @Test + fun aRegionKeyCostsWhatThePlanSaysItDoes() { + val point = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940")!! + repeat(30) { RegionKey.at(point, 6) } + repeat(30) { javaRoot(BigInteger.valueOf(123456789L), 6) } + + // The two costs a §7.7 sweep is made of: a box of gap G needs + // 3 * 2^(G/3) axis roots and 2^G combines, and the second dominates. + println("height | axis root ms | combine+2sha ms | java root ms | ratio") + for (height in listOf(8, 10, 12, 14)) { + val base = point.alignedBase(height).x.toUBigInt() + val ours = millis { CantorTree.subtreeRoot(base, height) } + val root = CantorTree.subtreeRoot(base, height) + val reps = if (height <= 10) 50 else 5 + val combine = millis { repeat(reps) { RegionKey.derive(CantorTree.cantorPair(CantorTree.cantorPair(root, root), root)) } } / reps + val java = millis { javaRoot(BigInteger(1, base.toMinimalBytes()), height) } + println("$height | ${fmt(ours)} | ${fmt(combine)} | ${fmt(java)} | ${fmt(ours / java)}x") + } + + // Loose on purpose — a shared box is noisy, and what would invalidate + // the budget model is an order of magnitude, not a busy minute. + val atEight = millis { RegionKey.at(point, 8) } + assertTrue(atEight < 500.0, "a height-8 key should be milliseconds, was ${fmt(atEight)} ms") + } + + @Test + fun theTwoImplementationsAgreeOnTheRootItself() { + // The differential test covers the arithmetic; this covers the fold + // built on it, which is where an off-by-one in the stack would live. + for (height in 0..12) { + // A base with bits set high and low, so the fold is not walking zeros. + val base = UBigInt.of((1L shl 62) + 1_234_567L + height) + val mine = CantorTree.subtreeRoot(base, height) + val theirs = javaRoot(BigInteger(1, base.toMinimalBytes()), height) + assertEquals(theirs, BigInteger(1, mine.toMinimalBytes()), "height $height") + } + } + + private fun fmt(value: Double) = ((value * 100).toLong() / 100.0).toString() +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt new file mode 100644 index 0000000000..7bb3a7245e --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/utils/bigint/PortableUBigIntDifferentialTest.kt @@ -0,0 +1,197 @@ +/* + * 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.quartz.utils.bigint + +import java.math.BigInteger +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * [PortableUBigInt] against `java.math.BigInteger`, on random inputs, operation by + * operation. + * + * This is the whole safety argument for the second implementation. JVM and + * Android alias the platform's; Apple and Linux run this one. What it computes becomes a decryption key + * (`CYBERSPACE_V2.md` §7.2), so a carry dropped in one limb is not a wrong + * number, it is an object that will not open — and it would open everywhere + * else, because everyone else is running the reference. A hand-written example + * suite cannot cover a carry chain; a few thousand random pairs across the + * sizes the Cantor tree actually reaches can, and the oracle is the + * implementation the rest of the world's JVMs already agree with. + * + * The sizes are chosen to straddle the Karatsuba threshold in both directions + * and to include the degenerate ends, because that boundary is where a + * split-and-recombine bug lives. + */ +class PortableUBigIntDifferentialTest { + private val random = Random(20260923) + + private fun PortableUBigInt.toBig(): BigInteger = BigInteger(1, toMinimalBytes()) + + private fun randomOf(bits: Int): Pair { + if (bits == 0) return PortableUBigInt.ZERO to BigInteger.ZERO + val bytes = ByteArray((bits + 7) / 8) + random.nextBytes(bytes) + // Force the top bit so the value really is this wide, which is also the + // case where BigInteger's own toByteArray grows a sign byte. + bytes[0] = (bytes[0].toInt() or 0x80).toByte() + val mine = PortableUBigInt.ofBytes(bytes) + return mine to BigInteger(1, bytes) + } + + /** Sizes around the split threshold, plus the degenerate ends. */ + private val widths = listOf(0, 1, 7, 8, 31, 32, 33, 64, 127, 128, 1_200, 1_280, 1_281, 4_096, 9_001) + + @Test + fun addingAgrees() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.add(theirsB), (mineA + mineB).toBig(), "$a + $b") + } + } + } + } + + @Test + fun multiplyingAgreesOnBothSidesOfTheKaratsubaThreshold() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.multiply(theirsB), (mineA * mineB).toBig(), "$a * $b") + } + } + } + } + + @Test + fun multiplyingAgreesOnTheSizesACantorTreeReaches() { + // A root at height h is about 86 * 2^h bits, so these are the operands + // of the last few pairings at heights 12 to 16 — where Karatsuba + // recurses several levels deep and a mis-split would show. + for (bits in listOf(44_000, 88_000, 176_000, 352_000)) { + val (mineA, theirsA) = randomOf(bits) + val (mineB, theirsB) = randomOf(bits) + assertEquals(theirsA.multiply(theirsB), (mineA * mineB).toBig(), "$bits bits squared") + } + } + + @Test + fun shiftingRightAgrees() { + for (bits in widths) { + for (by in listOf(0, 1, 7, 31, 32, 33, 64, 1_000, 100_000)) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs.shiftRight(by), mine.shiftRight(by).toBig(), "$bits >> $by") + } + } + } + + @Test + fun comparingAndEqualityAgree() { + for (a in widths) { + for (b in widths) { + repeat(4) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + assertEquals(theirsA.compareTo(theirsB), mineA.compareTo(mineB), "$a <=> $b") + assertEquals(theirsA == theirsB, mineA == mineB) + } + } + } + } + + @Test + fun bitLengthAgrees() { + for (bits in widths) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs.bitLength(), mine.bitLength, "$bits") + } + } + + @Test + fun theMinimalBytesAreTheReferencesAndNotJavas() { + // The reference's `int_to_bytes_be_min` is `n.to_bytes((bit_length + 7) + // // 8, "big")`, with a single zero byte for zero. BigInteger's own + // toByteArray is two's complement and prepends 0x00 whenever the top + // bit is set, which is half of all numbers — and §7.2 hashes these + // bytes, so the spare byte would be a different key. + assertEquals(listOf(0), PortableUBigInt.ZERO.toMinimalBytes().toList()) + assertEquals(listOf(1), PortableUBigInt.ONE.toMinimalBytes().toList()) + assertEquals(listOf(-1), PortableUBigInt.of(255).toMinimalBytes().toList()) + assertEquals(listOf(1, 0), PortableUBigInt.of(256).toMinimalBytes().toList()) + + for (bits in widths.filter { it > 0 }) { + val (mine, theirs) = randomOf(bits) + val mineBytes = mine.toMinimalBytes() + // Same value, and never longer than the bit length demands. + assertEquals(theirs, BigInteger(1, mineBytes), "$bits round trip") + assertEquals((theirs.bitLength() + 7) / 8, mineBytes.size, "$bits has no spare byte") + assertTrue(mineBytes[0] != 0.toByte(), "$bits leads with a significant byte") + } + } + + @Test + fun theTwoActualsAgreeWithEachOther() { + // The contract between the platforms, asserted rather than assumed: on + // this JVM `UBigInt` is `java.math.BigInteger`, and on Apple and Linux + // it is `PortableUBigInt`. A region key derived on a phone has to equal + // one derived on a desktop, so the two must agree on every operation + // and, above all, on the bytes that get hashed. + for (a in widths) { + for (b in widths) { + val (mineA, theirsA) = randomOf(a) + val (mineB, theirsB) = randomOf(b) + val fastA = UBigInt.ofBytes(mineA.toMinimalBytes()) + val fastB = UBigInt.ofBytes(mineB.toMinimalBytes()) + + assertEquals((mineA + mineB).toMinimalBytes().toList(), (fastA + fastB).toMinimalBytes().toList(), "$a + $b") + assertEquals((mineA * mineB).toMinimalBytes().toList(), (fastA * fastB).toMinimalBytes().toList(), "$a * $b") + assertEquals(mineA.shiftRight(33).toMinimalBytes().toList(), fastA.shiftRight(33).toMinimalBytes().toList(), "$a >> 33") + assertEquals(mineA.compareTo(mineB), fastA.compareTo(fastB), "$a <=> $b") + assertEquals(mineA.bitLength, fastA.bitLength, "$a bits") + assertEquals(mineA.isZero, fastA.isZero, "$a zero") + // And the JVM actual must have stripped the sign byte its + // `toByteArray` grows, which is the one place aliasing would + // have silently changed a key. + assertEquals(theirsA.toString(16).trimStart('0').ifEmpty { "0" }, fastA.toMinimalBytes().toHex(), "$a bytes") + } + } + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }.trimStart('0').ifEmpty { "0" } + + @Test + fun longsAndBytesRoundTrip() { + for (value in listOf(0L, 1L, 255L, 256L, Int.MAX_VALUE.toLong(), 1L shl 32, Long.MAX_VALUE)) { + assertEquals(BigInteger.valueOf(value), PortableUBigInt.of(value).toBig(), "$value") + } + for (bits in widths) { + val (mine, theirs) = randomOf(bits) + assertEquals(theirs, PortableUBigInt.ofBytes(mine.toMinimalBytes()).toBig(), "$bits") + } + } +} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt new file mode 100644 index 0000000000..f1890770f7 --- /dev/null +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/bigint/UBigInt.native.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.bigint + +/** + * [UBigInt] over [PortableUBigInt], for Apple and Linux, which have no big + * integer to borrow. + * + * Slower than the JVM's by 3 to 10 times on the operands a Cantor tree reaches, + * and identical in what it produces — which is the part that matters, because + * §7.2 turns it into a key. `PortableUBigIntDifferentialTest` is what holds the + * two together. + */ +actual class UBigInt internal constructor( + internal val raw: PortableUBigInt, +) : Comparable { + actual val bitLength: Int get() = raw.bitLength + + actual val isZero: Boolean get() = raw.isZero + + actual operator fun plus(other: UBigInt): UBigInt = UBigInt(raw + other.raw) + + actual operator fun times(other: UBigInt): UBigInt = UBigInt(raw * other.raw) + + actual fun shiftRight(bits: Int): UBigInt = UBigInt(raw.shiftRight(bits)) + + actual fun toMinimalBytes(): ByteArray = raw.toMinimalBytes() + + actual override fun compareTo(other: UBigInt): Int = raw.compareTo(other.raw) + + actual override fun equals(other: Any?): Boolean = other is UBigInt && raw == other.raw + + actual override fun hashCode(): Int = raw.hashCode() + + override fun toString(): String = "UBigInt($bitLength bits)" + + actual companion object { + actual val ZERO: UBigInt = UBigInt(PortableUBigInt.ZERO) + actual val ONE: UBigInt = UBigInt(PortableUBigInt.ONE) + + actual fun of(value: Long): UBigInt = UBigInt(PortableUBigInt.of(value)) + + actual fun ofBytes(bytes: ByteArray): UBigInt = UBigInt(PortableUBigInt.ofBytes(bytes)) + } +} From 2c029708d9725c4f6518591a084c25e6869e22d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:25:14 +0000 Subject: [PATCH 24/38] feat: read a bag's hint and price the search it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3. CyberspaceHint parses §7.7's hinted box, checks every rule the section makes normative, and prices the sweep. The harness is 13 of 13, the new section running §7.7's three golden vectors through `amy cyberspace hint` and diffing them against the spec's own hint-reference.py. Those 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 §10 then requires — so building a hint from the point has to reproduce the published tag byte for byte. london_h5_box11 as a cube, london_h5_x_exact as a two-dimensional hunt with X pinned exactly, and ideaspace_h8_y_open with an axis left open above the sector shift, which is why it carries X and Z and neither Y nor S. A hint is the hider's difficulty knob, so what the type reports is the gap: (Hx - h) + (Hy - h) + (Hz - h), as an exponent rather than a count, because it runs to 255 when all three axes are open and nothing holds 2^255. The trees are reported apart from the candidates and the asymmetry is the point — a sweep needs one Cantor tree per distinct base per axis and 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 can say it with a number, and a budget priced by trees under-quotes by that margin. read() returns null and never throws, for all of: wrong arity, bad hex, uppercase hex, an unaligned base, a height outside [0,85], a non-canonical integer, a height below the bag's h, and more than one hint tag. §7.7 is explicit that a broken hint is an absent one and "never invalidates the bag, because hints are advisory metadata about where to look". The canonical-integer rule earns its place for the same reason the aligned base does: both exist so two hiders who hint the same box publish the same bytes, and a reader that takes "05" alongside "5" lets one box have two spellings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 +- .../cli/commands/CyberspaceCommands.kt | 62 ++++- cli/tests/sno/refdriver.py | 18 ++ cli/tests/sno/sno-conformance.sh | 46 ++++ .../2026-09-22-cyberspace-region-bags.md | 49 +++- .../quartz/cyberspace/CyberspaceHint.kt | 228 ++++++++++++++++++ .../quartz/cyberspace/CyberspaceHintTest.kt | 223 +++++++++++++++++ 7 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index c7c06b1450..9e5408d61b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -517,7 +517,7 @@ private fun printUsage() { | 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 Simple Nostr Objects (DECK-0003): validate, price, verify an avatar - | cyberspace Cyberspace places and region keys (CYBERSPACE_V2 §2, §7.2) + | cyberspace Cyberspace places, region keys and hint boxes (§2, §7.2, §7.7) | 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt index 99535387a7..be54fceca5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt @@ -24,8 +24,10 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.cyberspace.CantorTree 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.nip01Core.core.Event /** * `amy cyberspace …` — places and the keys they derive, local and accountless. @@ -45,6 +47,7 @@ object CyberspaceCommands { | | 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) | |COORD_HEX is 32 bytes of lowercase hex, as a `C` or `hint` tag carries it. | @@ -57,10 +60,11 @@ object CyberspaceCommands { route( "cyberspace", tail, - "cyberspace ", + "cyberspace ", mapOf( "coord" to { rest -> coord(rest) }, "region" to { rest -> region(rest) }, + "hint" to { rest -> hint(rest) }, ), USAGE, ) @@ -123,6 +127,62 @@ object CyberspaceCommands { 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): 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 + } + /** An 85-bit axis as decimal, from its two halves, without a big integer. */ private fun decimal( high: Long, diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py index 045ff659a7..5f71aa628a 100755 --- a/cli/tests/sno/refdriver.py +++ b/cli/tests/sno/refdriver.py @@ -13,6 +13,7 @@ and payment reference), and just re-emits what they answer. 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 Paths come from $CYBERSPACE_DIR and $CYBERSPACE_CLI_DIR. """ @@ -127,9 +128,26 @@ def cmd_region(): }) +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}) + + COMMANDS = { "cases": cmd_cases, "region": cmd_region, + "hints": cmd_hints, "vectors": cmd_vectors, "verdict": cmd_verdict, "work": cmd_work, diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index 91edb41cb6..cd052c09c5 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -336,6 +336,52 @@ else 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 + print_summary grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md index 0378f50c80..cd74376452 100644 --- a/quartz/plans/2026-09-22-cyberspace-region-bags.md +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -195,7 +195,8 @@ is not offered as a button at all — it is reported as out of reach. 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. +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`. 5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. @@ -261,3 +262,49 @@ 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt new file mode 100644 index 0000000000..c4a19678d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHint.kt @@ -0,0 +1,228 @@ +/* + * 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.quartz.cyberspace + +/** + * `CYBERSPACE_V2.md` §7.7 — the hider's clue as to where a bag is. + * + * A bag reveals nothing about its own location: its `d` tag is a hash of a + * hash (§7.2), and §7.7 is blunt about what that leaves. "Without more + * information, 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." + * + * A hint is the way out, and it is **a search, not a direction**. The hider + * publishes an aligned box, as coarse as they like; a seeker derives the region + * key of every candidate region inside it and asks a relay for each `lookup_id`. + * What makes that possible from anywhere is the sentence that governs this + * whole feature: + * + * > 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. + * + * So the hint is also the **difficulty knob**. The price is + * `2^((Hx - h) + (Hy - h) + (Hz - h))` candidates — [gapBits] — and the hider + * sets it by how far above the bag's own height they pitch each axis. At the + * bottom of the range, 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" ([isDestination]). At the top, a sector-only hint on a shallow bag is + * a gap of 75, which no one will ever sweep — that hint says where to travel, + * not where to search. + * + * A box need not be a cube. Equal heights make one; unequal ones make a slab or + * a column, and an axis at exactly the bag's height with two coarse ones turns + * the hunt into two dimensions. + */ +class CyberspaceHint( + /** The box's aligned base, and the plane it lies in. */ + val base: CyberspacePoint, + val heightX: Int, + val heightY: Int, + val heightZ: Int, +) { + /** + * The exponent of the candidate count: how many regions of the bag's height + * fit in this box, as a power of two. + * + * An exponent rather than a number because it runs to 255 — three axes left + * fully open — and nothing holds `2^255`. §7.7's own table is read in these + * terms: 12 is seconds, 24 is hours, 30 or more is "days to never". + */ + fun gapBits(bagHeight: Int): Int = (heightX - bagHeight) + (heightY - bagHeight) + (heightZ - bagHeight) + + /** The candidate count itself, when it fits, and null when the sweep is beyond counting. */ + fun candidates(bagHeight: Int): Long? { + val bits = gapBits(bagHeight) + return if (bits in 0..62) 1L shl bits else null + } + + /** + * How many distinct Cantor trees a sweep of this box has to build. + * + * Not the candidate count: §4.7 combines three independent per-axis roots, + * so a box needs one tree per distinct base *per axis* and then one pairing + * per candidate. That is `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` trees against + * `2^gap` combines — for an even box, a cube root of the candidates. The + * trees are the cheap half above about height 8, where a combine costs + * roughly ten times a root, so a budget that prices a sweep by its trees + * will under-quote badly. + */ + fun axisTrees(bagHeight: Int): Long? { + var total = 0L + for (height in intArrayOf(heightX, heightY, heightZ)) { + val bits = height - bagHeight + if (bits < 0 || bits > 40) return null + total += 1L shl bits + } + return total + } + + /** §7.7: three heights equal to the bag's name the region itself, so there is nothing to search. */ + fun isDestination(bagHeight: Int): Boolean = gapBits(bagHeight) == 0 + + /** + * Whether a region of [bagHeight] based at [regionBase] lies inside this box. + * + * §7.7 makes it two checks per axis, "because both the region and the box + * are aligned": the hint height is at least the bag's, and the two bases + * agree once both are shifted right by the hint height. + */ + fun contains( + regionBase: CyberspacePoint, + bagHeight: Int, + ): Boolean { + if (regionBase.plane != base.plane) return false + if (bagHeight > heightX || bagHeight > heightY || bagHeight > heightZ) return false + return regionBase.x.alignedBase(heightX) == base.x.alignedBase(heightX) && + regionBase.y.alignedBase(heightY) == base.y.alignedBase(heightY) && + regionBase.z.alignedBase(heightZ) == base.z.alignedBase(heightZ) + } + + /** + * The sector tags §10 requires a bag carrying this hint to publish. + * + * "It MUST carry the sector tag of each axis whose hint height is at most + * 30, computed from the box's base, and `S` when all three are fixed." An + * axis coarser than a sector has no determined sector and gets no tag — + * which is why the third golden vector, with `Hy = 40`, carries `X` and `Z` + * and neither `Y` nor `S`. + */ + fun sectorTags(): List> { + val out = mutableListOf>() + if (heightX <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("X", base.x.sector().toString())) + if (heightY <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("Y", base.y.sector().toString())) + if (heightZ <= CyberspaceCoordinate.SECTOR_SHIFT) out.add(arrayOf("Z", base.z.sector().toString())) + if (out.size == 3) out.add(arrayOf("S", base.sector())) + return out + } + + /** The `hint` tag this box writes as. */ + fun toTag(): Array = arrayOf(TAG, CyberspaceCoordinate.encode(base), heightX.toString(), heightY.toString(), heightZ.toString()) + + override fun equals(other: Any?): Boolean = + other is CyberspaceHint && + other.base == base && + other.heightX == heightX && + other.heightY == heightY && + other.heightZ == heightZ + + override fun hashCode(): Int = ((base.hashCode() * 31 + heightX) * 31 + heightY) * 31 + heightZ + + companion object { + const val TAG = "hint" + + /** A hint height names an axis, so it runs to the axis's own width (§7.7). */ + const val MAX_HEIGHT = CyberspaceCoordinate.AXIS_BITS + + /** + * The hint a bag carries, or null when it carries none — **or carries a + * broken one**. + * + * §7.7 is explicit that those are the same answer: "A `hint` tag that + * breaks any rule above MUST be treated as absent, meaning the bag is + * read as if it carried no hint... 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 every check below returns null rather than throwing, and a caller + * that gets null sweeps nothing and still reads the bag. + * + * @param bagHeight the `h` tag of §8.6, when the bag carries one. Its + * only job here is the last rule: "When the bag carries an `h` tag, + * each hint height MUST therefore be at least `h`; a box smaller than + * the region could not contain it." + */ + fun read( + tags: Array>, + bagHeight: Int? = null, + ): CyberspaceHint? { + val hints = tags.filter { it.isNotEmpty() && it[0] == TAG } + // "A bag MUST carry at most one `hint` tag." Two is a bag whose + // author cannot be read literally, and §7.7's own remedy for a rule + // broken is to read the bag as if it had no hint at all. + if (hints.size != 1) return null + return read(hints[0], bagHeight) + } + + /** One `hint` tag, or null when it breaks any rule of §7.7. */ + fun read( + tag: Array, + bagHeight: Int? = null, + ): CyberspaceHint? { + if (tag.size != 5 || tag[0] != TAG) return null + + val heightX = canonicalHeight(tag[2]) ?: return null + val heightY = canonicalHeight(tag[3]) ?: return null + val heightZ = canonicalHeight(tag[4]) ?: return null + + if (bagHeight != null && (heightX < bagHeight || heightY < bagHeight || heightZ < bagHeight)) return null + + val point = CyberspaceCoordinate.decode(tag[1]) ?: return null + // "It MUST be the aligned base: the low `H` bits of each axis MUST + // be zero, and an axis with `H = 85` MUST be `0`." Requiring the + // base is what lets two hiders who hint the same box publish the + // same tag, so readers can compare hints by equality. + if (point.x.alignedBase(heightX) != point.x) return null + if (point.y.alignedBase(heightY) != point.y) return null + if (point.z.alignedBase(heightZ) != point.z) return null + + return CyberspaceHint(point, heightX, heightY, heightZ) + } + + /** + * A height as §10 writes one: base ten, no sign, no leading zeros + * except `"0"` itself, and inside `[0, 85]`. + * + * The canonical form matters for the same reason the aligned base does: + * `"05"` and `"5"` are the same number and two different tags, and a + * reader that accepts both lets one box have two spellings. + */ + private fun canonicalHeight(text: String): Int? { + if (text.isEmpty()) return null + if (text.length > 1 && text[0] == '0') return null + for (c in text) if (c !in '0'..'9') return null + val value = text.toIntOrNull() ?: return null + return if (value in 0..MAX_HEIGHT) value else null + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt new file mode 100644 index 0000000000..a5f81b59bf --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceHintTest.kt @@ -0,0 +1,223 @@ +/* + * 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.quartz.cyberspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * §7.7's hinted box, against the three golden vectors the section publishes. + * + * Those vectors are produced by `hint-reference.py` and they pin more than a + * parser: each gives a point, a bag height and three hint heights, and states + * the coordinate the aligned base must come out as and the sector tags the bag + * must then carry. So building a hint from the point reproduces the published + * tag byte for byte, or the alignment is wrong. + */ +class CyberspaceHintTest { + /** §9.8's london vector, which the first two hint vectors are taken from. */ + private val london = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940" + + /** §7.7: "The ideaspace point is x = 2^84 + 12345, y = 3 * 2^80 + 777, z = 2^85 - 1 - 4242 on plane 1." */ + private val ideaspace = "a4b64924924924924924924924924924924924924924924924924d84b60d9c8f" + + private fun hintOf( + coordinate: String, + x: Int, + y: Int, + z: Int, + ): CyberspaceHint { + val point = CyberspaceCoordinate.decode(coordinate) + assertNotNull(point) + return CyberspaceHint( + CyberspacePoint(point.x.alignedBase(x), point.y.alignedBase(y), point.z.alignedBase(z), point.plane), + x, + y, + z, + ) + } + + @Test + fun londonAtElevenReproducesItsPublishedTag() { + // `london_h5_box11`: bag at height 5, heights 11/11/11, 2^18 candidates. + val hint = hintOf(london, 11, 11, 11) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(18, hint.gapBits(5)) + assertEquals(1L shl 18, hint.candidates(5)) + assertFalse(hint.isDestination(5)) + + assertEquals( + listOf( + listOf("X", "18014398541305938"), + listOf("Y", "18014398549232983"), + listOf("Z", "18014398509410999"), + listOf("S", "18014398541305938-18014398549232983-18014398509410999"), + ), + hint.sectorTags().map { it.toList() }, + ) + } + + @Test + fun londonWithAnExactAxisReproducesItsPublishedTag() { + // `london_h5_x_exact`: heights 5/14/14. §7.7 calls this "a + // two-dimensional hunt: X is exact, so the seeker sweeps a 2^9 by 2^9 + // slab of height-5 regions" — still 2^18, reached a different way. + val hint = hintOf(london, 5, 14, 14) + assertEquals( + "c492492492492492492492edf5bee7267451c787d95ba4d7840c749041240000", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(18, hint.gapBits(5)) + // Every axis is at or under the sector shift, so all four tags, and the + // same ones as the cube: a sector is decided by bits above 30, which + // neither height touches. + assertEquals(4, hint.sectorTags().size) + assertEquals("18014398541305938-18014398549232983-18014398509410999", hint.sectorTags()[3][1]) + } + + @Test + fun anOpenAxisReproducesItsTagAndDropsItsSector() { + // `ideaspace_h8_y_open`: bag at height 8, heights 12/40/12, 2^40 + // candidates — "far beyond any search; that hint tells the seeker where + // to travel". Hy is above the sector shift, so no `Y` and no `S`. + val hint = hintOf(ideaspace, 12, 40, 12) + assertEquals( + "a4b64924924924924924924924924924924924924924924924924d8000000001", + CyberspaceCoordinate.encode(hint.base), + ) + assertEquals(40, hint.gapBits(8)) + assertEquals(1L shl 40, hint.candidates(8)) + + assertEquals( + listOf(listOf("X", "18014398509481984"), listOf("Z", "36028797018963967")), + hint.sectorTags().map { it.toList() }, + ) + } + + @Test + fun aTagRoundTripsThroughTheReader() { + for (hint in listOf(hintOf(london, 11, 11, 11), hintOf(london, 5, 14, 14), hintOf(ideaspace, 12, 40, 12))) { + assertEquals(hint, CyberspaceHint.read(hint.toTag())) + assertEquals(hint, CyberspaceHint.read(arrayOf(hint.toTag()), bagHeight = 5)) + } + } + + @Test + fun aHintAtTheBagsOwnHeightIsADestinationRatherThanASearch() { + // §7.7: "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." + val hint = hintOf(london, 5, 5, 5) + assertTrue(hint.isDestination(5)) + assertEquals(0, hint.gapBits(5)) + assertEquals(1L, hint.candidates(5)) + assertEquals(3L, hint.axisTrees(5), "one tree per axis and no more") + } + + @Test + fun aSweepCostsTreesPerAxisAndCombinesPerCandidate() { + // The decomposition of §4.7: the axes are independent up to the combine, + // so an even box needs a cube root of its candidates in trees. + val hint = hintOf(london, 11, 11, 11) + assertEquals(1L shl 18, hint.candidates(5)) + assertEquals(3L * (1L shl 6), hint.axisTrees(5), "2^6 bases on each of three axes") + } + + @Test + fun aSweepBeyondCountingSaysSoRatherThanWrapping() { + // A sector-only hint on a shallow bag is a gap of 75, "about 2^75 + // candidates, which no one will sweep". Nothing holds that, and a + // silently wrapped count would be quoted to a reader as a small number. + val hint = hintOf(london, 30, 30, 30) + assertEquals(75, hint.gapBits(5)) + assertNull(hint.candidates(5)) + + // The trees, though, are still countable, and that asymmetry is the + // point of reporting them apart: 25 bits on each axis is a hundred + // million Cantor trees against 2^75 combines. Both say the sweep is + // hopeless; only one of them can say it with a number. + assertEquals(3L * (1L shl 25), hint.axisTrees(5)) + + // Past 40 bits on a single axis even the trees stop fitting anything + // worth quoting, and that is where null starts. + assertNull(hintOf(london, 50, 30, 30).axisTrees(5)) + } + + @Test + fun containmentIsTwoChecksPerAxis() { + val box = hintOf(london, 11, 11, 11) + val point = CyberspaceCoordinate.decode(london)!! + + // The region the hint was built around, at the bag's height. + assertTrue(box.contains(point.alignedBase(5), 5)) + // A region one step outside the box on X. + val outside = CyberspacePoint(CyberspaceAxis(point.x.high, point.x.low + (1L shl 11)), point.y, point.z, point.plane) + assertFalse(box.contains(outside.alignedBase(5), 5)) + // §7.7: the claim is about a plane as well as a place. + val elsewhere = CyberspacePoint(point.x, point.y, point.z, CyberspacePlane.IDEASPACE) + assertFalse(box.contains(elsewhere.alignedBase(5), 5)) + // "A box smaller than the region could not contain it." + assertFalse(box.contains(point.alignedBase(12), 12)) + } + + @Test + fun aBrokenHintIsAbsentRatherThanFatal() { + // §7.7: "A `hint` tag that breaks any rule above MUST be treated as + // absent... A bad hint never invalidates the bag." + val good = hintOf(london, 11, 11, 11).toTag() + + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "11", "11")), "wrong arity") + assertNull(CyberspaceHint.read(arrayOf("hint", "nonsense", "11", "11", "11")), "bad hex") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1].uppercase(), "11", "11", "11")), "not lowercase") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "86", "11", "11")), "height past the axis") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "-1", "11", "11")), "signed") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "011", "11", "11")), "leading zero") + assertNull(CyberspaceHint.read(arrayOf("hint", good[1], "", "11", "11")), "empty") + // The base is not aligned to the heights it claims. + assertNull(CyberspaceHint.read(arrayOf("hint", london, "11", "11", "11")), "unaligned base") + // "each hint height MUST therefore be at least `h`". + assertNull(CyberspaceHint.read(arrayOf(good), bagHeight = 12), "box smaller than the region") + assertNotNull(CyberspaceHint.read(arrayOf(good), bagHeight = 11), "exactly the region is allowed") + + // "A bag MUST carry at most one `hint` tag." + assertNull(CyberspaceHint.read(arrayOf(good, good)), "two hints") + assertNull(CyberspaceHint.read(arrayOf(arrayOf("d", "abc"))), "no hint at all") + } + + @Test + fun anAxisLeftFullyOpenHasABaseOfZero() { + // §7.7: "A height of 85 leaves an axis open: the base is 0, the box + // spans the whole axis, and the hint says nothing about that coordinate." + val hint = hintOf(london, 85, 11, 11) + assertEquals(0L, hint.base.x.high) + assertEquals(0L, hint.base.x.low) + assertEquals(hint, CyberspaceHint.read(hint.toTag())) + // And an open axis is far past the sector shift, so it carries no tag. + assertEquals(listOf("Y", "Z"), hint.sectorTags().map { it[0] }) + } +} From 39658625c58eebd5ddda15e3bada87327d91d1e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:16:03 +0000 Subject: [PATCH 25/38] feat: open a region bag, and sweep a hint box to find one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind 33330 is §7.6's bag: one addressable event holding everything an author hid in one region, addressed by a hash of a hash so the relay can hand it to anyone and it buys them nothing. CyberspaceBagEvent reads it — the §8.6 tags, the AES-256-GCM payload, and the two plaintext shapes §7.6 allows, with an item that fails its signature check dropped alone because "one corrupt or forged item says nothing about the others". A wrong key returns null rather than throwing: §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in the bag". RegionSweep is §7.7's position-free search. The axes are swept independently up to the combine, which is what makes it affordable: §4.7 builds region_n from three per-axis Cantor roots, so a box costs 2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h) trees rather than a whole three-tree key per candidate. It is a cold Sequence on purpose — a hint is a stranger's choice of difficulty, and the caller's budget is the only defence. amy gains `cyberspace open` and `cyberspace sweep`, and the conformance harness gains the two sections that matter most here: cyberspace-cli seals a plaintext with its own key derivation, its own cipher and its own event builder, and amy has to arrive at the same 32 bytes from the coordinate alone — then find the same bag again from nothing but its hint box. 15/15 sections pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../com/vitorpamplona/amethyst/cli/Main.kt | 3 +- .../cli/commands/CyberspaceCommands.kt | 244 ++++++++++++++++- cli/tests/sno/refdriver.py | 69 +++++ cli/tests/sno/sno-conformance.sh | 126 ++++++++- .../quartz/cyberspace/CyberspaceBagEvent.kt | 246 ++++++++++++++++++ .../quartz/cyberspace/RegionSweep.kt | 138 ++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../cyberspace/CyberspaceBagEventTest.kt | 214 +++++++++++++++ .../quartz/cyberspace/RegionSweepTest.kt | 138 ++++++++++ 9 files changed, 1176 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 9e5408d61b..6d7207d0f9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -517,7 +517,8 @@ private fun printUsage() { | 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 Simple Nostr Objects (DECK-0003): validate, price, verify an avatar - | cyberspace Cyberspace places, region keys and hint boxes (§2, §7.2, §7.7) + | cyberspace Cyberspace places (§2), region keys (§7.2), hint boxes + | (§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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt index be54fceca5..cdfd3d34be 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CyberspaceCommands.kt @@ -23,11 +23,18 @@ 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. @@ -48,23 +55,38 @@ object CyberspaceCommands { | 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): Int = route( "cyberspace", tail, - "cyberspace ", + "cyberspace ", 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, ) @@ -183,6 +205,226 @@ object CyberspaceCommands { 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): 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): 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() 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, diff --git a/cli/tests/sno/refdriver.py b/cli/tests/sno/refdriver.py index 5f71aa628a..5ef34f02d4 100755 --- a/cli/tests/sno/refdriver.py +++ b/cli/tests/sno/refdriver.py @@ -14,6 +14,8 @@ and payment reference), and just re-emits what they answer. 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. """ @@ -144,8 +146,75 @@ def cmd_hints(): 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, diff --git a/cli/tests/sno/sno-conformance.sh b/cli/tests/sno/sno-conformance.sh index cd052c09c5..46f59c8c39 100755 --- a/cli/tests/sno/sno-conformance.sh +++ b/cli/tests/sno/sno-conformance.sh @@ -3,7 +3,7 @@ # sno-conformance.sh — diffs amy's DECK-0003 reader against the cyberspace # project's own reference implementations. No relay, no account, no device. # -# Three comparisons, each driven by the reference's own fixtures rather than +# 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 @@ -19,6 +19,12 @@ # 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 @@ -47,7 +53,7 @@ NO_BUILD=0 while [[ $# -gt 0 ]]; do case "$1" in --no-build) NO_BUILD=1 ;; - -h|--help) sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;; + -h|--help) sed -n '3,33p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; esac shift @@ -382,6 +388,122 @@ else 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt new file mode 100644 index 0000000000..810f28bfb8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEvent.kt @@ -0,0 +1,246 @@ +/* + * 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.quartz.cyberspace + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.serialization.json.JsonArray +import kotlin.io.encoding.Base64 + +/** + * One item out of an opened bag (`CYBERSPACE_V2.md` §7.6). + * + * [verified] is the only thing that decides whose words these are. §7.6: an item + * "MAY be signed. If it carries a `sig`, its `id` MUST be the canonical id + * (§8.2) and the signature MUST verify... An item without a `sig` is allowed, + * because some content is deliberately left unsigned; its `pubkey` is then a + * claim, and readers MUST NOT present it as verified." + * + * So placement is always attributable to the bag's author, and authorship of the + * content only to [Event.pubKey] and only when this is true. "A signed item + * written by one key and hidden by another is therefore shown as that author's + * content, placed here by the hider." + */ +@Immutable +class CyberspaceBagItem( + val event: Event, + val verified: Boolean, +) { + /** + * §7.6: an item MAY carry a `C` tag, "its exact coordinate (§2), which lets + * a client render it at a point rather than somewhere in the region." + */ + fun coordinate(): String? = event.tags.firstOrNull { it.size > 1 && it[0] == "C" }?.get(1) +} + +/** What a bag turned out to hold, once its key was found (§7.6). */ +@Immutable +sealed class CyberspaceBagContents { + /** + * "A JSON array whose elements are nostr events, signed or unsigned. + * Readers MUST try this shape first, because a list of items is the shape + * clients render item by item." + */ + @Immutable + class Items( + val items: List, + /** How many elements were dropped for a bad id or signature. */ + val dropped: Int, + ) : CyberspaceBagContents() + + /** + * "Anything that is not a list of items, such as a text note or a file. Its + * interpretation is application-defined." + */ + @Immutable + class Opaque( + val bytes: ByteArray, + ) : CyberspaceBagContents() +} + +/** + * `CYBERSPACE_V2.md` §7.6 and §8.6 — content hidden at a place. + * + * A bag is one addressable event holding everything its author has hidden in one + * region at one height. The ciphertext is public and a relay will hand it to + * anyone; only someone who has computed that region's key can open it, "whether + * they computed it by moving into the region or by deriving it for the + * coordinate directly". That is §7.1's chalk on the sidewalk: not secrecy, but a + * thing you cannot read without paying the cost of being where it is. + * + * The `d` tag is the region's `lookup_id` — a hash of the key (§7.2) — so the + * address is safe to publish and buys nothing on its own. That is also what a + * §7.7 sweep queries on: derive a candidate region's key, hash it, ask for the + * bag with that `d`. + * + * **A bag that will not open is not a broken bag.** §7.6 is explicit: "An + * attempt with the wrong key fails at the GCM tag check and reveals nothing + * about the plaintext. 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 [open] returns null and a client says nothing. + */ +@Immutable +class CyberspaceBagEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The `d` tag: this region's `lookup_id` (§7.2), and the address a sweep asks for. */ + fun lookupId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) + + /** + * The `h` tag: "the height of the region whose key encrypts the content, + * which is the discovery radius of §7.3". Optional, and a hint is read + * against it. + */ + fun height(): Int? = tags.firstOrNull { it.size > 1 && it[0] == "h" }?.get(1)?.toIntOrNull() + + /** + * §8.6: "`version` names the rules of §7.6. A reader MUST ignore a bag whose + * version it does not know." + */ + fun isKnownVersion(): Boolean = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.get(1) == VERSION + + fun hint(): CyberspaceHint? = CyberspaceHint.read(tags, height()) + + /** + * The `encrypted` tag's payload, or null when the bag carries none or names + * a cipher this does not implement. + * + * §7.6 fixes the cipher: AES-256-GCM, a fresh 12-byte nonce, a 16-byte tag, + * `nonce || ciphertext || tag`, base64. Only the lookup and the key are + * normative across the protocol; the cipher is the convention the reference + * CLI and ONOSENDAI share, so a bag naming a different one is somebody + * else's format rather than a malformed bag. + */ + fun payload(): ByteArray? { + val tag = tags.firstOrNull { it.size > 2 && it[0] == "encrypted" } ?: return null + if (tag[1] != ALGORITHM) return null + return try { + Base64.decode(tag[2]) + } catch (_: Exception) { + null + } + } + + /** + * Open this bag with a region key, or null when the key is not this + * region's — which §7.6 says is not an error and reveals nothing. + * + * @param key the 32-byte `location_decryption_key` of §7.2. + */ + fun open(key: ByteArray): CyberspaceBagContents? { + if (!isKnownVersion()) return null + if (key.size != KEY_BYTES) return null + + val payload = payload() ?: return null + if (payload.size < NONCE_BYTES + TAG_BYTES) return null + + val nonce = payload.copyOfRange(0, NONCE_BYTES) + val sealed = payload.copyOfRange(NONCE_BYTES, payload.size) + val plaintext = + try { + AESGCM(key, nonce).decryptOrNull(sealed) + } catch (_: Exception) { + null + } ?: return null + + return read(plaintext) + } + + companion object { + const val KIND = 33330 + + /** §7.6's cipher, as the `encrypted` tag names it. */ + const val ALGORITHM = "aes-256-gcm" + + /** §8.6's `version` tag: the rules of §7.6. */ + const val VERSION = "2" + + const val KEY_BYTES = 32 + const val NONCE_BYTES = 12 + const val TAG_BYTES = 16 + + /** + * §7.6's two plaintext shapes, in the order it requires: "Readers MUST + * try this shape first, because a list of items is the shape clients + * render item by item." + */ + fun read(plaintext: ByteArray): CyberspaceBagContents = readItems(plaintext) ?: CyberspaceBagContents.Opaque(plaintext) + + /** + * A JSON array of nostr events, each verified on its own. + * + * §7.6: "a reader MUST drop an item that fails either check, **and only + * that item**, because one corrupt or forged item says nothing about the + * others." So a bad element costs itself and nothing else — and a + * plaintext that is not an array of events at all is not a list, which + * makes it opaque rather than broken. + */ + private fun readItems(plaintext: ByteArray): CyberspaceBagContents.Items? { + val text = plaintext.decodeToString() + if (text.trimStart().firstOrNull() != '[') return null + + val elements = + try { + KotlinSerializationMapper.json.parseToJsonElement(text) as? JsonArray + } catch (_: Exception) { + null + } ?: return null + + val items = mutableListOf() + var dropped = 0 + var understood = 0 + for (element in elements) { + val event = + try { + Event.fromJson(element.toString()) + } catch (_: Exception) { + // Not an event at all. A plaintext of arbitrary JSON is + // not "a list of items", so this is not a dropped item; + // it is evidence the whole shape is the other one. + return null + } + understood++ + if (event.sig.isBlank()) { + // "An item without a `sig` is allowed, because some content + // is deliberately left unsigned; its `pubkey` is then a + // claim." Kept, and marked. + items.add(CyberspaceBagItem(event, verified = false)) + } else if (event.verify()) { + items.add(CyberspaceBagItem(event, verified = true)) + } else { + dropped++ + } + } + if (understood == 0) return null + return CyberspaceBagContents.Items(items, dropped) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt new file mode 100644 index 0000000000..a791fd602c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweep.kt @@ -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.quartz.cyberspace + +import com.vitorpamplona.quartz.utils.bigint.UBigInt + +/** + * Sweeping the box a hint names (`CYBERSPACE_V2.md` §7.7). + * + * > A seeker who trusts a hint sweeps the box: for every candidate region of + * > height `h` inside it, derive the region key (§7.2), compute its + * > `lookup_id`, and check the relay for a bag with that `d` tag (one batched + * > query can carry many lookup ids). + * + * **The axes are swept independently up to the combine**, which is the whole + * reason this is affordable. §4.7 builds `region_n` from three per-axis Cantor + * roots, so a box needs one tree per distinct base *per axis* — + * `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` of them — and then one pairing per candidate, + * rather than a whole three-tree key each time. For an even box that is a cube + * root of the candidates in tree work. + * + * **It is a cold [Sequence] on purpose.** The caller holds the budget, because + * the budget is the only defence: a hint is a stranger's choice of difficulty, + * and a bag can carry a gap of 30 precisely to burn a day of a reader's battery. + * Nothing here starts work until something pulls, and a caller that stops + * pulling has stopped paying. Price the hint with [CyberspaceHint.gapBits] + * *before* the first pull. + * + * Nothing here talks to a relay. What comes out is `lookup_id`s and the keys + * that made them; asking for the bags, in batches, is the caller's job. + */ +object RegionSweep { + /** + * Every candidate region in [hint]'s box, as the key material that finds it. + * + * Ordered so that the axis roots are built once each and reused across the + * combines they take part in: X outermost, then Y, then Z. A caller pulling + * the first item therefore pays for `2^(Hx-h) + 2^(Hy-h) + 2^(Hz-h)` trees + * and one combine, and each item after that is one combine and two hashes. + * + * @param bagHeight the `h` of §8.6 — the height of the region the bag is + * keyed to, and the size of the candidates this enumerates. + */ + fun of( + hint: CyberspaceHint, + bagHeight: Int, + maxComputeHeight: Int = CantorTree.DEFAULT_MAX_COMPUTE_HEIGHT, + ): Sequence { + require(bagHeight >= 0) { "height must be >= 0" } + require(bagHeight <= hint.heightX && bagHeight <= hint.heightY && bagHeight <= hint.heightZ) { + "a box smaller than the region cannot contain it (§7.7)" + } + + return sequence { + val xs = axisRoots(hint.base.x, hint.heightX, bagHeight, maxComputeHeight) + val ys = axisRoots(hint.base.y, hint.heightY, bagHeight, maxComputeHeight) + val zs = axisRoots(hint.base.z, hint.heightZ, bagHeight, maxComputeHeight) + + for (x in xs) { + for (y in ys) { + val xy = CantorTree.cantorPair(x, y) + for (z in zs) { + yield(RegionKey.derive(CantorTree.cantorPair(xy, z), bagHeight)) + } + } + } + } + } + + /** + * The Cantor root of every aligned subtree of [bagHeight] inside this axis's + * span of the box. + * + * The bases step by `2^bagHeight` from the box's own base, which is where + * §7.7's "both the region and the box are aligned" pays off: the candidates + * on an axis are exactly the `2^(H - h)` aligned positions in it, with + * nothing to search between them. + */ + private fun axisRoots( + base: CyberspaceAxis, + boxHeight: Int, + bagHeight: Int, + maxComputeHeight: Int, + ): List { + val steps = boxHeight - bagHeight + require(steps <= MAX_AXIS_STEPS) { "an axis of 2^$steps candidates is past anything a client sweeps" } + + val count = 1L shl steps + val out = ArrayList(count.toInt()) + var position = base + for (i in 0 until count) { + out.add(CantorTree.subtreeRoot(position.toUBigInt(), bagHeight, maxComputeHeight)) + position = position.plusShifted(bagHeight) + } + return out + } + + /** + * How far a single axis may be swept before this refuses. + * + * Not a judgement about difficulty — §7.7 lets a hider set that — but about + * memory: the roots of one axis are held while the other two are walked, and + * a root at height 12 is forty kilobytes. A million of them is not a slow + * search, it is a dead process, and the caller's own budget should have + * stopped long before here. + */ + const val MAX_AXIS_STEPS = 20 +} + +/** This axis with `2^height` added: the next aligned position along it. */ +private fun CyberspaceAxis.plusShifted(height: Int): CyberspaceAxis { + if (height >= Long.SIZE_BITS) { + return CyberspaceAxis(high + (1L shl (height - Long.SIZE_BITS)), low) + } + val step = 1L shl height + val sum = low + step + // Unsigned overflow: the sum wrapped past 2^64, so carry into the high half. + val carried = (low.toULong() > sum.toULong()) + return CyberspaceAxis(if (carried) high + 1 else high, sum) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 71832a5553..d597a994b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +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 @@ -652,6 +653,7 @@ class EventFactory { FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + CyberspaceBagEvent.KIND -> CyberspaceBagEvent(id, pubKey, createdAt, tags, content, sig) SnoObjectEvent.KIND -> SnoObjectEvent(id, pubKey, createdAt, tags, content, sig) SnoAvatarEvent.KIND -> SnoAvatarEvent(id, pubKey, createdAt, tags, content, sig) SnoShardEvent.KIND -> SnoShardEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt new file mode 100644 index 0000000000..01bf939118 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/CyberspaceBagEventTest.kt @@ -0,0 +1,214 @@ +/* + * 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.quartz.cyberspace + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * §7.6's bag, opened with a key the reference implementation would derive and a + * payload the reference implementation encrypted. + * + * The two vectors below were produced by `cryptography`'s AES-256-GCM, the same + * primitive `cyberspace-cli`'s `encrypt_with_location_key` calls, under the + * london height-4 key that `RegionKeyTest` already pins against the reference. + * So a pass here means the whole chain holds end to end: their coordinate, their + * Cantor roots, their key derivation, their cipher, our reader. + * + * The item list is deliberately mixed — a signed note, an unsigned item, a + * forged copy of the note with one byte of content changed, and a signed shard — + * because §7.6's rule about a bad item is the one most likely to be implemented + * as "reject the bag". + */ +class CyberspaceBagEventTest { + /** The key of the london vector at height 4, from `RegionKeyTest`. */ + private val key = Hex.decode("314ade123f63601cc69a0addf455ea5c2d84ea848b84285aee5a00941e316ab4") + + private val itemsPayload = "AAECAwQFBgcICQoLikF2BmXHqvoLgsTTZr3X9aZ0QQMXvpkcOPEtBi/voCzJmyQSBWKQ+F72zKwph7HgxRVzBB9h/0AXE3vvfYr9GO1hA39fx+PMxXMpQIIm8mgbAwjXaNHptMO5JoP4BaIK6+/CKlkisTnWlgKZv3c4NaANGgC2A6ul4F/ArroMbfhnt/dgRsQ8+LYmGShg88UbIV5tgSNIyanpd3ORcJy3hEsBmMBIfLetGh42TTbG7K36A7VBmTDpoymJrhqbrGaLbiSYfqgDIeQ6aKo74qBy7vg+YDFdP895RI/VvuiXYFCKk+DaDiWUG4exYsMNR1XZr6ECpn9F9WCFh3o1DATzYmYvDCkwM66euqcwk7D0wURBZTEmHJlfyAliCxWtIK5AdZucelvWO1v1geyoQ/3SaZ6cgfm1qQn3aEuK5cVCEEUiaaBd6zdn1JiLfVkHo/0xCkr4VQNo9dnLNh46iY/LO1IIsgjity5qYYaYMpFgUs9oFUeJ1v1tBMT0cogZ0rbFD2TmJ8MWPYT5BilG/gzjAWLYc8vEXJH4hbxGVVuitnQ5szAdJhD27TpRgEplcSmUBInJwkQQ2iY3F+baZp4M6QBNT3VKDbfL9XejqZECslH6RZ1zpla1+zGRvIT6724IlnuVgnpTy++EUJS8O6oFc6KNFfVryMwASOzd9geCCSQHf7ljhxESf98G36hyFGahMQyRfAorkWA99ulHERtNzUw6Su/WkvTUIcMBeAlbyuB5vluBddq2Z4IcLEbSn9gB2Ey5iRSCA1VifzhGSkZAgUZ7UB16FZdLFRmgjpUIdJBxy3XsL7fnl/TCQLoSat2IO157J7oe4pJ3qfrJX1ihqs0BTzGTdWNIR5slzQZIZiBelOyCxBVDslDCpn/av53CRZUpIkEXTkBQbQ+RNz1RGKVQLt/B01BfpFpAv1RoiWR7/B31/Hu2gHJv++/ey0Q6ByY7T6c/5SvAlDA55rZZwau27SG31cDu92ZLosqmHA+AvYirTjqY16UtAJJHaFMxYyb2TToUkHFK2DbMHIQuYacY3tITbnOpknTUZJgQfI0hFkSBfMZpRlod/1MT+tyzTeA9UCzf/HrEn0piOBvtdPs+eul/g5P7eAKStsY1ztZN37BeQT1LGplelYi/1Bu9Ok8uie3/uDMoixhnD3/taNo6UwCkX9eoS+Dt/y36+IFSEbQ38T5IDoiA9ZiLcESUZdaplWyOdPgPQhSrqFvMBR/iKvpW2gs/8rvgbNKBK08CUBFsTM9xhClBWG2MxHDWBcYzGue/1U687FF7VXU5T2HUOYLw53OZMdg6vw/z5JTssE3y5stjJZ3LTpPgIMinn4p3n5HG18prMfuF2ML9FuuvOvactdwxomW2LHsnM6XWKxEGcw+4/nZGti580uVYZZG5kqXptmyJ7GXXSM+vUEUSeC/wVDEEMPjcKLf/UlJKLrLNPLD92dRYut3xwCj3/v5ZuVlGjd/ZTUqGTLZJTXPMs73KcT+nORsz1JSi+nrmvgVjzuppY67dRP7mH5dB/YxueAT8VFPRr+bf+uii28MAXQk7p3sJ2RNNO/MhNG7eI+rEMhzrEJI0+34VZ1HuUwEk7DxTS7BmOgQQaLXZHMX7nbtGKRIVa9oxQc93ay/OTU66fR1W+UlXT6ED7aUzcpNKr2CaxWnhMpNJ2ELSBPpZIbON3Bjq0NKmI0s8V1cPX5HdzA40QzKOfWcuO5uE6apATYcuYmmQcauNJbZ3iX0QdQV8R4d0YFZh9ycFI6ybwD3yRVGwpM9vJZ1yOJE0gB2L4b9EEazSQB0Np9k67N8lNSuMKfp5TOJKr+Wk39Z+hLW6YMUFGM4e4DU9wqSZP6dlfeQkxHDasPqB2UzGYIb+zpIzfMJUd5zlz+VCdWg=" + + private val opaquePayload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA==" + + private fun bag( + payload: String, + vararg extra: Array, + ): 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), + ) + + @Test + fun aBagTheReferenceEncryptedOpensWithTheKeyTheReferenceDerives() { + val contents = bag(itemsPayload).open(key) + assertNotNull(contents, "the reference's own cipher, under the reference's own key") + assertTrue(contents is CyberspaceBagContents.Items, "a JSON array of events is the item shape (§7.6)") + } + + @Test + fun aForgedItemCostsItselfAndNothingElse() { + // §7.6: "a reader MUST drop an item that fails either check, and only + // that item, because one corrupt or forged item says nothing about the + // others." + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + assertEquals(1, contents.dropped, "the forged copy of the note") + assertEquals(3, contents.items.size, "the signed note, the unsigned item and the signed shard") + } + + @Test + fun anUnsignedItemIsKeptAndItsAuthorshipIsNotClaimed() { + // §7.6: "An item without a `sig` is allowed... its `pubkey` is then a + // claim, and readers MUST NOT present it as verified." + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + val unsigned = contents.items.single { it.event.sig.isBlank() } + assertFalse(unsigned.verified) + assertEquals("f".repeat(64), unsigned.event.pubKey, "the claim is carried, not endorsed") + // And the signed ones are verified, by the same field. + assertEquals(2, contents.items.count { it.verified }) + } + + @Test + fun anItemMayNameItsOwnPointInsideTheRegion() { + // §7.6: an item MAY carry a `C` tag, "which lets a client render it at a + // point rather than somewhere in the region". + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + val placed = contents.items.single { it.coordinate() != null } + assertEquals("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940", placed.coordinate()) + assertTrue(contents.items.count { it.coordinate() == null } > 0, "and an item without one is located no more precisely than the region") + } + + @Test + fun aKindTheReaderDoesNotKnowIsStillAnItem() { + // §7.6: "A reader that does not understand an item's `kind` skips it and + // renders the rest" — skipping is the renderer's job, so the reader + // hands over everything that verified. + val contents = bag(itemsPayload).open(key) as CyberspaceBagContents.Items + assertTrue(contents.items.any { it.event.kind == 3330 }, "the shard came through as an item") + assertTrue(contents.items.any { it.event.kind == 1 }) + } + + @Test + fun aPlaintextThatIsNotAListIsOpaque() { + // §7.6's other shape: "anything that is not a list of items, such as a + // text note or a file". + val contents = bag(opaquePayload).open(key) + assertTrue(contents is CyberspaceBagContents.Opaque) + assertEquals("just some words, not a list", contents.bytes.decodeToString()) + } + + @Test + fun theWrongKeyIsSilenceRatherThanAnError() { + // §7.6: "An attempt with the wrong key fails at the GCM tag check and + // reveals nothing about the plaintext. 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." + val wrong = ByteArray(32) { 7 } + assertNull(bag(itemsPayload).open(wrong)) + // And a key of the wrong size is not a key at all. + assertNull(bag(itemsPayload).open(ByteArray(16))) + } + + @Test + fun aVersionTheReaderDoesNotKnowIsIgnored() { + // §8.6: "`version` names the rules of §7.6. A reader MUST ignore a bag + // whose version it does not know." + val future = + CyberspaceBagEvent( + "a".repeat(64), + "b".repeat(64), + 1700000000, + arrayOf( + arrayOf("d", "abc"), + arrayOf("version", "3"), + arrayOf("encrypted", "aes-256-gcm", itemsPayload), + ), + "", + "0".repeat(128), + ) + assertFalse(future.isKnownVersion()) + assertNull(future.open(key), "a bag whose rules are unknown is not opened with this reader's") + } + + @Test + fun aCipherThisDoesNotImplementIsNotAMalformedBag() { + // Only the lookup and the key are normative across the protocol; §7.6's + // cipher is the convention the CLI and ONOSENDAI share. A bag naming + // another one is somebody else's format. + val other = + CyberspaceBagEvent( + "a".repeat(64), + "b".repeat(64), + 1700000000, + arrayOf(arrayOf("version", "2"), arrayOf("encrypted", "chacha20-poly1305", itemsPayload)), + "", + "0".repeat(128), + ) + assertNull(other.payload()) + assertNull(other.open(key)) + } + + @Test + fun theTagsThatMakeABagFindableAreRead() { + val hinted = + bag( + itemsPayload, + arrayOf("hint", "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d000000000", "11", "11", "11"), + ) + assertEquals("a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916", hinted.lookupId()) + assertEquals(4, hinted.height()) + assertTrue(hinted.isKnownVersion()) + + val hint = hinted.hint() + assertNotNull(hint) + // Read against the bag's own h, as §7.7 requires. + assertEquals(21, hint.gapBits(4)) + assertFalse(hint.isDestination(4)) + } + + @Test + fun aPayloadTooShortToHoldANonceAndATagIsNotOpened() { + // 12 + 16 bytes before there is any ciphertext at all; anything shorter + // is not this cipher's output. + val short = bag("AAECAwQFBgcICQoL") + assertNull(short.open(key)) + } + + @Test + fun anEventOfThisKindArrivesAsABag() { + val json = bag(opaquePayload).toJson() + assertTrue(Event.fromJson(json) is CyberspaceBagEvent, "kind 33330 is registered in the factory") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt new file mode 100644 index 0000000000..3b7754a539 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/RegionSweepTest.kt @@ -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.quartz.cyberspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * §7.7's sweep: the thing a seeker actually does with a hint. + * + * The test that matters is the round trip — hide a region, hint a box around it, + * sweep the box, and find the region's own `lookup_id` among the candidates. + * Everything else about this feature is preparation for that one answer. + */ +class RegionSweepTest { + private val london = CyberspaceCoordinate.decode("c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c9940")!! + + private fun boxAround( + point: CyberspacePoint, + height: Int, + ) = CyberspaceHint( + CyberspacePoint(point.x.alignedBase(height), point.y.alignedBase(height), point.z.alignedBase(height), point.plane), + height, + height, + height, + ) + + @Test + fun aSweepFindsTheRegionItsBoxWasDrawnAround() { + // A bag hidden at height 4, hinted with a box one height up: 2^3 = 8 + // candidates, one of which is the region itself. + val bagHeight = 4 + val hint = boxAround(london, 5) + assertEquals(3, hint.gapBits(bagHeight)) + + val wanted = RegionKey.at(london, bagHeight).lookupId + val swept = RegionSweep.of(hint, bagHeight).toList() + + assertEquals(8, swept.size, "2^((5-4) * 3) candidates") + assertTrue(swept.any { it.lookupId == wanted }, "the region the box was drawn around is in the box") + assertEquals(swept.size, swept.map { it.lookupId }.toSet().size, "every candidate is its own region") + assertTrue(swept.all { it.height == bagHeight }) + } + + @Test + fun aDestinationHintSweepsExactlyTheRegionItNames() { + // §7.7: "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." + val bagHeight = 6 + val hint = boxAround(london, bagHeight) + assertTrue(hint.isDestination(bagHeight)) + + val swept = RegionSweep.of(hint, bagHeight).toList() + assertEquals(1, swept.size) + assertEquals(RegionKey.at(london, bagHeight).lookupId, swept[0].lookupId) + } + + @Test + fun anUnevenBoxSweepsTheProductOfItsAxes() { + // A slab: X pinned exactly, two coarse axes. §7.7 calls this "a + // two-dimensional hunt". + val bagHeight = 3 + val hint = + CyberspaceHint( + CyberspacePoint(london.x.alignedBase(3), london.y.alignedBase(5), london.z.alignedBase(5), london.plane), + 3, + 5, + 5, + ) + assertEquals(4, hint.gapBits(bagHeight)) + + val swept = RegionSweep.of(hint, bagHeight).toList() + assertEquals(16, swept.size, "1 * 4 * 4") + assertTrue(swept.any { it.lookupId == RegionKey.at(london, bagHeight).lookupId }) + } + + @Test + fun nothingHappensUntilSomethingPulls() { + // The budget is the only defence against a hint a stranger chose, so a + // sweep has to be cold: building the sequence must not build a tree. + val hint = boxAround(london, 12) + val sequence = RegionSweep.of(hint, 4) + // 2^24 candidates. Constructing this is free; taking one is not, and + // taking all of them is what the caller's budget is for. + assertEquals(24, hint.gapBits(4)) + assertNotNull(sequence) + } + + @Test + fun aBoxSmallerThanTheRegionIsRefused() { + // §7.7: "a box smaller than the region could not contain it". + assertFailsWith { RegionSweep.of(boxAround(london, 4), 8).first() } + } + + @Test + fun anAxisPastWhatFitsInMemoryIsRefusedRatherThanAttempted() { + // Not a judgement about difficulty — the hider sets that — but about + // the roots of one axis being held while the other two are walked. + val hint = boxAround(london, RegionSweep.MAX_AXIS_STEPS + 5) + assertFailsWith { RegionSweep.of(hint, 0).first() } + } + + @Test + fun steppingAcrossTheAxisSplitStaysOnTheLattice() { + // An axis is two Longs, so a box whose candidates cross the 64-bit + // boundary is where a carry would go missing — and a wrong base is a + // key that opens nothing, silently. + val low = CyberspaceAxis(0L, -1L - 3L) // four steps below 2^64 + val point = CyberspacePoint(low, low, low, CyberspacePlane.DATASPACE) + val hint = CyberspaceHint(CyberspacePoint(low.alignedBase(2), low.alignedBase(2), low.alignedBase(2), point.plane), 2, 2, 2) + + val swept = RegionSweep.of(hint, 0).toList() + assertEquals(64, swept.size, "4 * 4 * 4") + assertEquals(swept.size, swept.map { it.lookupId }.toSet().size, "no two candidates collided across the split") + } +} From debf361f2b8ce14f1764879a478ff5d2262b6fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:33:03 +0000 Subject: [PATCH 26/38] feat: a card that prices a bag's search before it spends anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last layer of the region-bag work. A kind 33330 in a feed now renders a card that has already read its hint and says what the search costs: "Hidden in a box of 4096 regions", or that the box is out of reach. The quote is three subtractions on two tags, so it is free while the row composes, and a box past the caps never becomes a button at all — §7.7 lets a hider pick the difficulty, and a bag can carry a gap-30 hint precisely to burn a day of a reader's battery. The plan asked for the Android cost to be measured before any number was quoted in the UI. What ships measures it at the tap, on the device that is about to pay: the box's own base region is derived and timed, and only then is the rest committed to. That candidate is never wasted — a gap-0 hint names exactly it — and the estimate deliberately over-quotes, since the first candidate carries three axis roots that every later one reuses. Every sweep is a tap, including the gap-0 hint §7.7 itself calls "a destination the seeker can compute or walk to directly, not a search". A reader who learned that some bags open themselves would have learned an expectation a hostile bag could hide inside. Items render through the cards that already exist: a 3330 shard through SnoObjectCard, a kind 1 as its text, anything else named and skipped. The attribution follows §7.6 rather than intuition — placement belongs to the bag's author, authorship only to a signed item's own pubkey — so an unsigned item says its author is a claim instead of borrowing either name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- amethyst/plans/2026-09-21-deck-0003-sno.md | 27 +- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/CyberspaceBag.kt | 340 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + commons/ARCHITECTURE.md | 2 + .../amethyst/commons/cyberspace/BagSweep.kt | 272 ++++++++++++++ .../commons/model/cache/EventCache.kt | 2 + .../commons/cyberspace/BagSweepTest.kt | 190 ++++++++++ .../composeResources/values/strings.xml | 19 + .../2026-09-22-cyberspace-region-bags.md | 112 +++++- .../cyberspace/deck0003Sno/SnoShardEvent.kt | 18 +- 11 files changed, 971 insertions(+), 21 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index a9c1ab4376..8678b88e7a 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -839,17 +839,22 @@ are where the risk is, and they are testable without a device. 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: the container ships, the bag does not.** - `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. What is *not* - implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext - keyed to a Cyberspace region, so reading one means §2 coordinates, §4's Cantor - trees, §7.2 key derivation and a §7.7 sweep. That is the protocol, not a renderer, - and §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in - the bag". Note that "we have no position in cyberspace" is *not* the reason — - §7.7 is explicit that a seeker's position never enters the cost of a sweep. The - reason is §2.2's: the compute, and the protocol surface behind it. +- **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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 643eeaed3a..8d1962f64e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -158,6 +158,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCitation import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent 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 @@ -284,6 +285,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier 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 @@ -1430,6 +1432,10 @@ private fun RenderNoteRow( RenderSnoShard(baseNote, accountViewModel) } + is CyberspaceBagEvent -> { + RenderCyberspaceBag(baseNote, accountViewModel) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt new file mode 100644 index 0000000000..6249d38fed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt @@ -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(null) } + var job by remember(noteEvent) { mutableStateOf(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index d3afd79f85..19b60e3ed4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -184,6 +184,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 @@ -272,6 +273,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote +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 @@ -1081,6 +1083,8 @@ private fun FullBleedNoteCompose( 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) { diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 00aaa43c71..21673b3013 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -87,6 +87,8 @@ in `commonsUI`, under the same package. | `account` | no | New-account bootstrap events. | | `onchain` | no | On-chain zap splitting/broadcasting. | | `marmot` | no | MLS group-chat event processing. | +| `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. | diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt new file mode 100644 index 0000000000..dea1a42a27 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt @@ -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 = + 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)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 2ed724a813..6a4712db99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -138,6 +138,7 @@ 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 @@ -3837,6 +3838,7 @@ open class EventCache : is GeohashListEvent, is SnoObjectEvent, is SnoAvatarEvent, + is CyberspaceBagEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, is UserGraspListEvent, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt new file mode 100644 index 0000000000..ced3d3c4bc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt @@ -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): 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(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(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(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(BagSweep.quote(bag())) + // Two hint tags is one of §7.7's malformed cases. + assertIs(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4)))) + // And a box smaller than the region it claims to hold is another. + assertIs(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(BagSweep.quote(future)) + } + + @Test + fun sweepingTheBoxFindsTheRegionAndOpensTheBag() = + runTest { + val states = BagSweep.sweep(bag(hint(box666, 6))).toList() + + assertIs(states.first(), "priced on this device before the rest is spent") + val opened = assertIs(states.last()) + val contents = assertIs(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(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(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()) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 65f66de3ef..b0fc2cb0e0 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5332,4 +5332,23 @@ Hidden in ideaspace · %1$s Default avatar No shape published, so everyone sees this one + Hidden at a place + Hidden in a box of %1$s regions + Hidden in one region, which the hint names exactly + No hint, so this could be anywhere in cyberspace + Out of reach: the hint names 2^%1$d regions to search + Search for it + Stop + Pricing the search on this device… + Searched %1$s of %2$s regions + Out of reach: about %1$d minutes of searching on this device + Out of reach: about %1$d hours of searching on this device + Not in the box the hint named + Found the region, but the contents could not be read + %1$d item hidden here did not match its signature and was dropped + %1$d items hidden here did not match their signatures and were dropped + Opened, and there was nothing inside + Unsigned, so this author is a claim + An item of kind %1$d, which this client does not draw + %1$d bytes of something this client does not read diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md index cd74376452..401442a0f4 100644 --- a/quartz/plans/2026-09-22-cyberspace-region-bags.md +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -198,9 +198,12 @@ is not offered as a button at all — it is reported as out of reach. 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`. -5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. + 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. @@ -308,3 +311,108 @@ 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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt index 2fef0a61fa..482965d146 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -47,14 +47,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * * Not for want of somewhere to stand — §7.7 is explicit that "the seeker's own * position never enters this cost, because §7.1 makes looking and walking - * equivalent", so a client with no avatar could open a hinted bag. What stops - * it is the work: a key is three `O(2^h)` folds of BigInts that double in width - * every level, which the spec measures at 816 ms per key at height 16 on a - * desktop core and which grows about 2.2x per height above that. Amethyst - * implements none of it and a reader without the key sees base64 and nothing - * else — §7.6 is explicit that a failed decryption "MUST NOT be treated as an - * error in the bag". What this class reads is a shard handed over directly: - * quoted in a note, or fetched by id. + * equivalent", so a client with no avatar can open a hinted bag, and + * [com.vitorpamplona.quartz.cyberspace.RegionSweep] does. What decides whether + * it is worth trying is the work: a key is three `O(2^h)` folds of BigInts that + * double in width every level, which the spec measures at 816 ms per key at + * height 16 on a desktop core and which grows about 2.2x per height above that, + * and a hint's box multiplies that by up to 2^75. So a bag is opened only when + * its own hint prices the search into reach, and a reader who does not get + * there sees base64 and nothing else — §7.6 is explicit that a failed + * decryption "MUST NOT be treated as an error in the bag". A shard can also + * arrive with no bag at all: quoted in a note, or fetched by id. * * **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its * `pubkey` is a claim and a client MUST NOT present it as verified authorship. From 74d907cecb650c8d4e6d5a77edac18d2ceb0704c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 17:34:00 +0000 Subject: [PATCH 27/38] docs: a sealed shard is one nobody opened, not one nobody can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderSnoShard's KDoc said opening a bag was "the cyberspace protocol rather than a renderer" — true when it was written, and no longer. The protocol is in quartz and RenderCyberspaceBag drives it, so the sentence now points there and says what this card's job actually is: the shard that arrived on its own, quoted or fetched by id. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa --- .../vitorpamplona/amethyst/ui/note/types/SnoShard.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt index 121a17a8f0..c67fdd61e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoShard.kt @@ -52,10 +52,14 @@ private val VIEWER_HEIGHT = 360.dp * 3330). * * The same payload and the same rules as a standalone object — only the - * container differs — so this renders through the same card. What it does not - * do is open a bag: a shard still sealed in its `kind 33330` bag is ciphertext - * until its region key is derived, which is the cyberspace protocol rather than - * a renderer. + * 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( From d1815dff2cbc760fc1a169162ac2ace5ee0fb55a Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:11:43 +0000 Subject: [PATCH 28/38] fix(marmot): accept extensions every member supports in GCE proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GroupContextExtensions proposal was rejected unless each extension type was on a fixed list of types Quartz knows. RFC 9420 §13.4 makes support the rule instead: an extension in use by the group MUST be supported by all members. So a group whose members all advertise a type in their leaf capabilities could not install it, e.g. a client's own group metadata extension. Known types are accepted as before. Any other type is now accepted when every member's leaf lists it in capabilities.extensions. --- .../quartz/marmot/mls/group/MlsGroup.kt | 8 +- .../GroupContextExtensionsSupportTest.kt | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..1ab3b4d1ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -196,6 +196,8 @@ class MlsGroup private constructor( */ internal fun groupContextExtensionsSnapshot(): List = groupContext.extensions.toList() + private fun allMembersSupportExtension(extensionType: Int): Boolean = (0 until tree.leafCount).all { i -> tree.getLeaf(i)?.let { extensionType in it.capabilities.extensions } ?: true } + /** * Encode the current ratchet tree the same way it's serialized into * the GroupInfo's `ratchet_tree` extension on a Welcome — a freshly- @@ -2898,9 +2900,11 @@ class MlsGroup private constructor( } is Proposal.GroupContextExtensions -> { - // Validate extension types are supported (RFC 9420 Section 12.1.7) + // RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. Types this + // implementation knows are accepted as before; any other type is accepted when every member's leaf + // advertises it in capabilities.extensions. for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES) { + require(ext.extensionType in KNOWN_EXTENSION_TYPES || allMembersSupportExtension(ext.extensionType)) { "Unsupported extension type: ${ext.extensionType}" } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt new file mode 100644 index 0000000000..ada9e0375c --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt @@ -0,0 +1,74 @@ +/* + * 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.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities +import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. A GroupContextExtensions + * proposal can install a type this implementation doesn't know, as long as every member's leaf advertises it. + */ +class GroupContextExtensionsSupportTest { + // RFC 9420 §17.3 reserves 0xF000-0xFFFF for private use. + private val customType = 0xF0A1 + private val customExtension = Extension(customType, byteArrayOf(1, 2, 3)) + + // The Marmot leaf capabilities, with and without the custom type. + private val plain = Capabilities(extensions = listOf(0xF2EE), proposals = listOf(0x000A)) + private val withCustom = plain.copy(extensions = plain.extensions + customType) + + @Test + fun anExtensionEveryMemberSupportsIsInstalled() { + val (alice, bob) = twoMemberGroup(bobCapabilities = withCustom) + + alice.proposeGroupContextExtensions(alice.groupContextExtensionsSnapshot() + customExtension) + val commit = alice.commit() + bob.processFramedCommit(commit.framedCommitBytes) + + assertEquals(alice.epoch, bob.epoch) + for (group in listOf(alice, bob)) { + val installed = group.groupContextExtensionsSnapshot().single { it.extensionType == customType } + assertContentEquals(customExtension.extensionData, installed.extensionData) + } + } + + @Test + fun anExtensionSomeMemberDoesNotSupportIsRejected() { + val (alice, _) = twoMemberGroup(bobCapabilities = plain) + + alice.proposeGroupContextExtensions(alice.groupContextExtensionsSnapshot() + customExtension) + assertFails { alice.commit() } + } + + private fun twoMemberGroup(bobCapabilities: Capabilities): Pair { + val alice = MlsGroup.create("11".repeat(32).hexToByteArray(), capabilities = withCustom) + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0), capabilities = bobCapabilities) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + return alice to bob + } +} From 38fc66d43da4a2c417d5e0a7536d406267575da8 Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:21:18 +0000 Subject: [PATCH 29/38] feat(marmot): send and return authenticated_data on application messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MLS application messages carry an authenticated_data field (RFC 9420 §6.3.2): sent in the clear and bound to the message by the AEAD and the signature. Quartz verified it on receive but always sent it empty and never returned it, so an application could not use it, e.g. to bind the sender's account key to the message. encrypt() takes an optional authenticatedData, and DecryptedMessage returns it. Without the argument nothing changes on the wire. --- .../quartz/marmot/mls/group/MlsGroup.kt | 24 ++++-- .../marmot/mls/group/AuthenticatedDataTest.kt | 73 +++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..8a09a870f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -1116,8 +1116,15 @@ class MlsGroup private constructor( * * The signature is computed with `SignWithLabel(., "FramedContentTBS", * FramedContentTBS)` using the member's signature private key. + * + * [authenticatedData] is sent in the clear as the message's + * `authenticated_data` (RFC 9420 §6.3.2). It is bound to the message by the + * AEAD and the signature, and returned by [decrypt]. */ - fun encrypt(plaintext: ByteArray): ByteArray { + fun encrypt( + plaintext: ByteArray, + authenticatedData: ByteArray = ByteArray(0), + ): ByteArray { // Trim sentKeys if it grows too large if (sentKeys.size > MAX_SENT_KEYS) { val sortedKeys = sentKeys.keys.sorted() @@ -1146,7 +1153,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, senderLeafIndex = myLeafIndex, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, applicationData = plaintext, groupContext = groupContext, ), @@ -1162,7 +1169,7 @@ class MlsGroup private constructor( val pmcPlaintext = pmcWriter.toByteArray() // Build PrivateContentAAD (RFC 9420 §6.3.2) - val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0)) + val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, authenticatedData) val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext) // Build sender data plaintext: leaf_index || generation || reuse_guard @@ -1200,7 +1207,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, contentType = ContentType.APPLICATION, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, encryptedSenderData = encryptedSenderData, ciphertext = ciphertext, ) @@ -1442,6 +1449,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = applicationData, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1475,6 +1483,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = commitBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1562,6 +1571,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = proposalBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } } @@ -4563,19 +4573,23 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, + /** The message's `authenticated_data` (RFC 9420 §6.3.2), verified by the AEAD. */ + val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DecryptedMessage) return false return senderLeafIndex == other.senderLeafIndex && content.contentEquals(other.content) && - epoch == other.epoch + epoch == other.epoch && + authenticatedData.contentEquals(other.authenticatedData) } override fun hashCode(): Int { var result = senderLeafIndex result = 31 * result + content.contentHashCode() result = 31 * result + epoch.hashCode() + result = 31 * result + authenticatedData.contentHashCode() return result } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt new file mode 100644 index 0000000000..7a3385da12 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt @@ -0,0 +1,73 @@ +/* + * 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.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** `authenticated_data` on application messages (RFC 9420 §6.3.2): sent in the clear, bound by the AEAD. */ +class AuthenticatedDataTest { + private val aad = "sender-binding".encodeToByteArray() + + @Test + fun theReceiverGetsTheSendersAuthenticatedData() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray(), aad)) + + assertContentEquals("hi".encodeToByteArray(), received.content) + assertContentEquals(aad, received.authenticatedData) + } + + @Test + fun withoutItTheAuthenticatedDataIsEmpty() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray())) + + assertEquals(0, received.authenticatedData.size) + } + + @Test + fun alteredAuthenticatedDataFailsToDecrypt() { + val (alice, bob) = twoMemberGroup() + + val sent = MlsMessage.decodeTls(TlsReader(alice.encrypt("hi".encodeToByteArray(), aad))) + val message = PrivateMessage.decodeTls(TlsReader(sent.payload)) + val altered = MlsMessage.fromPrivateMessage(message.copy(authenticatedData = "someone-else".encodeToByteArray())) + + assertFails { bob.decrypt(altered.toTlsBytes()) } + } + + private fun twoMemberGroup(): Pair { + val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + return alice to bob + } +} From ae88f6fdc130a3a57c33a6cd27e550d8223324ec Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 23 Sep 2026 16:24:51 -0400 Subject: [PATCH 30/38] perf(sno): reuse the raster buffers across a turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning an object allocated a fresh pixel buffer, a fresh depth buffer and a bitmap on every frame — about 1.8 MB, of which 1.2 MB is Java heap — and a drag asks for roughly thirty-five frames a second. On an SM-T220 that is 41 MB/s against Amethyst's ~100 MB live set, and it showed: sampling HeapTaskDaemon through 32 s of continuous circling, garbage collection took **51% of a core** in ~3 s bursts every ~6 s, which is what made the turn stutter every few seconds. The same screen held still spent none, so it was the turn's own garbage and not the relay-ingest storm. The raster size is fixed for the whole gesture, so the two buffers can be held and refilled instead of reallocated. `SnoRasterScratch` does that and `render` takes one optionally, so a thumbnail — which draws once — keeps allocating and pays nothing for this. The buffers are handed out rather than copied, so they cannot serve two renders at once, and the viewer could previously have several: it restarted a `LaunchedEffect` per pointer event while `render` has no cancellation checks and runs to completion regardless, leaving 2-4 frames in flight with all but one discarded. Angles now go through one conflated collector, which both makes the scratch safe and stops drawing frames nobody waits for. The turn still lands wherever the finger stopped, because conflation keeps the newest angle rather than the oldest. Measured on the tablet, AOT-compiled, same object and same drags: | | GC thread | frame median | p90 | |---|---|---|---| | before | 51.2% of a core | 17.3 ms | 22.0 ms | | after | **7.7%** | **10.8 ms** | **15.9 ms** | Frames got faster too, which a JVM microbenchmark had predicted they would not: there `IntArray(n) { bg }` beats allocate-and-fill 0.040 ms to 0.151 ms because C2 vectorises it. That does not carry to ART, and the allocation it avoids was worth more than the fill it adds. Judge this one by GC and by displayed jank, not by `render` wall time. Three tests cover what reuse can break: a sequence of angles has to draw identically with and without a scratch, the buffers have to follow a change of raster size, and a lit frame must not leave its shading behind for an unlit one. All three fail if the depth reset is removed. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/commons/sno/SnoRasterScratch.kt | 60 +++++++++++++++++++ .../amethyst/commons/sno/SnoRasterizer.kt | 16 ++++- .../amethyst/commons/sno/SnoRasterizerTest.kt | 49 +++++++++++++++ .../commons/sno/ui/SnoObjectViewer.kt | 52 +++++++++++----- 4 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt new file mode 100644 index 0000000000..88a80b5275 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterScratch.kt @@ -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) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt index 4eff817f91..0b093853e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizer.kt @@ -121,6 +121,12 @@ object SnoRasterizer { * @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") @@ -132,13 +138,19 @@ object SnoRasterizer { 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 pixels = IntArray(width * height) { background } + 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 = FloatArray(width * height) { Float.NEGATIVE_INFINITY } + 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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt index e4c4521303..3dc6d0a791 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/sno/SnoRasterizerTest.kt @@ -283,4 +283,53 @@ class SnoRasterizerTest { 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))) + } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt index db06cd50f3..dbbd5c80f2 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/sno/ui/SnoObjectViewer.kt @@ -40,6 +40,7 @@ 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 @@ -60,10 +61,12 @@ 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 @@ -201,20 +204,41 @@ fun SnoObjectViewer( // the turn coarse instead of freezing the gesture. var bitmap by remember(eventId) { mutableStateOf(null) } - LaunchedEffect(payload, rasterPx, yaw, pitch, backgroundArgb, lit) { - bitmap = - withContext(Dispatchers.Default) { - val pixels = - SnoRasterizer.render( - payload = payload, - width = rasterPx, - height = rasterPx, - yawDegrees = yaw, - pitchDegrees = pitch, - background = backgroundArgb, - lighting = if (lit) lighting.of(payload) else null, - ) - PlatformImage.create(pixels, rasterPx, rasterPx).toComposeImageBitmap() + // 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() + } } } From 9d0a5ada0ea8577e9c6f0d03fe4e1b24544b917d Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:26:38 +0000 Subject: [PATCH 31/38] feat(marmot): caller-chosen group_id and optional required_capabilities MlsGroup.create always picks a random group_id and always installs the Marmot required_capabilities. Groups shared with other MLS stacks may need a group_id fixed by the application, and may carry no required_capabilities. create takes an optional groupId (null keeps 32 random bytes), and requiredCapabilities can be null to leave the extension out. Defaults are unchanged. --- .../quartz/marmot/mls/group/MlsGroup.kt | 8 +-- .../mls/group/GroupCreateOptionsTest.kt | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..ea0403a7a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -3624,7 +3624,9 @@ class MlsGroup private constructor( * the MIP-era set; a current-profile group passes * [buildCurrentProfileRequiredCapabilitiesExtension]. */ - requiredCapabilities: Extension = buildMarmotRequiredCapabilitiesExtension(), + requiredCapabilities: Extension? = buildMarmotRequiredCapabilitiesExtension(), + /** The group's MLS `group_id`. Null picks 32 random bytes. */ + groupId: ByteArray? = null, ): MlsGroup { val sigKp = signingKey?.let { key -> @@ -3633,7 +3635,7 @@ class MlsGroup private constructor( } ?: Ed25519.generateKeyPair() val encKp = X25519.generateKeyPair() - val groupId = MlsCryptoProvider.randomBytes(32) + val groupId = groupId ?: MlsCryptoProvider.randomBytes(32) val leafNode = buildLeafNode( @@ -3654,7 +3656,7 @@ class MlsGroup private constructor( // bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so // new peers who join later can see the group name without first // decrypting a pre-membership bootstrap commit — see MIP-03). - val baseExtensions = listOf(requiredCapabilities) + val baseExtensions = listOfNotNull(requiredCapabilities) val groupContext = GroupContext( groupId = groupId, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt new file mode 100644 index 0000000000..4e71e5f0c4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt @@ -0,0 +1,53 @@ +/* + * 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.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GroupCreateOptionsTest { + private val creator = "11".repeat(32).hexToByteArray() + + @Test + fun aCallerChosenGroupIdIsUsed() { + val groupId = "my-group".encodeToByteArray() + val alice = MlsGroup.create(creator, groupId = groupId) + assertContentEquals(groupId, alice.groupId) + + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + assertContentEquals(groupId, bob.groupId) + } + + @Test + fun aGroupWithoutRequiredCapabilitiesWorks() { + val alice = MlsGroup.create(creator, requiredCapabilities = null) + assertTrue(alice.groupContextExtensionsSnapshot().none { it.extensionType == 0x0003 }) + + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + assertEquals(alice.epoch, bob.epoch) + assertContentEquals("hi".encodeToByteArray(), bob.decrypt(alice.encrypt("hi".encodeToByteArray())).content) + } +} From f50b39fcc946a2dd62874ba5ce7fb09a0a1cb3ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:27:28 +0000 Subject: [PATCH 32/38] refactor: split the smallest AccountViewModel-bound composables into app entries and shared displays First pass of the Render/Display split (commons/plans/2026-07-16-note-ui- commons-extraction.md): the app keeps the entry that talks to the account, commonsUI gets the rendering, which takes values and callbacks. - Channel header actions. The NIP-28 and ephemeral-chat Join/Leave buttons were four copies of the same FilledTonalButton; they become JoinChannelButton/LeaveChannelButton(onClick) in chats/ui and the headers pass `accountViewModel.follow/unfollow(channel)`. MuteChannelButton(isMuted, onToggle) is the display half of MuteChatButton, which keeps collecting the muted set. EditChannelButton (nip28PublicChat/ui) replaces the app's EditButton wrapper. - ProofOfWorkPill(pow, onClick) in ui/note; DisplayPoW stays the entry that explains the number in a toast. - FollowedHashtagLabel(tag, onClick) in ui/note; the follow-list lookup stays in DisplayFollowingHashtagsInPost. - HashtagExternalIdCard(topic, onClick) in nip73ExternalIds/ui, the display overload of DisplayHashtagExternalId. - rememberFoundCacheIds(FeedState) in nipCCGeocaching/ui; rememberMyFoundCacheIds only picks the account's Finds feed. - NewProductButton, NewMusicTrackButton and RelaysTabHeader took accountViewModel (and the header a User) without reading them. The parameters go and the files move whole to nip99Classifieds/ui, music/ui and profile/ui. Duplicates removed on the way: DiscoverScreen carried its own copies of NewProductButton and of NewArticleButton (as NewLongFormMarkdownButton), and ephemChat/ui/JoinButton had no callers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../ui/note/elements/DisplayHashtags.kt | 31 +----- .../amethyst/ui/note/elements/DisplayPoW.kt | 40 ++------ .../nip22Comments/DisplayHashtagExternalId.kt | 58 +----------- .../header/ShortEphemeralChatChannelHeader.kt | 8 +- .../header/LongPublicChatChannelHeader.kt | 9 +- .../header/ShortPublicChatChannelHeader.kt | 4 +- .../header/actions/JoinChatButton.kt | 48 ---------- .../header/actions/LeaveChatButton.kt | 48 ---------- .../header/actions/MuteChatButton.kt | 34 +------ .../loggedIn/discover/DiscoverScreen.kt | 58 +----------- .../loggedIn/geocaches/GeocacheFinds.kt | 30 +----- .../loggedIn/music/MusicTracksScreen.kt | 3 +- .../loggedIn/products/ProductsScreen.kt | 3 +- .../screen/loggedIn/profile/ProfileScreen.kt | 4 +- commons/ARCHITECTURE.md | 2 +- .../commons/chats/ui/ChannelActionButtons.kt | 94 +++++++++++++++++++ .../commons/ephemChat/ui/JoinButton.kt | 44 --------- .../commons/music/ui}/NewMusicTrackButton.kt | 11 +-- .../nip28PublicChat/ui/EditChannelButton.kt | 15 +-- .../ui/HashtagExternalIdCard.kt | 79 ++++++++++++++++ .../nip99Classifieds/ui}/NewProductButton.kt | 8 +- .../nipCCGeocaching/ui/FoundCacheIds.kt | 56 +++++++++++ .../commons/profile/ui}/RelaysTabHeader.kt | 9 +- .../commons/ui/note/FollowedHashtagLabel.kt | 47 +++++----- .../commons/ui/note/ProofOfWorkPill.kt | 49 +++++----- 25 files changed, 335 insertions(+), 457 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt delete mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui}/NewMusicTrackButton.kt (87%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt (78%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui}/NewProductButton.kt (91%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui}/RelaysTabHeader.kt (83%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt (51%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt (56%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index b60072001c..a695bb79e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -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())) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 0da6aa11eb..25c0870a11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -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(), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt index 1848479637..1166fa439b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -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, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 035ea330f3..b63dd2940e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -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( @@ -130,8 +130,8 @@ fun JoinEphemeralChatButtonIfNotAlreadyJoined( 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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 8dfec8f0cb..cb74de4208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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 @@ -212,7 +213,7 @@ fun EditButtonIfIamCreator( } if (isMe) { - EditButton(channel, accountViewModel, nav) + EditChannelButton { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) } } } @@ -225,6 +226,6 @@ fun LeaveButtonIfFollowing( val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) if (isFollowing) { - LeaveChatButton(channel, accountViewModel, nav) + LeaveChannelButton { accountViewModel.unfollow(channel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index b90ff26e63..be5aa768b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -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( @@ -147,6 +147,6 @@ fun JoinChatButtonIfNotAlreadyJoined( val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) if (!isFollowing) { - JoinChatButton(channel, accountViewModel, nav) + JoinChannelButton { accountViewModel.follow(channel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt deleted file mode 100644 index 1a14b7a52d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt +++ /dev/null @@ -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.join -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 JoinChatButton( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - FilledTonalButton( - modifier = HalfHalfHorzModifier, - onClick = { accountViewModel.follow(channel) }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(Res.string.join)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt deleted file mode 100644 index 8a3cfcd30e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt +++ /dev/null @@ -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)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt index e5864ed985..47418b63c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 0e355a01aa..6ded9230bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt index f6cdc5e142..6c90d45d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt @@ -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 { 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 { - val loaded by state.feed.collectAsStateWithLifecycle() - - return remember(loaded) { - loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() } - } + return rememberFoundCacheIds(finds) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index 49b0cafccd..7330473598 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt index 7cee0b536d..99c72dfa2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index dd0cf3d0ca..0bc9808c07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -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() } }, ) diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 473cad938c..fa90c43d92 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -140,7 +140,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 `/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`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/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 | diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt new file mode 100644 index 0000000000..da23e6ccd3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt @@ -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, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt deleted file mode 100644 index c304893c25..0000000000 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt +++ /dev/null @@ -1,44 +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.commons.ephemChat.ui - -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.join -import com.vitorpamplona.amethyst.commons.ui.stringRes - -@Composable -fun JoinButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, -) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost, - ) { - Text(text = stringRes(Res.string.join)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt index c886c60594..76d070bd85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt @@ -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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt index 159f0faec6..320e656b4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt @@ -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( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt new file mode 100644 index 0000000000..a0dbc0c74c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt index 2c410dd1b3..ca5ec5a69b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt @@ -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()) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt new file mode 100644 index 0000000000..aa0a738cb7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt @@ -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 = + 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 { + val loaded by state.feed.collectAsStateWithLifecycle() + + return remember(loaded) { + loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt index 3a2a316e7b..7ce01d0b1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt @@ -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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt index 760b69a194..a390dbcc71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt @@ -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, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt index 5f48f50ff0..9ae434afab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt @@ -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, + ) } From c17b0075f5a48f0faf285b7061eaa85c4a2fd051 Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:30:11 +0000 Subject: [PATCH 33/38] feat(marmot): Ed25519.keyPairFromSeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some MLS stacks store Ed25519 signing keys as the 32-byte seed only, while Quartz uses the 64-byte seed||public form. keyPairFromSeed rebuilds the pair from the seed (RFC 8032 §5.1.5) with each platform's existing derivePublicKey. --- .../quartz/marmot/mls/crypto/Ed25519.apple.kt | 6 ++++++ .../quartz/marmot/mls/crypto/Ed25519.kt | 6 ++++++ .../marmot/mls/crypto/Ed25519.jvmAndroid.kt | 6 ++++++ .../quartz/marmot/mls/Ed25519Test.kt | 17 +++++++++++++++++ .../quartz/marmot/mls/crypto/Ed25519.linux.kt | 6 ++++++ 5 files changed, 41 insertions(+) diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt index 74c7621bc8..e311cd8aa1 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt @@ -128,6 +128,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + // --- Internal operations --- /** Derive Ed25519 public key from 32-byte seed. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt index 3271d75549..168b586808 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt @@ -64,6 +64,12 @@ expect object Ed25519 { * @return 32-byte public key */ fun publicFromPrivate(privateKey: ByteArray): ByteArray + + /** + * Rebuild a key pair from a 32-byte seed (RFC 8032 §5.1.5), for keys stored as seeds only. + * @return pair of (privateKey: 64 bytes seed+public, publicKey: 32 bytes) + */ + fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair } data class Ed25519KeyPair( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt index 34fe7fa03b..fa500dc60a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt index 19e3c0c7e2..1c8822924f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt @@ -114,4 +114,21 @@ class Ed25519Test { ) { kotlin.test.assertContentEquals(expected, actual) } + + @Test + fun keyPairFromSeedMatchesRfc8032TestVector1() { + val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60".hexToByteArray() + val kp = Ed25519.keyPairFromSeed(seed) + assertContentEquals("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a".hexToByteArray(), kp.publicKey) + assertContentEquals(seed + kp.publicKey, kp.privateKey) + } + + @Test + fun keyPairFromSeedSignsLikeTheGeneratedPair() { + val generated = Ed25519.generateKeyPair() + val rebuilt = Ed25519.keyPairFromSeed(generated.privateKey.copyOfRange(0, 32)) + assertEquals(generated, rebuilt) + val msg = "hi".encodeToByteArray() + assertTrue(Ed25519.verify(msg, Ed25519.sign(msg, rebuilt.privateKey), generated.publicKey)) + } } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt index 209c36a4b7..efc7e36298 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte() From 220e087a721e1f70e2ace1bbb56324e80a89a407 Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:33:48 +0000 Subject: [PATCH 34/38] feat(marmot): optional leaf lifetime on createKeyPackage createKeyPackage always stamps the Marmot window (84 days, backdated an hour). MLS groups outside Marmot use other windows, and a KeyPackage's lifetime is part of the signed leaf, so it can only be chosen at creation. createKeyPackage and buildLeafNode take an optional lifetime; null keeps the Marmot window. --- .../quartz/marmot/mls/group/MlsGroup.kt | 8 ++- .../mls/group/KeyPackageLifetimeTest.kt | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index adbf3a6c9b..07920051ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -509,6 +509,8 @@ class MlsGroup private constructor( leafExtensions: List = emptyList(), capabilities: Capabilities = marmotLeafCapabilities(), keyPackageExtensions: List = emptyList(), + /** The leaf's lifetime. Null uses the Marmot window (84 days, backdated an hour for clock skew). */ + lifetime: Lifetime? = null, ): KeyPackageBundle { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() @@ -523,6 +525,7 @@ class MlsGroup private constructor( signingKey = sigKp.privateKey, capabilities = capabilities, leafExtensions = leafExtensions, + lifetime = lifetime, ) val unsigned = @@ -4254,6 +4257,7 @@ class MlsGroup private constructor( parentHash: ByteArray? = null, capabilities: Capabilities = marmotLeafCapabilities(), leafExtensions: List = emptyList(), + lifetime: Lifetime? = null, ): LeafNode { val unsigned = LeafNode( @@ -4265,7 +4269,9 @@ class MlsGroup private constructor( capabilities = capabilities, leafNodeSource = source, lifetime = - if (source == LeafNodeSource.KEY_PACKAGE) { + if (source == LeafNodeSource.KEY_PACKAGE && lifetime != null) { + lifetime + } else if (source == LeafNodeSource.KEY_PACKAGE) { // A real, bounded window. `Lifetime(0, Long.MAX_VALUE)` // used to go here, which any receiver enforcing // `foundation/key-packages.md` rejects outright: the diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt new file mode 100644 index 0000000000..89472471a3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KeyPackageLifetimeTest { + private val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + private val bob = "22".repeat(32).hexToByteArray() + + @Test + fun theDefaultIsTheBoundedMarmotWindow() { + val lifetime = + assertNotNull( + alice + .createKeyPackage(bob, ByteArray(0)) + .keyPackage.leafNode.lifetime, + ) + assertTrue(lifetime.notAfter - lifetime.notBefore <= 84L * 24 * 3600 + 3600) + } + + @Test + fun aCallerChosenLifetimeIsUsedAndTheMemberCanBeAdded() { + val now = TimeUtils.now() + val chosen = Lifetime(now - 3600, now + 365L * 24 * 3600) + + val bundle = alice.createKeyPackage(bob, ByteArray(0), lifetime = chosen) + assertEquals(chosen, bundle.keyPackage.leafNode.lifetime) + + val result = alice.addMember(bundle.keyPackage.toTlsBytes()) + val joined = MlsGroup.processWelcome(result.welcomeBytes!!, bundle) + assertEquals(alice.epoch, joined.epoch) + } +} From 230f645eba96da8c4dcf51a6c872d68f4c0785c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:57:05 +0000 Subject: [PATCH 35/38] =?UTF-8?q?refactor:=20second=20Render/Display=20bat?= =?UTF-8?q?ch=20=E2=80=94=20NIP-43=20cards,=20NIP=20card,=20two=20dialogs,?= =?UTF-8?q?=20bookmark=20toggle,=20crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NIP-43 relay membership: the five renderers drew only from the event (nav and relayPubKey were passed to the card and never read). The cards move to commonsUI nip43RelayMembers/ui as RelayMembershipListCard, RelayAddMemberCard, RelayRemoveMemberCard, RelayJoinRequestCard, RelayLeaveRequestCard over a public RelayMemberEventCard; the app keeps the dispatcher entries that decode the note. - NIPs on Nostr: NipNoteHeader only read the event; it is NipCard(event) in nipsOnNostr/ui. Its preview no longer seeds LocalCache to build a Note the card never used. - RelayGroupInviteCodeDialog(onJoin, onDismiss) in nip29RelayGroups/ui; JoinRelayGroupDialog stays the entry that sends the kind-9021 request. - HashtagLabelDialog(onConfirm, onDismiss) in nip32Labeling/ui, keeping the hashtag normalization; AddHashtagLabelDialog publishes the label. - BookmarkToggleButton(isBookmarked, onToggle) in ui/note; PodcastBookmarkButton keeps reading the public bookmark sets. - CrossfadeIfEnabled: the crossfade machinery (DeferredCrossfade, MyCrossfade, and a CrossfadeIfEnabled taking `enabled: Boolean`) moves to ui/components. The app's CrossfadeIfEnabled(accountViewModel) is now a one-line overload passing "not performance mode", so its ~40 callers are unchanged. DeferredCrossfade becomes public for the androidTest that exercises it. No behaviour changes: every display is the entry's former body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../amethyst/ui/note/DeferredAnimationTest.kt | 2 +- .../amethyst/ui/actions/CrossfadeIfEnabled.kt | 137 +---------- .../ui/note/elements/AddHashtagLabelDialog.kt | 73 +----- .../amethyst/ui/note/types/Nip.kt | 160 +------------ .../ui/note/types/PodcastBookmarkButton.kt | 58 +---- .../amethyst/ui/note/types/RelayMembers.kt | 200 +--------------- .../relayGroup/JoinRelayGroupDialog.kt | 57 +---- commons/ARCHITECTURE.md | 2 +- .../ui/RelayGroupInviteCodeDialog.kt | 86 +++++++ .../nip32Labeling/ui/HashtagLabelDialog.kt | 105 ++++++++ .../nip43RelayMembers/ui/RelayMemberCards.kt | 224 ++++++++++++++++++ .../commons/nipsOnNostr/ui/NipCard.kt | 164 +++++++++++++ .../commons/ui/components/Crossfade.kt | 169 +++++++++++++ .../commons/ui/note/BookmarkToggleButton.kt | 77 ++++++ 14 files changed, 875 insertions(+), 639 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt index a2cbb273e7..086b3e42d4 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -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.DeferredCrossfade import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index 4c5014141f..9470b4e89d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -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 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 DeferredCrossfade( - targetState: T, - modifier: Modifier, - contentAlignment: Alignment, - animationSpec: FiniteAnimationSpec, - 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 MyCrossfade( - targetState: T, - modifier: Modifier = Modifier, - contentAlignment: Alignment = Alignment.TopStart, - animationSpec: FiniteAnimationSpec = tween(), - label: String = "Crossfade", - content: @Composable (T) -> Unit, -) { - val transition = updateTransition(targetState, label) - transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) -} - -@ExperimentalAnimationApi -@Composable -fun Transition.MyCrossfade( - modifier: Modifier = Modifier, - contentAlignment: Alignment = Alignment.TopStart, - animationSpec: FiniteAnimationSpec = tween(), - contentKey: (targetState: T) -> Any? = { it }, - content: @Composable (targetState: T) -> Unit, -) { - val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } - val contentMap = remember { mutableScatterMapOf 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt index 977181c7f1..4200433286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt index 0be95db94d..d606a31bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt index daf6d81628..d282f524b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -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` * 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, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt index bf6c079f3c..22c1fc34c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt index e1edd7eb42..197323b319 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt @@ -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, ) } diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index fa90c43d92..00bc47da2e 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -140,7 +140,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`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/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 `/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 | diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt new file mode 100644 index 0000000000..39132b97c9 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt @@ -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)) } + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt new file mode 100644 index 0000000000..507902ea28 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt @@ -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)) + } + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt new file mode 100644 index 0000000000..65e4e7765f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt @@ -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 +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, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt new file mode 100644 index 0000000000..52a2f10eb4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt @@ -0,0 +1,164 @@ +/* + * 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.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.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) }, + ) +} + +@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, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt new file mode 100644 index 0000000000..38c8ce3edc --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt @@ -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 CrossfadeIfEnabled( + targetState: T, + enabled: Boolean, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = 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 +fun DeferredCrossfade( + targetState: T, + modifier: Modifier, + contentAlignment: Alignment, + animationSpec: FiniteAnimationSpec, + 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 MyCrossfade( + targetState: T, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + label: String = "Crossfade", + content: @Composable (T) -> Unit, +) { + val transition = updateTransition(targetState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) +} + +@ExperimentalAnimationApi +@Composable +fun Transition.MyCrossfade( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + contentKey: (targetState: T) -> Any? = { it }, + content: @Composable (targetState: T) -> Unit, +) { + val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } + val contentMap = remember { mutableScatterMapOf 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() } } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt new file mode 100644 index 0000000000..8e72fb9ee8 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt @@ -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), + ) + } +} From 027463a3e99ac9e56271fbf0d5d8c0a9105ac6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:31:47 +0000 Subject: [PATCH 36/38] =?UTF-8?q?refactor:=20third=20Render/Display=20batc?= =?UTF-8?q?h=20=E2=80=94=20report=20dialog,=20relay-group=20menu=20items,?= =?UTF-8?q?=20git=20status=20buttons,=20RSVP=20row,=20chat=20subject=20dia?= =?UTF-8?q?log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each keeps its app entry (account reads, signing, publishing) and moves the rendering to commonsUI, taking values and callbacks: - ReportDialog(onBlock, onReport(reason, text), onDismiss) in nip56Reports/ui; ReportNoteDialog hides the author and files the report. - BuzzPinDropdownItem(isPinned, closeMenu, onToggle) in buzz/ui and RelayGroupMessagesDropdownItem(onMyList, closeMenu, onToggle) in nip29RelayGroups/ui; the app overloads read the starred set and the kind-10009 list. - GitStatusButtons(closedOrApplied, isPatchOrPr, onReopen, onMarkMerged, onClose) in nip34Git/ui, with CompactButtonHeight/Padding (now public; GitPullRequestChanges imports them). GitStatusActions keeps the moderation check, the status index read and the signing. - CalendarRsvpButtons(currentStatus, onSelect) in nip52Calendar/ui; CalendarRsvpRow keeps the RSVP lookup and publishing. - ChatroomSubjectDialog(initialSubject, onPost(subject, message), onClose) in nip17Dm/ui; NewChatroomSubjectDialog builds and sends the NIP-17 message. An unused rememberCoroutineScope is dropped. No behaviour changes: every display is the entry's former body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../amethyst/ui/note/types/CalendarRsvpRow.kt | 101 +------- .../ui/note/types/GitPullRequestChanges.kt | 2 + .../ui/note/types/GitStatusActions.kt | 68 +----- .../header/NewChatroomSubjectDialog.kt | 151 ++---------- .../relayGroup/BuzzChannelMenuItems.kt | 53 +--- .../loggedIn/report/ReportNoteDialog.kt | 203 +--------------- .../commons/buzz/ui/BuzzPinDropdownItem.kt | 62 +++++ .../nip17Dm/ui/ChatroomSubjectDialog.kt | 147 ++++++++++++ .../ui/RelayGroupMessagesDropdownItem.kt | 62 +++++ .../commons/nip34Git/ui/GitStatusButtons.kt | 98 ++++++++ .../nip52Calendar/ui/CalendarRsvpButtons.kt | 135 +++++++++++ .../commons/nip56Reports/ui/ReportDialog.kt | 227 ++++++++++++++++++ 12 files changed, 783 insertions(+), 526 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt index 7ba0e0a4bb..b3b9a80dd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt @@ -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::`). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt index eeec970d64..04f1c257aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -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.CompactButtonHeight +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonPadding 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index eeb66e0737..79be00fbe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -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? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 437a9d7fd1..890d697cce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -20,42 +20,9 @@ */ 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 +34,25 @@ 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 = + 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, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt index 61db159967..81836641f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt index 4344908a75..23017a308e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt @@ -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) - } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt new file mode 100644 index 0000000000..f88494e0a6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt @@ -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.buzz.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.buzz_pin +import com.vitorpamplona.amethyst.commons.resources.buzz_unpin +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * 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 BuzzPinDropdownItem( + isPinned: Boolean, + closeMenu: () -> Unit, + onToggle: () -> Unit, +) { + 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() + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt new file mode 100644 index 0000000000..e3cb9e8c87 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt @@ -0,0 +1,147 @@ +/* + * 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. + */ +@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() + }, + 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, + ) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt new file mode 100644 index 0000000000..797b767231 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt @@ -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() + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt new file mode 100644 index 0000000000..59e1d5c36f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt @@ -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 CompactButtonHeight = Modifier.height(32.dp) +val CompactButtonPadding = 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 = 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 = onMarkMerged, + 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 = onClose, + 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)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt new file mode 100644 index 0000000000..33f47db5ca --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt @@ -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 + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt new file mode 100644 index 0000000000..a8cd7086c1 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt @@ -0,0 +1,227 @@ +/* + * 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 + +/** + * 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 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 = { + 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..reportTypes.lastIndex, + onClick = { + onReport(reportTypes[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) + } +} From 538a5fd2bd4cbb008a6c6302707072de11de761a Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:08:11 +0000 Subject: [PATCH 37/38] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index d5752ea194..27a7baff42 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -544,4 +544,73 @@ Usunięto %1$d prywatnych transmiterów Usunięto %1$d prywatne transmitery + Użyj nowej wersji + tylko do odczytu + tylko do zapisu + odczyt i zapis + profil + Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy. + lista obserwowanych + Określa, czyje posty pojawiają się w Twoim kanale głównym. + lista zablokowanych + Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach. + lista publicznych transmiterów + Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki. + Lista transmiterów DM + Gdzie inni dostarczają Twoje prywatne wiadomości. + transmitery kluczy czatu grupowego + Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych. + lista transmiterów wyszukiwania + Transmitery używane podczas wyszukiwania. + lista transmiterów indeksujących + Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów. + ulubione kanały transmitera + Transmitery, które przeglądasz jako kanały informacyjne. + lista zablokowanych przekaźników + obserwowane lokalizacje + Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia. + lista zaufanych dostawców + Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu. + Portfel Cashu + Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia. + ustawienia odbioru nutzap + Minty i klucz, które inni używają do wysyłania ci „nutzapów”. + adresy płatności + Sposoby, w jakie inni mogą Ci zapłacić. + Oferta BOLT12 + Ta możliwość pozwala innym płacić Ci za pomocą Lightning. + lista + Lista zapisana na tym urządzeniu. + Pola profilu + Osoby + Hashtagi + Słowa + Wątki + Czaty publiczne + Społeczności + Kanały + Lokalizacje + Transmitery + Grupy + Czat pokoje + Zaufani dostawcy + Minty + Klucze Nutzap + Adresy płatności + Oferty + Powiązane tożsamości + Inne wpisy + Nazwa użytkownika + Nazwa wyświetlana + O profilu + Zdjęcie + Baner + Strona www + Adres Nostr + Lightning Adres + LNURL + Zaimki + Data urodzin + Oferta CLINK + Flaga bota From c609b33a91b29537f8154e864fde509e049315a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:28:31 +0000 Subject: [PATCH 38/38] fix: address the audit of the Render/Display split (#4185) Desktop now uses the shared dialogs instead of its own copies: - ReportNoteDialog (desktop) wraps the shared ReportDialog: translated labels and the Violence reason desktop was missing. It follows the Android flow: "Block & Hide User" mutes the author, "Post Report" files the report and mutes. Desktop's report-without-mute option goes away. - GroupSubjectDialog (desktop ChatPane) wraps ChatroomSubjectDialog, so the group-name dialog is translated and matches Android. Carried-over issues fixed in the shared code: - ChatroomSubjectDialog's Post button was always enabled, so a blank subject and blank message sent an empty NIP-17 message to every room member. It is enabled only when either field has text. - ReportDialog rebuilt its seven labelled reasons on every keystroke and kept stale labels across a locale change (unkeyed remember). The reasons are now a constant list and the spinner options are remembered keyed on the resolved labels. - NewChatroomSubjectDialog read the room's subject on every recomposition; it is remembered per room again, as before the split. API surface: - DeferredCrossfade goes back to internal; its androidTest drives the same path through CrossfadeIfEnabled(enabled = true). - CompactButtonHeight/Padding are renamed GitCardButtonHeight/Padding, since they are git-card sizes exposed across modules. - RelayMemberEventCard and NoPaddingSuggestionChip are private again, and the chip drops an ExperimentalMaterial3Api opt-in it never needed. - The three channel-header helpers no longer take a nav they don't use. - UserProfileScreen calls ReportNoteDialog through an import instead of an inline fully-qualified name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../amethyst/ui/note/DeferredAnimationTest.kt | 6 +- .../ui/note/types/GitPullRequestChanges.kt | 12 +-- .../header/NewChatroomSubjectDialog.kt | 11 ++- .../header/ShortEphemeralChatChannelHeader.kt | 3 +- .../header/LongPublicChatChannelHeader.kt | 3 +- .../header/ShortPublicChatChannelHeader.kt | 3 +- .../nip17Dm/ui/ChatroomSubjectDialog.kt | 4 +- .../commons/nip34Git/ui/GitStatusButtons.kt | 16 ++-- .../nip43RelayMembers/ui/RelayMemberCards.kt | 2 +- .../commons/nip56Reports/ui/ReportDialog.kt | 32 +++---- .../commons/nipsOnNostr/ui/NipCard.kt | 4 +- .../commons/ui/components/Crossfade.kt | 2 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 13 +-- .../amethyst/desktop/ui/chats/ChatPane.kt | 68 ++++----------- .../desktop/ui/note/ReportNoteDialog.kt | 84 ++----------------- .../amethyst/desktop/ui/note/ShareMenu.kt | 10 +-- 16 files changed, 90 insertions(+), 183 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt index 086b3e42d4..e176135a49 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -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.commons.ui.components.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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt index 04f1c257aa..280c29f59c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -41,8 +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.CompactButtonHeight -import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonPadding +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 @@ -108,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)) @@ -142,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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 890d697cce..dd881133d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -36,10 +37,12 @@ fun NewChatroomSubjectDialog( ) { ChatroomSubjectDialog( initialSubject = - accountViewModel.account.chatroomList.rooms - .get(room) - ?.subject - ?.value ?: "", + remember(room) { + accountViewModel.account.chatroomList.rooms + .get(room) + ?.subject + ?.value ?: "" + }, onPost = { subject, message -> accountViewModel.launchSigner { val template = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index b63dd2940e..9fc763710b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -118,14 +118,13 @@ 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index cb74de4208..ae56c76d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -198,7 +198,7 @@ fun LongChannelActionOptions( EditButtonIfIamCreator(channel, accountViewModel, nav) - LeaveButtonIfFollowing(channel, accountViewModel, nav) + LeaveButtonIfFollowing(channel, accountViewModel) } @Composable @@ -221,7 +221,6 @@ fun EditButtonIfIamCreator( fun LeaveButtonIfFollowing( channel: PublicChatChannel, accountViewModel: AccountViewModel, - nav: INav, ) { val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index be5aa768b7..317783b3a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -135,14 +135,13 @@ 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) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt index e3cb9e8c87..4273f9a3ba 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt @@ -58,6 +58,7 @@ 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( @@ -94,7 +95,8 @@ fun ChatroomSubjectDialog( onPost(groupName.value, message.value) onClose() }, - true, + // Nothing to send without a new subject or a message. + isActive = groupName.value.isNotBlank() || message.value.isNotBlank(), ) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt index 59e1d5c36f..73bd060df0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt @@ -43,8 +43,8 @@ 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 CompactButtonHeight = Modifier.height(32.dp) -val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) +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 @@ -67,8 +67,8 @@ fun GitStatusButtons( if (closedOrApplied) { FilledTonalButton( onClick = onReopen, - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, + 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)) @@ -77,8 +77,8 @@ fun GitStatusButtons( if (isPatchOrPr) { FilledTonalButton( onClick = onMarkMerged, - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, + 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)) @@ -87,8 +87,8 @@ fun GitStatusButtons( OutlinedButton( onClick = onClose, colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, + 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)) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt index 65e4e7765f..731198bcec 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt @@ -124,7 +124,7 @@ fun RelayLeaveRequestCard() { } @Composable -fun RelayMemberEventCard( +private fun RelayMemberEventCard( icon: MaterialSymbol, title: String, subtitle: String?, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt index a8cd7086c1..5e738f51df 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt @@ -84,6 +84,18 @@ 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). @@ -96,18 +108,10 @@ fun ReportDialog( onReport: (reason: ReportType, additionalReason: String) -> Unit, 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() } + 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) } @@ -178,9 +182,9 @@ fun ReportDialog( ActionButton( text = stringRes(Res.string.report_dialog_post_report_btn), icon = MaterialSymbols.Report, - enabled = selectedReason in 0..reportTypes.lastIndex, + enabled = selectedReason in 0..ReportReasons.lastIndex, onClick = { - onReport(reportTypes[selectedReason].first, additionalReason) + onReport(ReportReasons[selectedReason].first, additionalReason) onDismiss() }, ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt index 52a2f10eb4..9a3ab37730 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt @@ -25,7 +25,6 @@ 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 @@ -142,9 +141,8 @@ fun NipCardPreview() { ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun NoPaddingSuggestionChip( +private fun NoPaddingSuggestionChip( label: String, modifier: Modifier = Modifier, ) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt index 38c8ce3edc..0642d05d96 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt @@ -86,7 +86,7 @@ private class ChangeLatch { */ @OptIn(ExperimentalAnimationApi::class) @Composable -fun DeferredCrossfade( +internal fun DeferredCrossfade( targetState: T, modifier: Modifier, contentAlignment: Alignment, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index eb8b8242f6..bf08ddbbc2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 8c1bec2d71..1c3fc89cf5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt index 673be60738..7a98ef8761 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt @@ -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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt index 701de331e7..fc724485cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -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)