From 250f918ebe6b8b5ed94172246eaf4c94f007d8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:57:40 +0000 Subject: [PATCH] feat(nip89): parse and display app-handler t/i/a/client tags NIP-89 handler cards (kind 31990) previously only surfaced the supported kinds and platform links, and even the platform links were silently dropped when the link tag had no entity type. This widens both parsing and display: - Fix PlatformLinkTag.match to accept 2-element link tags (entity type is optional per NIP-89), so e.g. NostrHub's `["android", "intent:..."]` links are no longer discarded. - Parse the `i` supported-NIP tags (NostrHub points them at the NIP spec markdown files) into a new SupportedNipTag, plus accessors for `t` categories, `a` related addresses, and the `client` tag. - Extend AppDefinitionEvent.build() with categories/supportedNips/ relatedAddresses/client so creation stays symmetric with parsing. - Render the new data in RenderAppDefinition: category chips, compact tappable rows for related addressable events (source repo, store listing, ...), a "via " line, and NIP chips with a "+N" overflow that opens a bottom sheet listing every supported NIP linking to its spec. The deprecated `alt` tag is intentionally not surfaced on the handler card. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn --- .../amethyst/ui/note/types/AppDefinition.kt | 257 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 6 + .../definition/AppDefinitionEvent.kt | 25 ++ .../definition/TagArrayBuilderExt.kt | 4 + .../definition/TagArrayExt.kt | 3 + .../definition/tags/PlatformLinkTag.kt | 4 +- .../definition/tags/SupportedNipTag.kt | 70 +++++ .../definition/AppDefinitionEventTest.kt | 115 ++++++++ .../definition/PlatformLinkTagTest.kt | 24 +- .../definition/SupportedNipTagTest.kt | 73 +++++ 10 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/SupportedNipTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/SupportedNipTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index 5d91388fcc..102c71bf9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -40,20 +40,26 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -66,6 +72,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -84,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -92,10 +100,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.SupportedNipTag import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -247,8 +258,21 @@ fun RenderAppDefinition( } } - Row(modifier = Modifier.padding(top = 4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp), + ) { ByAuthorChip(noteEvent.pubKey, accountViewModel, nav) + + val client = remember(noteEvent) { noteEvent.client()?.name } + if (!client.isNullOrBlank()) { + Text( + text = stringRes(R.string.app_definition_via, client), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 8.dp), + ) + } } val website = remember(theAppMetadata) { theAppMetadata.website } @@ -288,14 +312,34 @@ fun RenderAppDefinition( } } + val categories = remember(noteEvent) { noteEvent.categories().distinct() } + if (categories.isNotEmpty()) { + SectionLabel(stringRes(R.string.app_definition_categories)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + categories.forEach { CategoryChip(it) } + } + } + + val relatedAddresses = remember(noteEvent) { noteEvent.relatedAddresses() } + if (relatedAddresses.isNotEmpty()) { + SectionLabel(stringRes(R.string.app_definition_related)) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + relatedAddresses.forEach { aTag -> + RelatedEventRow(aTag, accountViewModel, nav) + } + } + } + val platforms = remember(noteEvent) { noteEvent.platformLinks().map { it.platform }.distinct() } if (platforms.isNotEmpty()) { - Text( - text = stringRes(R.string.app_definition_available_on), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + SectionLabel(stringRes(R.string.app_definition_available_on)) FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -307,12 +351,7 @@ fun RenderAppDefinition( val supportedKinds = remember(noteEvent) { noteEvent.supportedKinds() } if (supportedKinds.isNotEmpty()) { - Text( - text = stringRes(R.string.app_definition_handles), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + SectionLabel(stringRes(R.string.app_definition_handles)) FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -326,6 +365,189 @@ fun RenderAppDefinition( } } } + + val supportedNips = remember(noteEvent) { noteEvent.supportedNips() } + if (supportedNips.isNotEmpty()) { + SupportedNipsSection(supportedNips) + } + } + } + } +} + +private const val VISIBLE_SUPPORTED_NIP_LIMIT = 8 + +/** A section header matching the metrics used by the platforms/handles rows. */ +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) +} + +/** + * NIPs the app declares it supports (NostrHub-style `i` tags). Shows a handful of + * chips inline with a "+N" overflow that opens a bottom sheet listing every NIP, + * each one opening its spec. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SupportedNipsSection(nips: List) { + var showAllSheet by rememberSaveable { mutableStateOf(false) } + val visible = remember(nips) { nips.take(VISIBLE_SUPPORTED_NIP_LIMIT) } + val overflow = (nips.size - VISIBLE_SUPPORTED_NIP_LIMIT).coerceAtLeast(0) + val uri = LocalUriHandler.current + + SectionLabel(stringRes(R.string.app_definition_implements)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 6.dp, bottom = 5.dp), + ) { + visible.forEach { nip -> + NipChip(nip.nip) { runCatching { uri.openUri(nip.url) } } + } + if (overflow > 0) { + OverflowChip(overflow) { showAllSheet = true } + } + } + + if (showAllSheet) { + AllNipsSheet(nips = nips, onDismiss = { showAllSheet = false }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AllNipsSheet( + nips: List, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val uri = LocalUriHandler.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Text( + text = stringRes(R.string.app_definition_supported_nips), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = nips, key = { it.url }) { nip -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + val target = nip.url + scope.launch { + sheetState.hide() + onDismiss() + runCatching { uri.openUri(target) } + } + }.padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NipChip(nip.nip) + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = nip.url.removePrefix("https://"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun NipChip( + nip: String, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(50) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier, + ) { + Text( + text = stringRes(R.string.app_definition_nip, nip), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CategoryChip(category: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = category, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +/** + * Compact, tappable row for an `a`-tag reference (source repo, store listing, ...). + * Loads the addressable event so the tap can open it; the d-tag is shown as the + * label since publishers use human-readable identifiers there. + */ +@Composable +private fun RelatedEventRow( + aTag: ATag, + accountViewModel: AccountViewModel, + nav: INav, +) { + val address = remember(aTag) { Address(aTag.kind, aTag.pubKeyHex, aTag.dTag) } + val shape = RoundedCornerShape(8.dp) + + LoadAddressableNote(address, accountViewModel) { note -> + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .clickable { + if (note != null) { + routeFor(note, accountViewModel.account)?.let { nav.nav(it) } + } + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) { + KindChip(aTag.kind) + if (aTag.dTag.isNotBlank()) { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = aTag.dTag, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -335,10 +557,15 @@ private const val VISIBLE_SUPPORTED_KIND_LIMIT = 12 /** Same shape and metrics as [KindChip] so it lines up with the kind chips. */ @Composable -private fun OverflowChip(count: Int) { +private fun OverflowChip( + count: Int, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(50) Surface( - shape = RoundedCornerShape(50), + shape = shape, color = MaterialTheme.colorScheme.surfaceVariant, + modifier = if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier, ) { Text( text = "+$count", diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b88a199ff3..6ce7b1c706 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -615,6 +615,12 @@ Handles by Available on + Categories + Related + Implements + Supported NIPs + via %1$s + NIP-%1$s Web Android iOS diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index 78cf731091..8dc4f3ee18 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -25,7 +25,11 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags +import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider @@ -33,6 +37,7 @@ import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.client import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -103,6 +108,18 @@ class AppDefinitionEvent( fun platformLinks() = tags.platformLinks() + /** Categories the app declares via `t` tags (e.g. "social", "video"). */ + fun categories() = tags.hashtags() + + /** NIPs the app declares it supports via NostrHub-style `i` tags. */ + fun supportedNips() = tags.supportedNips() + + /** Related addressable events referenced via `a` tags (source repo, store listing, ...). */ + fun relatedAddresses() = tags.taggedATags() + + /** The client (NIP-89 `client` tag) that published this handler, if any. */ + fun client() = tags.client().firstOrNull() + override fun publishedAt(): Long? { val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) @@ -134,6 +151,10 @@ class AppDefinitionEvent( details: AppMetadata, supportedKinds: Set, links: List, + categories: List = emptyList(), + supportedNips: List = emptyList(), + relatedAddresses: List = emptyList(), + client: String? = null, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -141,6 +162,10 @@ class AppDefinitionEvent( dTag(dTag) kinds(supportedKinds) links(links) + if (categories.isNotEmpty()) hashtags(categories) + if (supportedNips.isNotEmpty()) supportedNips(supportedNips) + if (relatedAddresses.isNotEmpty()) aTags(relatedAddresses) + client?.let { client(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt index 2055895620..5bfe047336 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayBuilderExt.kt @@ -22,5 +22,9 @@ package com.vitorpamplona.quartz.nip89AppHandlers.definition import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent.Companion.PlatformLink +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.SupportedNipTag fun TagArrayBuilder.links(links: List) = addAll(links.map { it.toTagArray() }) + +/** Adds one `i` tag per supported NIP, pointing at the NIP spec markdown file. */ +fun TagArrayBuilder.supportedNips(urls: List) = addAll(urls.map { SupportedNipTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt index 87818b5e79..55985c8f01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt @@ -22,5 +22,8 @@ package com.vitorpamplona.quartz.nip89AppHandlers.definition import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.SupportedNipTag fun TagArray.platformLinks() = this.mapNotNull(PlatformLinkTag::parse) + +fun TagArray.supportedNips() = this.mapNotNull(SupportedNipTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt index b6a8ba7b79..0f3a9ab9dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt @@ -34,7 +34,9 @@ class PlatformLinkTag( companion object { fun match(tag: Tag): Boolean = - if (tag.has(2)) { + // Needs at least the platform code and the uri (2 elements). The entity + // type (index 2) is optional per NIP-89, so a 2-element link still matches. + if (tag.has(1)) { tag[0] == PlatformType.IOS.code || tag[0] == PlatformType.WEB.code || tag[0] == PlatformType.ANDROID.code } else { false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/SupportedNipTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/SupportedNipTag.kt new file mode 100644 index 0000000000..95cad36eb1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/SupportedNipTag.kt @@ -0,0 +1,70 @@ +/* + * 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.nip89AppHandlers.definition.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * A NIP an app handler declares it supports. NostrHub publishes these as `i` tags + * pointing at the NIP spec markdown files, e.g. + * + * ``` + * ["i", "https://github.com/nostr-protocol/nips/blob/master/01.md"] + * ``` + * + * The NIP identifier (`01`, `5A`, ...) is extracted from the file name. The + * original [url] is kept so the UI can open the spec. + */ +class SupportedNipTag( + val nip: String, + val url: String, +) { + fun toTagArray() = assemble(url) + + companion object { + const val TAG_NAME = "i" + + // The trailing `.md` segment of a NIP spec url. NIP ids are 1-3 + // alphanumeric chars ("1", "01", "5A", "B7", ...). + private val NIP_FILE = Regex("""/([0-9A-Za-z]{1,3})\.md(?:[?#].*)?$""") + + fun parseNip(url: String): String? = + NIP_FILE + .find(url) + ?.groupValues + ?.get(1) + ?.uppercase() + + fun parse(tag: Tag): SupportedNipTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val nip = parseNip(tag[1]) ?: return null + return SupportedNipTag(nip, tag[1]) + } + + fun assemble(url: String) = arrayOfNotNull(TAG_NAME, url) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEventTest.kt new file mode 100644 index 0000000000..7dd704a1d9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEventTest.kt @@ -0,0 +1,115 @@ +/* + * 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.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AppDefinitionEventTest { + // A trimmed-down copy of the real NostrHub-published Amethyst handler, keeping at + // least one of every tag kind we now parse. + private val event = + AppDefinitionEvent( + id = "00", + pubKey = "aa", + createdAt = 1782775172, + content = """{"name":"Amethyst","about":"Nostr client for Android","website":"https://amethyst.social/"}""", + sig = "00", + tags = + arrayOf( + arrayOf("d", "1685802317447"), + arrayOf("alt", "NIP-89 handler: Amethyst"), + arrayOf("k", "0"), + arrayOf("k", "1"), + arrayOf("android", "intent:#Intent;scheme=nostr;package=com.vitorpamplona.amethyst;end`;"), + arrayOf("t", "social"), + arrayOf("t", "video"), + arrayOf("i", "https://github.com/nostr-protocol/nips/blob/master/01.md"), + arrayOf("i", "https://github.com/nostr-protocol/nips/blob/master/5A.md"), + arrayOf("a", "30617:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:amethyst", "wss://relay.ngit.dev/"), + arrayOf("client", "NostrHub"), + ), + ) + + @Test + fun parsesMetadataAndKinds() { + assertEquals("Amethyst", event.appMetaData()?.name) + assertEquals(setOf(0, 1), event.supportedKinds().toSet()) + } + + @Test + fun parsesAndroidPlatformLinkWithoutEntityType() { + // The fix for has(2) -> has(1): a 2-element android tag must now be picked up. + val links = event.platformLinks() + assertEquals(1, links.size) + assertEquals("android", links[0].platform) + } + + @Test + fun parsesCategories() { + assertEquals(listOf("social", "video"), event.categories()) + } + + @Test + fun parsesSupportedNips() { + assertEquals(listOf("01", "5A"), event.supportedNips().map { it.nip }) + } + + @Test + fun parsesRelatedAddresses() { + val addresses = event.relatedAddresses() + assertEquals(1, addresses.size) + assertEquals(30617, addresses[0].kind) + assertEquals("amethyst", addresses[0].dTag) + } + + @Test + fun parsesClient() { + assertEquals("NostrHub", event.client()?.name) + } + + @Test + fun buildWritesAllTags() { + val template = + AppDefinitionEvent.build( + details = AppMetadata().apply { name = "Amethyst" }, + supportedKinds = setOf(1, 30023), + links = emptyList(), + categories = listOf("social"), + supportedNips = listOf("https://github.com/nostr-protocol/nips/blob/master/01.md"), + relatedAddresses = listOf(ATag(30617, "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", "amethyst")), + client = "Amethyst", + dTag = "test", + ) + + assertEquals(AppDefinitionEvent.KIND, template.kind) + assertTrue(template.tags.any { it[0] == "t" && it[1] == "social" }) + assertTrue(template.tags.any { it[0] == "i" && it[1].endsWith("01.md") }) + assertTrue(template.tags.any { it[0] == "a" && it[1].startsWith("30617:") }) + + val client = template.tags.firstOrNull { it[0] == "client" } + assertNotNull(client) + assertEquals("Amethyst", client[1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt index 27b5e1f54f..becdb2f496 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt @@ -90,8 +90,26 @@ class PlatformLinkTagTest { val tagArray = original.toTagArray() val parsed = PlatformLinkTag.parse(tagArray) - // Without entityType, tag only has 2 elements, so match requires has(2) which means 3 elements - // This is expected behavior per NIP-89 spec (entityType is specified) - assertNull(parsed) + // The entity type is optional per NIP-89, so a 2-element link must still parse. + assertNotNull(parsed) + assertEquals("android", parsed.platform) + assertEquals("amethyst://open/", parsed.uri) + assertNull(parsed.entityType) + } + + @Test + fun parsesAndroidIntentLinkWithoutEntityType() { + // The real-world shape published by NostrHub: a 2-element android intent link. + val tag = + arrayOf( + "android", + "intent:#Intent;scheme=nostr;package=com.vitorpamplona.amethyst;end`;", + ) + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("android", result.platform) + assertEquals("intent:#Intent;scheme=nostr;package=com.vitorpamplona.amethyst;end`;", result.uri) + assertNull(result.entityType) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/SupportedNipTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/SupportedNipTagTest.kt new file mode 100644 index 0000000000..7fa62fc19a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/SupportedNipTagTest.kt @@ -0,0 +1,73 @@ +/* + * 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.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.SupportedNipTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SupportedNipTagTest { + @Test + fun parsesNumericNip() { + val tag = arrayOf("i", "https://github.com/nostr-protocol/nips/blob/master/01.md") + val result = SupportedNipTag.parse(tag) + + assertEquals("01", result?.nip) + assertEquals("https://github.com/nostr-protocol/nips/blob/master/01.md", result?.url) + } + + @Test + fun parsesHexNipAndUppercases() { + val tag = arrayOf("i", "https://github.com/nostr-protocol/nips/blob/master/5A.md") + assertEquals("5A", SupportedNipTag.parse(tag)?.nip) + + val lower = arrayOf("i", "https://github.com/nostr-protocol/nips/blob/master/7d.md") + assertEquals("7D", SupportedNipTag.parse(lower)?.nip) + } + + @Test + fun ignoresNonNipIdentityClaim() { + // A regular NIP-39 identity claim is also an `i` tag, but it is not a NIP spec url. + val tag = arrayOf("i", "github:vitorpamplona", "https://gist.github.com/abc") + assertNull(SupportedNipTag.parse(tag)) + } + + @Test + fun ignoresUrlWithoutMdFile() { + val tag = arrayOf("i", "https://github.com/nostr-protocol/nips") + assertNull(SupportedNipTag.parse(tag)) + } + + @Test + fun ignoresWrongTagName() { + assertNull(SupportedNipTag.parse(arrayOf("t", "https://example.com/01.md"))) + } + + @Test + fun roundTrips() { + val original = SupportedNipTag("01", "https://github.com/nostr-protocol/nips/blob/master/01.md") + val parsed = SupportedNipTag.parse(original.toTagArray()) + + assertEquals(original.nip, parsed?.nip) + assertEquals(original.url, parsed?.url) + } +}