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()) + } +}