mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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) })
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
@@ -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])
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user