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 <client>" 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
This commit is contained in:
Claude
2026-06-29 23:57:40 +00:00
parent 2152d5d5d6
commit 250f918ebe
10 changed files with 562 additions and 19 deletions
@@ -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<SupportedNipTag>) {
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<SupportedNipTag>,
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",
+6
View File
@@ -615,6 +615,12 @@
<string name="app_definition_handles">Handles</string>
<string name="app_definition_by">by</string>
<string name="app_definition_available_on">Available on</string>
<string name="app_definition_categories">Categories</string>
<string name="app_definition_related">Related</string>
<string name="app_definition_implements">Implements</string>
<string name="app_definition_supported_nips">Supported NIPs</string>
<string name="app_definition_via">via %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
@@ -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<Int>,
links: List<PlatformLink>,
categories: List<String> = emptyList(),
supportedNips: List<String> = emptyList(),
relatedAddresses: List<ATag> = emptyList(),
client: String? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AppDefinitionEvent>.() -> 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()
}
}
@@ -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<AppDefinitionEvent>.links(links: List<PlatformLink>) = addAll(links.map { it.toTagArray() })
/** Adds one `i` tag per supported NIP, pointing at the NIP spec markdown file. */
fun TagArrayBuilder<AppDefinitionEvent>.supportedNips(urls: List<String>) = addAll(urls.map { SupportedNipTag.assemble(it) })
@@ -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)
@@ -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
@@ -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 `<id>.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)
}
}
@@ -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:<bech32>#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])
}
}
@@ -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/<bech32>", 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:<bech32>#Intent;scheme=nostr;package=com.vitorpamplona.amethyst;end`;",
)
val result = PlatformLinkTag.parse(tag)
assertNotNull(result)
assertEquals("android", result.platform)
assertEquals("intent:<bech32>#Intent;scheme=nostr;package=com.vitorpamplona.amethyst;end`;", result.uri)
assertNull(result.entityType)
}
}
@@ -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)
}
}