From a690777ef63d5dc1bdecf1ab7629c98f83be5736 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:05:04 +0000 Subject: [PATCH 1/2] feat: show external-id scope as reply context for NIP-22 comments A Comment event (kind 1111) scoped to an external identifier (`I` tag) previously rendered as a bare text post and landed in the Home "New Threads" feed. Treat it as a reply to that external scope: it now shows in the conversations feed and renders a typed chip (hashtag, geohash, url, or generic) above the comment text. https://claude.ai/code/session_01ArHkNXu1ANrVGZAyMWg4Xu --- .../note/nip22Comments/DisplayExternalId.kt | 94 ++++++++++++++++++- .../amethyst/ui/note/types/Text.kt | 11 +++ amethyst/src/main/res/values/strings.xml | 3 + .../amethyst/commons/model/Note.kt | 9 +- .../quartz/nip22Comments/CommentEvent.kt | 3 + 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index 8e72db0c1..86565a8f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -20,12 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId @Composable fun DisplayExternalId( @@ -42,6 +67,73 @@ fun DisplayExternalId( DisplayHashtagExternalId(externalId, accountViewModel, nav) } - else -> {} + is UrlId -> { + DisplayUrlExternalId(externalId) + } + + else -> { + DisplayGenericExternalId(externalId) + } + } +} + +@Composable +fun DisplayUrlExternalId(externalId: UrlId) { + val uriHandler = LocalUriHandler.current + DisplayExternalIdChip( + symbol = MaterialSymbols.Link, + contentDescription = stringRes(id = R.string.external_url_scope), + label = externalId.url, + linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } }, + ) +} + +@Composable +fun DisplayGenericExternalId(externalId: ExternalId) { + DisplayExternalIdChip( + symbol = MaterialSymbols.Public, + contentDescription = stringRes(id = R.string.external_id_scope), + label = externalId.toScope(), + linkInteractionListener = null, + ) +} + +@Composable +private fun DisplayExternalIdChip( + symbol: MaterialSymbol, + contentDescription: String, + label: String, + linkInteractionListener: LinkInteractionListener?, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + if (linkInteractionListener != null) { + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("externalId", null, linkInteractionListener), + ) { + append(label) + } + } + } else { + buildAnnotatedString { append(label) } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 9c15c1513..ce5eb75ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -54,7 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip73ExternalIds.scope enum class ReplyRenderType { FULL, @@ -119,6 +122,14 @@ fun RenderTextEvent( Spacer(modifier = HalfVertSpacer) } } + } else if (!makeItShort && noteEvent is CommentEvent) { + // A comment scoped to an external identifier (`I` tag) has no in-cache parent + // note. Show the scope itself as the reply context. + val scope = remember(note) { noteEvent.scope() } + if (scope != null) { + DisplayExternalId(scope, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e9fd03b1c..3f6fbf18c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1475,6 +1475,9 @@ Hashtag-exclusive Post Only followers of the hashtag will see it. Your general followers won\'t see it. + Comment on a website + Comment on an external resource + %1$d min read diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1a2790d08..9566969ca 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -773,15 +773,20 @@ open class Note( return author?.reportsOrNull()?.hasReportNewerThan(dayAgo) ?: false } - fun isNewThread(): Boolean = - ( + fun isNewThread(): Boolean { + val event = event + return ( event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0 ) && + // A comment scoped to an external identifier (`I` tag) is a reply to that + // scope, even though there is no in-cache parent note to populate replyTo. + !(event is CommentEvent && event.hasRootScopeIdentifier()) && event !is ChannelMessageEvent && event !is LiveActivitiesChatMessageEvent + } fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 17b51a2ab..16327de44 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -181,6 +181,9 @@ class CommentEvent( fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) } + /** True when the comment points at an external identifier (`I` tag), e.g. a hashtag, geohash or url. */ + fun hasRootScopeIdentifier() = tags.any(RootIdentifierTag::match) + fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind) fun hasReplyScopeKind(kind: String) = tags.any(ReplyKindTag::isKind, kind) From b7ead4114565a60f8be1b310428872ae224b082d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 15:28:53 +0000 Subject: [PATCH 2/2] fix: use lambda for generic RootIdentifierTag.match call RootIdentifierTag is generic, so the `::match` method reference failed JVM compilation. Call it through a lambda like the rest of CommentEvent. https://claude.ai/code/session_01ArHkNXu1ANrVGZAyMWg4Xu --- .../com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt index 16327de44..8bd085449 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip22Comments/CommentEvent.kt @@ -182,7 +182,7 @@ class CommentEvent( fun isScoped(scopeTest: (String) -> Boolean) = tags.any { RootIdentifierTag.isTagged(it, scopeTest) || ReplyIdentifierTag.isTagged(it, scopeTest) } /** True when the comment points at an external identifier (`I` tag), e.g. a hashtag, geohash or url. */ - fun hasRootScopeIdentifier() = tags.any(RootIdentifierTag::match) + fun hasRootScopeIdentifier() = tags.any { RootIdentifierTag.match(it) } fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind)