feat(napplets): app-store-style card + icon manifest tag; demote capabilities

Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.

Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
  icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 17:13:41 +00:00
parent 4cd71b140c
commit a2eae42c07
16 changed files with 278 additions and 84 deletions
@@ -49,6 +49,7 @@ fun RenderRootNappletEvent(
identifier = null, identifier = null,
isNapplet = true, isNapplet = true,
requires = event.requires(), requires = event.requires(),
icon = event.icon(),
// Tapping Open hands off to the sandboxed :napplet process; the card itself never executes code. // Tapping Open hands off to the sandboxed :napplet process; the card itself never executes code.
onOpen = onOpen =
if (event.paths().isNotEmpty()) { if (event.paths().isNotEmpty()) {
@@ -76,6 +77,7 @@ fun RenderNamedNappletEvent(
identifier = event.identifier(), identifier = event.identifier(),
isNapplet = true, isNapplet = true,
requires = event.requires(), requires = event.requires(),
icon = event.icon(),
onOpen = onOpen =
if (event.paths().isNotEmpty()) { if (event.paths().isNotEmpty()) {
{ NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = event.identifier()) } { NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = event.identifier()) }
@@ -101,6 +103,7 @@ fun RenderRootSiteEvent(
servers = event.servers(), servers = event.servers(),
identifier = null, identifier = null,
isNapplet = false, isNapplet = false,
icon = event.icon(),
onOpen = onOpen =
if (event.paths().isNotEmpty()) { if (event.paths().isNotEmpty()) {
{ {
@@ -137,6 +140,7 @@ fun RenderNamedSiteEvent(
servers = event.servers(), servers = event.servers(),
identifier = event.identifier(), identifier = event.identifier(),
isNapplet = false, isNapplet = false,
icon = event.icon(),
onOpen = onOpen =
if (event.paths().isNotEmpty()) { if (event.paths().isNotEmpty()) {
{ {
@@ -166,12 +166,12 @@ object NappletCommands {
StaticSitePublish.run( StaticSitePublish.run(
dataDir, dataDir,
rest, rest,
"napplet publish <dir> --server <blossom-url> [--requires identity,relay,…] [--d ID] [--relay R] [--title T]", "napplet publish <dir> --server <blossom-url> [--requires identity,relay,…] [--d ID] [--relay R] [--title T] [--icon URL]",
) { m -> ) { m ->
if (m.identifier != null) { if (m.identifier != null) {
NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source) NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon)
} else { } else {
RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source) RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon)
} }
} }
@@ -176,14 +176,14 @@ object NsiteCommands {
StaticSitePublish.run( StaticSitePublish.run(
dataDir, dataDir,
rest, rest,
"nsite publish <dir> --server <blossom-url> [--d ID] [--relay R] [--title T] [--description D] [--source URL]", "nsite publish <dir> --server <blossom-url> [--d ID] [--relay R] [--title T] [--description D] [--source URL] [--icon URL]",
) { m -> ) { m ->
if (m.identifier != null) { if (m.identifier != null) {
NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source) { NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source, m.icon) {
siteAggregateHash(m.paths) siteAggregateHash(m.paths)
} }
} else { } else {
RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source) { RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source, m.icon) {
siteAggregateHash(m.paths) siteAggregateHash(m.paths)
} }
} }
@@ -49,6 +49,7 @@ object StaticSitePublish {
val title: String?, val title: String?,
val description: String?, val description: String?,
val source: String?, val source: String?,
val icon: String?,
) )
suspend fun run( suspend fun run(
@@ -70,6 +71,7 @@ object StaticSitePublish {
val title = args.flag("title") val title = args.flag("title")
val description = args.flag("description") val description = args.flag("description")
val sourceUrl = args.flag("source") val sourceUrl = args.flag("source")
val icon = args.flag("icon")
val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
Context.open(dataDir).use { ctx -> Context.open(dataDir).use { ctx ->
@@ -82,7 +84,7 @@ object StaticSitePublish {
return Output.error("upload_failed", e.message ?: "upload failed") return Output.error("upload_failed", e.message ?: "upload failed")
} }
val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl) val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl, icon)
val signed = ctx.signer.sign(buildEvent(manifest)) val signed = ctx.signer.sign(buildEvent(manifest))
val relays = val relays =
@@ -100,6 +102,7 @@ object StaticSitePublish {
"kind" to signed.kind, "kind" to signed.kind,
"d" to identifier, "d" to identifier,
"title" to title, "title" to title,
"icon" to icon,
"servers" to servers, "servers" to servers,
"requires" to requires, "requires" to requires,
"aggregate_sha256" to SiteAggregateHash.compute(result.pathTags), "aggregate_sha256" to SiteAggregateHash.compute(result.pathTags),
@@ -83,7 +83,9 @@
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card --> <!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Static Website: %1$s</string> <string name="nsite_title">Static Website: %1$s</string>
<string name="napplet_card_title">Napplet: %1$s</string> <string name="napplet_card_title">Napplet: %1$s</string>
<string name="napplet_card_permissions">Permissions:</string> <string name="napplet_card_kind">Napplet</string>
<string name="nsite_website_kind">Website</string>
<string name="napplet_card_permissions">What it can access</string>
<string name="nsite_root_site">Root Site</string> <string name="nsite_root_site">Root Site</string>
<string name="nsite_source">Source:</string> <string name="nsite_source">Source:</string>
<string name="nsite_servers">Servers:</string> <string name="nsite_servers">Servers:</string>
@@ -20,47 +20,63 @@
*/ */
package com.vitorpamplona.amethyst.commons.ui.note package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_card_kind
import com.vitorpamplona.amethyst.commons.resources.napplet_card_permissions import com.vitorpamplona.amethyst.commons.resources.napplet_card_permissions
import com.vitorpamplona.amethyst.commons.resources.napplet_card_title
import com.vitorpamplona.amethyst.commons.resources.nsite_open import com.vitorpamplona.amethyst.commons.resources.nsite_open
import com.vitorpamplona.amethyst.commons.resources.nsite_root_site import com.vitorpamplona.amethyst.commons.resources.nsite_root_site
import com.vitorpamplona.amethyst.commons.resources.nsite_servers import com.vitorpamplona.amethyst.commons.resources.nsite_servers
import com.vitorpamplona.amethyst.commons.resources.nsite_source import com.vitorpamplona.amethyst.commons.resources.nsite_source
import com.vitorpamplona.amethyst.commons.resources.nsite_title import com.vitorpamplona.amethyst.commons.resources.nsite_website_kind
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
// Card chrome inlined so the shared component carries no app-theme dependency (matches Amethyst's private val CardShape = RoundedCornerShape(16.dp)
// QuoteBorder / subtleBorder / spacing tokens). private val IconShape = RoundedCornerShape(14.dp)
private val CardShape = RoundedCornerShape(15.dp) private val CardPadding = 12.dp
private val CardPadding = 10.dp private val IconSize = 56.dp
private val RowSpacing = 5.dp
private val DividerThickness = 0.25.dp
/** /**
* The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event. It only * The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event, styled like
* displays manifest metadata (title, description, source, Blossom servers, declared permissions) and * an app-store entry: square [icon] (with a colored monogram fallback), name, a type label, a short
* an **Open** button — it never executes applet code. Launching runs in the platform host's sandbox, * description, and an **Open** button. The technical bits a typical user doesn't care about — the
* supplied by the caller via [onOpen]; a null [onOpen] (no paths) hides the button. * declared capabilities, Blossom servers, and source URL — are tucked behind a "What it can access"
* disclosure; capabilities are also re-confirmed at the consent prompt when the napplet actually uses
* one. It never executes applet code; launching runs in the platform host's sandbox via [onOpen]
* (a null [onOpen] — no paths — hides the button).
* *
* Both the Android feed and a future desktop feed render this identical card so the two can't drift. * Both the Android feed and a future desktop feed render this identical card so the two can't drift.
*/ */
@@ -73,95 +89,195 @@ fun StaticWebsiteCard(
identifier: String?, identifier: String?,
isNapplet: Boolean, isNapplet: Boolean,
requires: List<String> = emptyList(), requires: List<String> = emptyList(),
icon: String? = null,
onOpen: (() -> Unit)? = null, onOpen: (() -> Unit)? = null,
) { ) {
Row( val displayTitle = title?.ifBlank { null } ?: identifier?.ifBlank { null } ?: stringResource(Res.string.nsite_root_site)
val kindLabel = stringResource(if (isNapplet) Res.string.napplet_card_kind else Res.string.nsite_website_kind)
Column(
modifier = modifier =
Modifier Modifier
.clip(shape = CardShape) .fillMaxWidth()
.border( .clip(CardShape)
1.dp, .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CardShape)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), .padding(CardPadding),
CardShape, verticalArrangement = Arrangement.spacedBy(10.dp),
).padding(CardPadding),
) { ) {
Column { Row(verticalAlignment = Alignment.CenterVertically) {
val displayTitle = title ?: identifier ?: stringResource(Res.string.nsite_root_site) AppIcon(icon, displayTitle)
val header = if (isNapplet) Res.string.napplet_card_title else Res.string.nsite_title
Text( Spacer(Modifier.width(12.dp))
text = stringResource(header, displayTitle),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
description?.let { Column(Modifier.weight(1f)) {
Text( Text(
text = it, text = kindLabel.uppercase(),
modifier = Modifier.fillMaxWidth().padding(vertical = RowSpacing), style = MaterialTheme.typography.labelSmall,
maxLines = 3, color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
Text(
text = displayTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
HorizontalDivider(thickness = DividerThickness) onOpen?.let {
Spacer(Modifier.width(8.dp))
FilledTonalButton(onClick = it) { Text(stringResource(Res.string.nsite_open)) }
}
}
source?.let { description?.takeIf { it.isNotBlank() }?.let {
Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) { Text(
Text( text = it,
text = stringResource(Res.string.nsite_source), style = MaterialTheme.typography.bodyMedium,
maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow = TextOverflow.Ellipsis, maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
if (requires.isNotEmpty() || !source.isNullOrBlank() || servers.isNotEmpty()) {
DetailsDisclosure(requires = requires, source = source, servers = servers)
}
}
}
/** Square app icon: the manifest [iconUrl] when present, otherwise a colored monogram from [title]. */
@Composable
private fun AppIcon(
iconUrl: String?,
title: String,
) {
if (!iconUrl.isNullOrBlank()) {
AsyncImage(
model = iconUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(IconSize).clip(IconShape),
)
} else {
Box(
modifier =
Modifier
.size(IconSize)
.clip(IconShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = title.trim().take(1).uppercase(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
}
}
/** Tap-to-expand "What it can access": capability rows (icon + name) plus source/servers details. */
@Composable
private fun DetailsDisclosure(
requires: List<String>,
source: String?,
servers: List<String>,
) {
var expanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
MaterialSymbols.Shield,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(Res.string.napplet_card_permissions),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
requires.forEach { capability ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
capabilitySymbol(capability),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = capability.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyMedium,
) )
Spacer(modifier = Modifier.width(RowSpacing))
ClickableUrl(it)
} }
} }
source?.takeIf { it.isNotBlank() }?.let {
LabeledUrl(stringResource(Res.string.nsite_source), it)
}
if (servers.isNotEmpty()) { if (servers.isNotEmpty()) {
Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
text = stringResource(Res.string.nsite_servers), text = stringResource(Res.string.nsite_servers),
maxLines = 1, style = MaterialTheme.typography.labelMedium,
overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.width(RowSpacing)) servers.forEach { ClickableUrl(it) }
Column {
servers.forEach { server -> ClickableUrl(server) }
}
}
}
if (requires.isNotEmpty()) {
Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) {
Text(
text = stringResource(Res.string.napplet_card_permissions),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(RowSpacing))
Text(
text = requires.joinToString(", "),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
onOpen?.let {
Button(
onClick = it,
modifier = Modifier.fillMaxWidth().padding(top = RowSpacing),
) {
Text(stringResource(Res.string.nsite_open))
} }
} }
} }
} }
} }
@Composable
private fun LabeledUrl(
label: String,
url: String,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(6.dp))
ClickableUrl(url)
}
}
/** Best-effort capability → icon map for the disclosure (host-agnostic; unknown domains fall back). */
private fun capabilitySymbol(domain: String): MaterialSymbol =
when (domain.lowercase()) {
"identity" -> MaterialSymbols.AccountCircle
"keys" -> MaterialSymbols.Key
"relay" -> MaterialSymbols.Public
"storage" -> MaterialSymbols.Storage
"value" -> MaterialSymbols.Bolt
"resource" -> MaterialSymbols.Language
"upload" -> MaterialSymbols.Upload
"shell" -> MaterialSymbols.Tune
else -> MaterialSymbols.Lock
}
/** A primary-colored, single-line clickable URL that opens in the platform's default handler. */ /** A primary-colored, single-line clickable URL that opens in the platform's default handler. */
@Composable @Composable
private fun ClickableUrl(url: String) { private fun ClickableUrl(url: String) {
@@ -169,6 +285,7 @@ private fun ClickableUrl(url: String) {
Text( Text(
text = url.removePrefix("https://").removePrefix("http://"), text = url.removePrefix("https://").removePrefix("http://"),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier =
@@ -52,6 +52,8 @@ class NamedSiteEvent(
fun source() = tags.siteSource() fun source() = tags.siteSource()
fun icon() = tags.siteIcon()
fun identifier() = dTag() fun identifier() = dTag()
companion object { companion object {
@@ -64,6 +66,7 @@ class NamedSiteEvent(
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
source: String? = null, source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NamedSiteEvent>.() -> Unit = {}, initializer: TagArrayBuilder<NamedSiteEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
@@ -73,6 +76,7 @@ class NamedSiteEvent(
title?.let { siteTitle(it) } title?.let { siteTitle(it) }
description?.let { siteDescription(it) } description?.let { siteDescription(it) }
source?.let { siteSource(it) } source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer() initializer()
} }
} }
@@ -51,6 +51,8 @@ class RootSiteEvent(
fun source() = tags.siteSource() fun source() = tags.siteSource()
fun icon() = tags.siteIcon()
companion object { companion object {
const val KIND = 15128 const val KIND = 15128
@@ -60,6 +62,7 @@ class RootSiteEvent(
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
source: String? = null, source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RootSiteEvent>.() -> Unit = {}, initializer: TagArrayBuilder<RootSiteEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
@@ -68,6 +71,7 @@ class RootSiteEvent(
title?.let { siteTitle(it) } title?.let { siteTitle(it) }
description?.let { siteDescription(it) } description?.let { siteDescription(it) }
source?.let { siteSource(it) } source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer() initializer()
} }
} }
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.IconTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag
@@ -40,6 +41,8 @@ fun <T : Event> TagArrayBuilder<T>.siteDescription(description: String) = addUni
fun <T : Event> TagArrayBuilder<T>.siteSource(url: String) = addUnique(SourceTag.assemble(url)) fun <T : Event> TagArrayBuilder<T>.siteSource(url: String) = addUnique(SourceTag.assemble(url))
fun <T : Event> TagArrayBuilder<T>.siteIcon(url: String) = addUnique(IconTag.assemble(url))
fun <T : Event> TagArrayBuilder<T>.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash)) fun <T : Event> TagArrayBuilder<T>.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash))
/** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */ /** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip5aStaticWebsites
import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.IconTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag
@@ -38,4 +39,6 @@ fun TagArray.siteDescription() = firstNotNullOfOrNull(DescriptionTag::parse)
fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse) fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse)
fun TagArray.siteIcon() = firstNotNullOfOrNull(IconTag::parse)
fun TagArray.siteAggregateHash() = firstNotNullOfOrNull(XTag::parse) fun TagArray.siteAggregateHash() = firstNotNullOfOrNull(XTag::parse)
@@ -0,0 +1,40 @@
/*
* 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.nip5aStaticWebsites.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The `icon` tag: a URL to the site's / napplet's square app icon, for richer launcher cards. */
class IconTag {
companion object {
const val TAG_NAME = "icon"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -67,6 +68,7 @@ class NamedNappletEvent(
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
source: String? = null, source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NamedNappletEvent>.() -> Unit = {}, initializer: TagArrayBuilder<NamedNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
@@ -78,6 +80,7 @@ class NamedNappletEvent(
title?.let { siteTitle(it) } title?.let { siteTitle(it) }
description?.let { siteDescription(it) } description?.let { siteDescription(it) }
source?.let { siteSource(it) } source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer() initializer()
} }
} }
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -59,6 +60,9 @@ interface NappletManifest {
fun source(): String? = tags.siteSource() fun source(): String? = tags.siteSource()
/** `icon` tag: URL to the napplet's square app icon, when the publisher supplied one. */
fun icon(): String? = tags.siteIcon()
/** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */ /** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */
fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths()) fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths())
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -63,6 +64,7 @@ class RootNappletEvent(
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
source: String? = null, source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RootNappletEvent>.() -> Unit = {}, initializer: TagArrayBuilder<RootNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) { ) = eventTemplate(KIND, "", createdAt) {
@@ -73,6 +75,7 @@ class RootNappletEvent(
title?.let { siteTitle(it) } title?.let { siteTitle(it) }
description?.let { siteDescription(it) } description?.let { siteDescription(it) }
source?.let { siteSource(it) } source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer() initializer()
} }
} }
@@ -57,6 +57,7 @@ class NappletEventTest {
title = "Calc", title = "Calc",
description = "a calculator", description = "a calculator",
source = "https://github.com/x/calc", source = "https://github.com/x/calc",
icon = "https://example.com/calc.png",
), ),
::NamedNappletEvent, ::NamedNappletEvent,
) )
@@ -70,6 +71,7 @@ class NappletEventTest {
assertEquals("Calc", event.title()) assertEquals("Calc", event.title())
assertEquals("a calculator", event.description()) assertEquals("a calculator", event.description())
assertEquals("https://github.com/x/calc", event.source()) assertEquals("https://github.com/x/calc", event.source())
assertEquals("https://example.com/calc.png", event.icon())
// build() stamps the x aggregate, and it verifies against the path tags. // build() stamps the x aggregate, and it verifies against the path tags.
assertNotNull(event.declaredAggregateHash()) assertNotNull(event.declaredAggregateHash())
+2
View File
@@ -23,6 +23,8 @@ amy napplet publish tools/napplet-test \
``` ```
- `--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129. - `--d` makes it an addressable kind-35129 napplet; omit it for a root kind-15129.
- `--icon https://…/icon.png` sets the square app icon shown on the launcher card (optional; the card
falls back to a colored monogram from the title when absent).
- For a plain static site, use `amy nsite publish <dir> --server …` (kind 15128/35128). - For a plain static site, use `amy nsite publish <dir> --server …` (kind 15128/35128).
- Use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and - Use the **same key you're logged in as in Amethyst**, so the napplet appears under your account and
the identity reads (`getProfile`, `getFollows`, …) have data. the identity reads (`getProfile`, `getFollows`, …) have data.