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,
isNapplet = true,
requires = event.requires(),
icon = event.icon(),
// Tapping Open hands off to the sandboxed :napplet process; the card itself never executes code.
onOpen =
if (event.paths().isNotEmpty()) {
@@ -76,6 +77,7 @@ fun RenderNamedNappletEvent(
identifier = event.identifier(),
isNapplet = true,
requires = event.requires(),
icon = event.icon(),
onOpen =
if (event.paths().isNotEmpty()) {
{ NappletLauncher.launch(context = context, manifest = event, authorPubKey = event.pubKey, identifier = event.identifier()) }
@@ -101,6 +103,7 @@ fun RenderRootSiteEvent(
servers = event.servers(),
identifier = null,
isNapplet = false,
icon = event.icon(),
onOpen =
if (event.paths().isNotEmpty()) {
{
@@ -137,6 +140,7 @@ fun RenderNamedSiteEvent(
servers = event.servers(),
identifier = event.identifier(),
isNapplet = false,
icon = event.icon(),
onOpen =
if (event.paths().isNotEmpty()) {
{
@@ -166,12 +166,12 @@ object NappletCommands {
StaticSitePublish.run(
dataDir,
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 ->
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 {
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(
dataDir,
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 ->
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)
}
} 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)
}
}
@@ -49,6 +49,7 @@ object StaticSitePublish {
val title: String?,
val description: String?,
val source: String?,
val icon: String?,
)
suspend fun run(
@@ -70,6 +71,7 @@ object StaticSitePublish {
val title = args.flag("title")
val description = args.flag("description")
val sourceUrl = args.flag("source")
val icon = args.flag("icon")
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
Context.open(dataDir).use { ctx ->
@@ -82,7 +84,7 @@ object StaticSitePublish {
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 relays =
@@ -100,6 +102,7 @@ object StaticSitePublish {
"kind" to signed.kind,
"d" to identifier,
"title" to title,
"icon" to icon,
"servers" to servers,
"requires" to requires,
"aggregate_sha256" to SiteAggregateHash.compute(result.pathTags),
@@ -83,7 +83,9 @@
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Static Website: %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_source">Source:</string>
<string name="nsite_servers">Servers:</string>
@@ -20,47 +20,63 @@
*/
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.napplet_card_kind
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_root_site
import com.vitorpamplona.amethyst.commons.resources.nsite_servers
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
// Card chrome inlined so the shared component carries no app-theme dependency (matches Amethyst's
// QuoteBorder / subtleBorder / spacing tokens).
private val CardShape = RoundedCornerShape(15.dp)
private val CardPadding = 10.dp
private val RowSpacing = 5.dp
private val DividerThickness = 0.25.dp
private val CardShape = RoundedCornerShape(16.dp)
private val IconShape = RoundedCornerShape(14.dp)
private val CardPadding = 12.dp
private val IconSize = 56.dp
/**
* The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event. It only
* displays manifest metadata (title, description, source, Blossom servers, declared permissions) and
* an **Open** button — it never executes applet code. Launching runs in the platform host's sandbox,
* supplied by the caller via [onOpen]; a null [onOpen] (no paths) hides the button.
* The inert, host-agnostic preview card for a NIP-5A static site or NIP-5D napplet event, styled like
* an app-store entry: square [icon] (with a colored monogram fallback), name, a type label, a short
* description, and an **Open** button. The technical bits a typical user doesn't care about — the
* 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.
*/
@@ -73,95 +89,195 @@ fun StaticWebsiteCard(
identifier: String?,
isNapplet: Boolean,
requires: List<String> = emptyList(),
icon: String? = 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
.clip(shape = CardShape)
.border(
1.dp,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
CardShape,
).padding(CardPadding),
.fillMaxWidth()
.clip(CardShape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CardShape)
.padding(CardPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column {
val displayTitle = title ?: identifier ?: stringResource(Res.string.nsite_root_site)
val header = if (isNapplet) Res.string.napplet_card_title else Res.string.nsite_title
Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(icon, displayTitle)
Text(
text = stringResource(header, displayTitle),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.width(12.dp))
description?.let {
Column(Modifier.weight(1f)) {
Text(
text = it,
modifier = Modifier.fillMaxWidth().padding(vertical = RowSpacing),
maxLines = 3,
text = kindLabel.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
Text(
text = displayTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
HorizontalDivider(thickness = DividerThickness)
onOpen?.let {
Spacer(Modifier.width(8.dp))
FilledTonalButton(onClick = it) { Text(stringResource(Res.string.nsite_open)) }
}
}
source?.let {
Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) {
Text(
text = stringResource(Res.string.nsite_source),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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()) {
Row(Modifier.fillMaxWidth().padding(top = RowSpacing)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringResource(Res.string.nsite_servers),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(RowSpacing))
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))
servers.forEach { ClickableUrl(it) }
}
}
}
}
}
@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. */
@Composable
private fun ClickableUrl(url: String) {
@@ -169,6 +285,7 @@ private fun ClickableUrl(url: String) {
Text(
text = url.removePrefix("https://").removePrefix("http://"),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
@@ -52,6 +52,8 @@ class NamedSiteEvent(
fun source() = tags.siteSource()
fun icon() = tags.siteIcon()
fun identifier() = dTag()
companion object {
@@ -64,6 +66,7 @@ class NamedSiteEvent(
title: String? = null,
description: String? = null,
source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NamedSiteEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -73,6 +76,7 @@ class NamedSiteEvent(
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer()
}
}
@@ -51,6 +51,8 @@ class RootSiteEvent(
fun source() = tags.siteSource()
fun icon() = tags.siteIcon()
companion object {
const val KIND = 15128
@@ -60,6 +62,7 @@ class RootSiteEvent(
title: String? = null,
description: String? = null,
source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RootSiteEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -68,6 +71,7 @@ class RootSiteEvent(
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
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.TagArrayBuilder
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.ServerTag
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>.siteIcon(url: String) = addUnique(IconTag.assemble(url))
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. */
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip5aStaticWebsites
import com.vitorpamplona.quartz.nip01Core.core.TagArray
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.ServerTag
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.siteIcon() = firstNotNullOfOrNull(IconTag::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.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -67,6 +68,7 @@ class NamedNappletEvent(
title: String? = null,
description: String? = null,
source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NamedNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -78,6 +80,7 @@ class NamedNappletEvent(
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
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.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -59,6 +60,9 @@ interface NappletManifest {
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]. */
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.nip5aStaticWebsites.siteAggregateHash
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteIcon
import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers
import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource
@@ -63,6 +64,7 @@ class RootNappletEvent(
title: String? = null,
description: String? = null,
source: String? = null,
icon: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RootNappletEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -73,6 +75,7 @@ class RootNappletEvent(
title?.let { siteTitle(it) }
description?.let { siteDescription(it) }
source?.let { siteSource(it) }
icon?.let { siteIcon(it) }
initializer()
}
}
@@ -57,6 +57,7 @@ class NappletEventTest {
title = "Calc",
description = "a calculator",
source = "https://github.com/x/calc",
icon = "https://example.com/calc.png",
),
::NamedNappletEvent,
)
@@ -70,6 +71,7 @@ class NappletEventTest {
assertEquals("Calc", event.title())
assertEquals("a calculator", event.description())
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.
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.
- `--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).
- 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.