feat: render Simple Nostr Objects in the feed and thread

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
Vitor Pamplona
2026-09-21 22:26:50 +00:00
co-authored by Claude Opus 5
parent 1ac64bcc79
commit ce888dea74
15 changed files with 527 additions and 0 deletions
@@ -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,
@@ -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))
@@ -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,
@@ -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),
)
}
}
}
}
}
@@ -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) {
@@ -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()
@@ -3080,4 +3080,8 @@
<string name="buzz_persona_publish">Publish persona</string>
<string name="profile_card_follows_you">Follows you</string>
<string name="profile_card_bot">Bot</string>
<string name="sno_object_title">3D object</string>
<string name="sno_object_details">%1$d vertices · %2$d faces</string>
<string name="sno_object_unreadable">This 3D object could not be read (rule %1$s)</string>
<string name="sno_object_turn_hint">Drag to turn</string>
</resources>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import coil3.Image
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
@@ -28,3 +29,9 @@ expect fun PlatformImage.toCoilImage(): Image
/** Decodes a base64 `data:` image URI into a Coil image; throws on malformed input. */
expect fun base64DataUriToCoilImage(dataUri: String): Image
/**
* Converts a decoded [PlatformImage] straight into a Compose image, for a
* drawing that changes every frame and so has no business in an image cache.
*/
expect fun PlatformImage.toComposeImageBitmap(): ImageBitmap
@@ -0,0 +1,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<SnoObjectToRender> {
override fun create(
data: SnoObjectToRender,
options: Options,
imageLoader: ImageLoader,
): Fetcher = SnoFetcher(data)
}
object SKeyer : Keyer<SnoObjectToRender> {
override fun key(
data: SnoObjectToRender,
options: Options,
): String = data.cacheKey
}
}
@@ -0,0 +1,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
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.sno.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.sno.SnoObjectToRender
import com.vitorpamplona.amethyst.commons.sno.SnoRasterizer
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
/**
* A Simple Nostr Object drawn at a fixed size, for a feed or a thread.
*
* Rasterised once per (event, size, view) through Coil, so scrolling past the
* same object costs a memory-cache hit rather than a re-render.
*/
@Composable
fun SnoThumbnail(
payload: SnoPayload,
eventId: String,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
yawDegrees: Float = SnoRasterizer.DEFAULT_YAW_DEGREES,
pitchDegrees: Float = SnoRasterizer.DEFAULT_PITCH_DEGREES,
background: Color = Color.Transparent,
) {
val pixelSize = with(LocalDensity.current) { size.roundToPx() }
val backgroundArgb = if (background == Color.Transparent) 0 else background.toArgb()
val request =
remember(payload, eventId, pixelSize, yawDegrees, pitchDegrees, backgroundArgb) {
SnoObjectToRender(
payload = payload,
eventId = eventId,
size = pixelSize,
yawDegrees = yawDegrees,
pitchDegrees = pitchDegrees,
background = backgroundArgb,
)
}
Box(modifier.size(size), contentAlignment = Alignment.Center) {
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier.size(size),
)
}
}
@@ -0,0 +1,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,
)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import coil3.Image
import coil3.asImage
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
@@ -36,3 +38,5 @@ actual fun base64DataUriToCoilImage(dataUri: String): Image {
val encoded = SkiaImage.makeFromEncoded(Base64.decode(payload))
return Bitmap.makeFromImage(encoded).asImage(true)
}
actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap()
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.service.image
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import coil3.Image
import coil3.asImage
import com.vitorpamplona.amethyst.commons.base64Image.toPlatformImage
@@ -29,3 +31,5 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Image
actual fun PlatformImage.toCoilImage(): Image = toSkiaBitmap().asImage(true)
actual fun base64DataUriToCoilImage(dataUri: String): Image = Base64Image.toPlatformImage(dataUri).toCoilImage()
actual fun PlatformImage.toComposeImageBitmap(): ImageBitmap = toSkiaBitmap().asComposeImageBitmap()
@@ -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()