diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt
index 1bb7fae45a..79118e9c6b 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StaticWebsite.kt
@@ -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()) {
{
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt
index 45f12f106e..457d1915c8 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt
@@ -166,12 +166,12 @@ object NappletCommands {
StaticSitePublish.run(
dataDir,
rest,
- "napplet publish
--server [--requires identity,relay,…] [--d ID] [--relay R] [--title T]",
+ "napplet publish --server [--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)
}
}
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt
index 2a3c2b35f8..e00b767220 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt
@@ -176,14 +176,14 @@ object NsiteCommands {
StaticSitePublish.run(
dataDir,
rest,
- "nsite publish --server [--d ID] [--relay R] [--title T] [--description D] [--source URL]",
+ "nsite publish --server [--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)
}
}
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt
index 2b1b18f0ef..fd702ae3db 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSitePublish.kt
@@ -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),
diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml
index 56098b0851..0e3d8e9bde 100644
--- a/commons/src/commonMain/composeResources/values/strings.xml
+++ b/commons/src/commonMain/composeResources/values/strings.xml
@@ -83,7 +83,9 @@
Static Website: %1$s
Napplet: %1$s
- Permissions:
+ Napplet
+ Website
+ What it can access
Root Site
Source:
Servers:
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt
index 73e4165be3..0680732b87 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/StaticWebsiteCard.kt
@@ -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 = 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,
+ source: String?,
+ servers: List,
+) {
+ 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 =
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
index fd235c3c43..a61407388f 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/NamedSiteEvent.kt
@@ -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.() -> 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()
}
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
index 52b45cfdc5..647a4ce17a 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/RootSiteEvent.kt
@@ -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.() -> 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()
}
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt
index 6668880538..6937d7866f 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt
@@ -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 TagArrayBuilder.siteDescription(description: String) = addUni
fun TagArrayBuilder.siteSource(url: String) = addUnique(SourceTag.assemble(url))
+fun TagArrayBuilder.siteIcon(url: String) = addUnique(IconTag.assemble(url))
+
fun TagArrayBuilder.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash))
/** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt
index c8c2913f8a..75afa97a2d 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt
@@ -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)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt
new file mode 100644
index 0000000000..dc19d5fdbe
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/IconTag.kt
@@ -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? {
+ 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)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt
index 40faba2996..df7bd1c746 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt
@@ -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.() -> 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()
}
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
index ce0e9bbc77..728e9f1323 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt
@@ -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())
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt
index 9f029a4eb9..d7d4b35182 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt
@@ -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.() -> 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()
}
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt
index 26c7b894eb..77a417dea4 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt
@@ -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())
diff --git a/tools/napplet-test/README.md b/tools/napplet-test/README.md
index 380bc4bcd5..863f3017ca 100644
--- a/tools/napplet-test/README.md
+++ b/tools/napplet-test/README.md
@@ -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 --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.