mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
co-authored by
Claude Opus 5
parent
1ac64bcc79
commit
ce888dea74
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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) {
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+7
@@ -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
|
||||
}
|
||||
}
|
||||
+122
@@ -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
|
||||
}
|
||||
+79
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
@@ -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()
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user