Merge pull request #2891 from vitorpamplona/claude/comment-reply-context-XMBOV

Display external identifier scopes in NIP-22 comments
This commit is contained in:
Vitor Pamplona
2026-05-14 11:46:19 -04:00
committed by GitHub
5 changed files with 117 additions and 3 deletions
@@ -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,
)
}
}
@@ -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)
}
}
}
+3
View File
@@ -1475,6 +1475,9 @@
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
<string name="hashtag_exclusive_explainer">Only followers of the hashtag will see it. Your general followers won\'t see it.</string>
<string name="external_url_scope">Comment on a website</string>
<string name="external_id_scope">Comment on an external resource</string>
<string name="long_form_reading_minutes">%1$d min read</string>
@@ -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 }
@@ -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(it) }
fun hasRootScopeKind(kind: String) = tags.any(RootKindTag::isKind, kind)
fun hasReplyScopeKind(kind: String) = tags.any(ReplyKindTag::isKind, kind)