Merge pull request #3810 from dmnyc/feat/nip73-comment-previews

feat(nip73): show rich previews for external-content comments
This commit is contained in:
Vitor Pamplona
2026-07-29 16:17:30 -04:00
committed by GitHub
8 changed files with 183 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,
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
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.material3.IconButton
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
@@ -37,12 +41,14 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
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
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -53,6 +59,7 @@ fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
onUrlComments: (() -> Unit)? = null,
onCardClick: (() -> Unit)? = null,
) {
val uri = LocalUriHandler.current
val popupExpanded =
@@ -95,7 +102,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 +120,34 @@ 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,
)
// Only meaningful when the card's own tap does something else (e.g. opening the
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
if (onCardClick != null) {
IconButton(
onClick = { runCatching { uri.openUri(url) } },
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(R.string.url_preview_open_in_browser),
modifier = Size14Modifier,
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.staticCompositionLocalOf
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 = staticCompositionLocalOf<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,36 @@ 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
val chip =
@Composable {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = url,
linkInteractionListener = { nav.nav(Route.Url(url)) },
)
}
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
// third-party page, so when previews are off this stays a plain link chip.
if (!accountViewModel.settings.showUrlPreview()) {
chip()
return
}
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo, onCardClick = { nav.nav(Route.Url(url)) })
}
else -> {
chip()
}
}
}
@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)
}
@@ -133,6 +133,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
@@ -281,6 +282,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
@@ -331,6 +333,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
@@ -1133,6 +1136,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,28 @@ 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.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
@Composable
@@ -85,7 +94,7 @@ fun UrlScreen(
topBar = {
TopBarExtensibleWithBackButton(
title = {
DisplayUrlHeader(url, Modifier.weight(1f))
Text(stringRes(R.string.external_content_title))
},
popBack = nav::popBack,
)
@@ -97,12 +106,47 @@ 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,
) {
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
// third-party page, so when previews are off this stays the plain URL header.
if (!accountViewModel.settings.showUrlPreview()) {
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
return
}
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
}
}
}
+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>