mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Merge pull request #3810 from dmnyc/feat/nip73-comment-previews
feat(nip73): show rich previews for external-content comments
This commit is contained in:
@@ -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 },
|
||||
|
||||
+41
-7
@@ -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)
|
||||
}
|
||||
|
||||
+24
@@ -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,
|
||||
|
||||
+51
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user