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