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.