diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index de8722cd42..8bda095d45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index cf0815b798..3f26837655 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index f32c7eafeb..786ece9fba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -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 }, 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 90c3aad5d5..deca589468 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 @@ -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 { 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 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 2ec8bc7109..b9954841e1 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 @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 735e3a9175..6856ea3045 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt index ef8ba1d120..d323345e19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt @@ -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) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6228800866..53b93fb4a1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2426,8 +2426,10 @@ Hashtag-exclusive Post + External Content Comment on a website Comment on an external resource + Open in browser %1$d min read