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) + } +}