feat(nip73): show rich previews for external-content comments

Kind 1111 comments scoped to a NIP-73 external identifier (e.g. a web
article) rendered as a bare URL with no context. Reuse the existing
OpenGraph preview pipeline so feed rows, expanded threads, the
reply-compose banner, and the URL thread screen all show a rich
image/title/description card instead, and give the URL thread screen
a proper scrolling header instead of a bare title.
This commit is contained in:
The Daniel
2026-07-29 11:50:17 -04:00
parent 8a183d1cbd
commit ac426531f3
8 changed files with 161 additions and 29 deletions
@@ -52,13 +52,10 @@ fun LoadUrlPreview(
}
@Composable
fun LoadUrlPreviewDirect(
fun rememberUrlPreviewState(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
): UrlPreviewState {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
produceState(
@@ -69,6 +66,18 @@ fun LoadUrlPreviewDirect(
accountViewModel.urlPreview(url) { value = it }
}
}
return urlPreviewState
}
@Composable
fun LoadUrlPreviewDirect(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
val urlPreviewState = rememberUrlPreviewState(url, accountViewModel)
CrossfadeIfEnabled(
targetState = urlPreviewState,
@@ -21,22 +21,29 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.util.setText
@@ -53,6 +60,7 @@ fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
onUrlComments: (() -> Unit)? = null,
onCardClick: (() -> Unit)? = null,
) {
val uri = LocalUriHandler.current
val popupExpanded =
@@ -95,7 +103,11 @@ fun UrlPreviewCard(
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
runCatching { uri.openUri(url) }
if (onCardClick != null) {
onCardClick()
} else {
runCatching { uri.openUri(url) }
}
},
onLongClick = {
popupExpanded.value = true
@@ -109,14 +121,31 @@ fun UrlPreviewCard(
modifier = previewCardImageModifier,
)
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
Row(
modifier = MaxWidthWithHorzPadding,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f, fill = false),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.size(4.dp))
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(R.string.url_preview_open_in_browser),
modifier =
Modifier
.size(14.dp)
.clickable { runCatching { uri.openUri(url) } },
tint = Color.Gray,
)
}
Text(
text = previewInfo.title,
@@ -47,6 +47,7 @@ fun FeedLoaded(
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: INav,
header: (@Composable () -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -54,6 +55,12 @@ fun FeedLoaded(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (header != null) {
item {
header()
}
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
@@ -28,6 +28,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
@@ -41,6 +42,9 @@ 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.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -52,6 +56,14 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
/**
* The normalized scope (e.g. [ExternalId.toScope]) of the external content a screen is
* currently dedicated to, if any. A screen built entirely around one external scope (e.g.
* the URL thread screen) provides this so nested comments sharing that same scope don't
* redundantly repeat the preview the screen itself already shows.
*/
val LocalCurrentExternalScope = compositionLocalOf<String?> { null }
@Composable
fun DisplayExternalId(
externalId: ExternalId,
@@ -68,7 +80,7 @@ fun DisplayExternalId(
}
is UrlId -> {
DisplayUrlExternalId(externalId, nav)
DisplayUrlExternalId(externalId, accountViewModel, nav)
}
else -> {
@@ -80,14 +92,24 @@ fun DisplayExternalId(
@Composable
fun DisplayUrlExternalId(
externalId: UrlId,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = externalId.url,
linkInteractionListener = { nav.nav(Route.Url(externalId.url)) },
)
val url = externalId.url
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo, onCardClick = { nav.nav(Route.Url(url)) })
}
else -> {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = url,
linkInteractionListener = { nav.nav(Route.Url(url)) },
)
}
}
}
@Composable
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
@@ -142,9 +143,11 @@ fun RenderTextEvent(
}
} 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.
// note. Show the scope itself as the reply context -- unless the screen we're
// in is already dedicated to this exact scope (e.g. the URL thread screen),
// in which case every row would otherwise redundantly repeat the same preview.
val scope = remember(note) { noteEvent.scope() }
if (scope != null) {
if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) {
DisplayExternalId(scope, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
@@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
@@ -280,6 +281,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -330,6 +332,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
@@ -1116,6 +1119,27 @@ private fun FullBleedNoteCompose(
accountViewModel,
nav,
)
} else if (noteEvent is CommentEvent) {
// A comment scoped to external content (NIP-73 `I` tag, e.g. a URL) has no
// in-cache parent note, so it surfaces here as the thread's own "master" note.
// Show its external scope for context, same as the reply-context branch in
// RenderTextEvent does for non-root occurrences of the same comment.
val scope = remember(baseNote) { noteEvent.scope() }
if (scope != null) {
DisplayExternalId(scope, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
RenderTextEvent(
baseNote,
false,
canPreview,
quotesLeft = 3,
unPackReply = ReplyRenderType.NONE,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else {
RenderTextEvent(
baseNote,
@@ -23,19 +23,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url
import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
@Composable
@@ -85,7 +93,7 @@ fun UrlScreen(
topBar = {
TopBarExtensibleWithBackButton(
title = {
DisplayUrlHeader(url, Modifier.weight(1f))
Text(stringRes(R.string.external_content_title))
},
popBack = nav::popBack,
)
@@ -97,12 +105,40 @@ fun UrlScreen(
},
accountViewModel = accountViewModel,
) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
CompositionLocalProvider(LocalCurrentExternalScope provides url) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { state, listState ->
FeedLoaded(
state,
listState,
null,
accountViewModel,
nav,
header = { UrlScreenPreview(url, accountViewModel) },
)
},
)
}
}
}
@Composable
fun UrlScreenPreview(
url: String,
accountViewModel: AccountViewModel,
) {
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
DisplayUrlHeader(url, Modifier)
}
}
}
+2
View File
@@ -2426,8 +2426,10 @@
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
<string name="external_content_title">External Content</string>
<string name="external_url_scope">Comment on a website</string>
<string name="external_id_scope">Comment on an external resource</string>
<string name="url_preview_open_in_browser">Open in browser</string>
<string name="long_form_reading_minutes">%1$d min read</string>