From 4e6d8e106c5779b9bbb162f6fd12de34ffb7942a Mon Sep 17 00:00:00 2001 From: LubuSeb <187313664+LubuSeb@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:26:29 +0200 Subject: [PATCH] Add URL-scoped comment feeds --- .../RelaySubscriptionsCoordinator.kt | 3 + .../vitorpamplona/amethyst/ui/MainActivity.kt | 17 +++ .../amethyst/ui/components/LoadUrlPreview.kt | 18 ++- .../amethyst/ui/components/RichTextViewer.kt | 2 +- .../amethyst/ui/components/UrlPreviewCard.kt | 10 ++ .../markdown/MarkdownMediaRenderer.kt | 2 +- .../amethyst/ui/navigation/AppNavigation.kt | 16 +++ .../ui/navigation/navs/BouncingIntentNav.kt | 5 + .../amethyst/ui/navigation/routes/Routes.kt | 14 ++ .../ui/note/creators/previews/PreviewUrl.kt | 16 ++- .../note/nip22Comments/DisplayExternalId.kt | 12 +- .../screen/loggedIn/url/NewUrlNoteButton.kt | 59 +++++++++ .../ui/screen/loggedIn/url/UrlPostScreen.kt | 84 ++++++++++++ .../ui/screen/loggedIn/url/UrlScreen.kt | 122 ++++++++++++++++++ .../screen/loggedIn/url/dal/UrlFeedFilter.kt | 67 ++++++++++ .../loggedIn/url/dal/UrlFeedViewModel.kt | 48 +++++++ .../url/datasource/FilterPostsByUrl.kt | 51 ++++++++ .../datasource/UrlFeedFilterSubAssembler.kt | 38 ++++++ .../url/datasource/UrlFilterAssembler.kt | 50 +++++++ .../UrlFilterAssemblerSubscription.kt | 41 ++++++ .../vitorpamplona/amethyst/URIParserTest.kt | 43 ++++++ .../url/datasource/FilterPostsByUrlTest.kt | 50 +++++++ .../quartz/nip73ExternalIds/urls/UrlId.kt | 5 + 23 files changed, 758 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFeedFilterSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrlTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9a7c1b5342..413bc8c52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssembler @@ -114,6 +115,7 @@ class RelaySubscriptionsCoordinator( val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) + val urls = UrlFilterAssembler(client) val relayFeed = RelayFeedFilterAssembler(client) val relayInfoNip66 = RelayInfoNip66FilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) @@ -205,6 +207,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + urls, relayFeed, relayInfoNip66, chess, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 5eac5e817f..8eb9fd5c23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -51,8 +51,10 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.UriParser +import java.net.URLDecoder import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -147,6 +149,18 @@ fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=") +fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=") + +fun urlRoute(uri: String): Route.Url? { + val url = + runCatching { + val rawUrl = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("id") ?: return null + URLDecoder.decode(rawUrl, Charsets.UTF_8.name()) + }.getOrNull() ?: return null + + return UrlId.toScopeOrNull(url)?.let { Route.Url(it) } +} + fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=") fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") @@ -164,6 +178,9 @@ fun uriToRoute( if (isHashtagRoute(uri)) { return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase()) } + if (isUrlRoute(uri)) { + return urlRoute(uri) + } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { 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 0a26b2fc92..3dbdab70de 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 @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding @@ -39,11 +41,12 @@ fun LoadUrlPreview( urlText: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { if (!accountViewModel.settings.showUrlPreview()) { ClickableUrl(urlText, url) } else { - LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel) + LoadUrlPreviewDirect(url, urlText, callbackUri, accountViewModel, nav) } } @@ -53,6 +56,7 @@ fun LoadUrlPreviewDirect( urlText: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -72,7 +76,7 @@ fun LoadUrlPreviewDirect( ) { state -> when (state) { is UrlPreviewState.Loaded -> { - RenderLoaded(state, url, callbackUri, accountViewModel) + RenderLoaded(state, url, callbackUri, accountViewModel, nav) } is UrlPreviewState.Loading -> { @@ -94,6 +98,7 @@ fun RenderLoaded( url: String, callbackUri: String? = null, accountViewModel: AccountViewModel, + nav: INav? = null, ) { when { state.previewInfo.mimeType.startsWith("image") -> { @@ -130,7 +135,14 @@ fun RenderLoaded( } else -> { - UrlPreviewCard(url, state.previewInfo) + UrlPreviewCard( + url, + state.previewInfo, + onUrlComments = + nav?.let { + { it.nav(Route.Url(url)) } + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index e14010212f..1fc0747349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -549,7 +549,7 @@ private fun RenderWordWithPreview( is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) - is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) + is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel, nav) is NowhereLinkSegment -> NowhereLinkCard(word) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) 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 e55429cf5b..cf0815b798 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 @@ -52,6 +52,7 @@ import kotlinx.coroutines.launch fun UrlPreviewCard( url: String, previewInfo: UrlInfoItem, + onUrlComments: (() -> Unit)? = null, ) { val uri = LocalUriHandler.current val popupExpanded = @@ -76,6 +77,15 @@ fun UrlPreviewCard( popupExpanded.value = false } } + onUrlComments?.let { + M3ActionRow( + icon = MaterialSymbols.Link, + text = stringRes(R.string.kind_comments), + ) { + popupExpanded.value = false + it() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index 87b751525e..4ff978ce84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -132,7 +132,7 @@ class MarkdownMediaRenderer( renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder) } else { renderInlineFullWidth(richTextStringBuilder) { - LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel) + LoadUrlPreview(uri, title ?: uri, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index da1f4590e3..9d04de4be4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -199,6 +199,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlPostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.UrlScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen @@ -426,6 +428,7 @@ fun BuildNavigation( composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { UrlScreen(it, accountViewModel, nav) } composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } @@ -536,6 +539,19 @@ fun BuildNavigation( ) } + composableFromBottomArgs { + UrlPostScreen( + url = it.url, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + composableFromBottomArgs { ReplyCommentPostScreen( replyId = it.replyTo, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt index 33df344796..1e943a0780 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.utils.Log +import java.net.URLEncoder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -144,6 +145,10 @@ class BouncingIntentNav( NOSTR_URI_PREFIX + "hashtag?id=" + route.hashtag } + is Route.Url -> { + NOSTR_URI_PREFIX + "url?id=" + URLEncoder.encode(route.url, Charsets.UTF_8.name()) + } + is Route.LiveActivityChannel -> { NOSTR_URI_PREFIX + NAddress.create(route.kind, route.pubKeyHex, route.dTag, null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 58d41472cd..2afc44e90b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -459,6 +459,10 @@ sealed class Route { val geohash: String, ) : Route() + @Serializable data class Url( + val url: String, + ) : Route() + @Serializable data class ChessGame( val gameId: String, ) : Route() @@ -673,6 +677,16 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class UrlPost( + val url: String? = null, + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null, + val quote: String? = null, + val draft: String? = null, + ) : Route() + @Serializable data class GenericCommentPost( val message: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index 918011fbcf..d1b3da35d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -84,7 +84,7 @@ fun PreviewUrl( } else -> { - MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirect(myUrlPreview, myUrlPreview, accountViewModel, nav) } } } @@ -104,7 +104,7 @@ fun PreviewUrl( } RichTextParser.isUrlWithoutScheme(myUrlPreview) -> { - MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirect("https://$myUrlPreview", myUrlPreview, accountViewModel, nav) } } } @@ -138,7 +138,7 @@ fun PreviewUrlFillWidth( } else -> { - MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirectFillWidth(myUrlPreview, myUrlPreview, accountViewModel, nav) } } } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { @@ -154,7 +154,7 @@ fun PreviewUrlFillWidth( nav = nav, ) } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { - MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel) + MyLoadUrlPreviewDirectFillWidth("https://$myUrlPreview", myUrlPreview, accountViewModel, nav) } } @@ -200,6 +200,7 @@ private fun MyLoadUrlPreviewDirect( url: String, urlText: String, accountViewModel: AccountViewModel, + nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -268,6 +269,7 @@ private fun MyLoadUrlPreviewDirectFillWidth( url: String, urlText: String, accountViewModel: AccountViewModel, + nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") val urlPreviewState by @@ -304,7 +306,11 @@ private fun MyLoadUrlPreviewDirectFillWidth( accountViewModel = accountViewModel, ) } else { - UrlPreviewCard(url, previewInfo = state.previewInfo) + UrlPreviewCard( + url, + previewInfo = state.previewInfo, + onUrlComments = { nav.nav(com.vitorpamplona.amethyst.ui.navigation.routes.Route.Url(url)) }, + ) } } 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 86565a8f56..90c3aad5d5 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 @@ -30,7 +30,6 @@ 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 @@ -43,6 +42,7 @@ 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.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @@ -68,7 +68,7 @@ fun DisplayExternalId( } is UrlId -> { - DisplayUrlExternalId(externalId) + DisplayUrlExternalId(externalId, nav) } else -> { @@ -78,13 +78,15 @@ fun DisplayExternalId( } @Composable -fun DisplayUrlExternalId(externalId: UrlId) { - val uriHandler = LocalUriHandler.current +fun DisplayUrlExternalId( + externalId: UrlId, + nav: INav, +) { DisplayExternalIdChip( symbol = MaterialSymbols.Link, contentDescription = stringRes(id = R.string.external_url_scope), label = externalId.url, - linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } }, + linkInteractionListener = { nav.nav(Route.Url(externalId.url)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt new file mode 100644 index 0000000000..cecf7e0950 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/NewUrlNoteButton.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewUrlPostButton( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.UrlPost(url)) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterRes(R.drawable.ic_compose, 1), + contentDescription = stringRes(id = R.string.new_community_note), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt new file mode 100644 index 0000000000..ac27428364 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlPostScreen.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel +import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun UrlPostScreen( + url: String? = null, + message: String? = null, + attachment: String? = null, + replyId: HexKey? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val postViewModel: CommentPostViewModel = viewModel() + postViewModel.init(accountViewModel) + + val context = LocalContext.current + + LaunchedEffect(postViewModel, accountViewModel) { + url?.let { + UrlId.toScopeOrNull(it)?.let { normalizedUrl -> + postViewModel.newPostFor(UrlId(normalizedUrl)) + } + } + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { + postViewModel.reply(it) + } + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { + postViewModel.editFromDraft(it) + } + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { + postViewModel.quote(it) + } + message?.ifBlank { null }?.let { + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() + } + attachment?.ifBlank { null }?.toUri()?.let { + withContext(Dispatchers.IO) { + val mediaType = context.contentResolver.getType(it) + postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) + } + } + } + + GenericCommentPostScreen(postViewModel, accountViewModel, nav) +} 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 new file mode 100644 index 0000000000..55e103051d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/UrlScreen.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +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.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.quartz.nip73ExternalIds.urls.UrlId + +@Composable +fun UrlScreen( + route: Route.Url, + accountViewModel: AccountViewModel, + nav: INav, +) { + val url = UrlId.toScopeOrNull(route.url) ?: return + + PrepareViewModelsUrlScreen(url, accountViewModel, nav) +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun PrepareViewModelsUrlScreen( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val urlFeedViewModel: UrlFeedViewModel = + viewModel( + key = url + "UrlFeedViewModel", + factory = + UrlFeedViewModel.Factory( + url, + accountViewModel.account.followOutboxesOrProxy.flow.value, + accountViewModel.account, + ), + ) + + UrlScreen(url, urlFeedViewModel, accountViewModel, nav) +} + +@Composable +fun UrlScreen( + url: String, + feedViewModel: UrlFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + UrlFilterAssemblerSubscription(url, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarExtensibleWithBackButton( + title = { + DisplayUrlHeader(url, Modifier.weight(1f)) + }, + popBack = nav::popBack, + ) + }, + floatingButton = { + FabBottomBarPadded(nav) { + NewUrlPostButton(url, accountViewModel, nav) + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun DisplayUrlHeader( + url: String, + modifier: Modifier, +) { + Text( + url, + fontWeight = FontWeight.Bold, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt new file mode 100644 index 0000000000..5e9755de45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedFilter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId + +class UrlFeedFilter( + url: String, + val relays: Set, + val account: Account, + val cache: LocalCache, +) : AdditiveFeedFilter() { + val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty() + + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + normalizedUrl + + override fun feed(): List { + val notes = + cache.notes.filterIntoSet { _, it -> + acceptableEvent(it, normalizedUrl) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it, normalizedUrl) } + + fun acceptableEvent( + it: Note, + url: String, + ): Boolean = + acceptableViaScope(it.event, url) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && + account.isAcceptable(it) + + fun acceptableViaScope( + event: com.vitorpamplona.quartz.nip01Core.core.Event?, + url: String, + ): Boolean = UrlId.toScopeOrNull(url)?.let { event is CommentEvent && event.isTaggedScope(it, UrlId::match) } ?: false + + override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt new file mode 100644 index 0000000000..8f81ba57d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/dal/UrlFeedViewModel.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class UrlFeedViewModel( + val url: String, + val relays: Set, + val account: Account, +) : AndroidFeedViewModel( + UrlFeedFilter(url, relays, account, LocalCache), + ) { + @Suppress("UNCHECKED_CAST") + class Factory( + val url: String, + val relays: Set, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UrlFeedViewModel(url, relays, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt new file mode 100644 index 0000000000..6773c35810 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId + +fun filterPostsByUrl( + url: String, + relays: Set, + since: SincePerRelayMap?, +): List { + val normalizedUrl = UrlId.toScopeOrNull(url) ?: return emptyList() + val urlScopeMap = mapOf("I" to listOf(normalizedUrl)) + + return relays.map { relay -> + val since = since?.get(relay)?.time + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = urlScopeMap, + kinds = CommentKinds, + limit = 100, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..fae8a64935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFeedFilterSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class UrlFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: UrlQueryState, + since: SincePerRelayMap?, + ): List = filterPostsByUrl(key.normalizedUrl, key.relays, since) + + override fun id(key: UrlQueryState) = key.normalizedUrl +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssembler.kt new file mode 100644 index 0000000000..aecde52c29 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId + +class UrlQueryState( + val url: String, + val relays: Set, +) { + val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty() +} + +@Stable +class UrlFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + UrlFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..8817f71eb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/UrlFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun UrlFilterAssemblerSubscription( + url: String, + accountViewModel: AccountViewModel, +) { + val state = + remember(url) { + UrlQueryState(url, accountViewModel.account.followOutboxesOrProxy.flow.value) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().urls) +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt new file mode 100644 index 0000000000..3a7efcf7e9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.urlRoute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.net.URLEncoder + +class URIParserTest { + @Test + fun parsesEncodedUrlRoutes() { + val url = "HTTPS://Example.com/path?b=2&a=1#section" + val route = urlRoute("nostr:url?id=${URLEncoder.encode(url, Charsets.UTF_8.name())}") + + assertEquals(Route.Url("https://example.com/path?b=2&a=1"), route) + } + + @Test + fun ignoresMalformedEncodedUrlRoutes() { + assertNull(urlRoute("nostr:url?id=%")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrlTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrlTest.kt new file mode 100644 index 0000000000..3829561737 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrlTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterPostsByUrlTest { + @Test + fun normalizesUrlAndQueriesNip22UrlScope() { + val relay = NormalizedRelayUrl("wss://relay.example/") + val filters = filterPostsByUrl("HTTPS://Example.com/path?b=2&a=1#section", setOf(relay), null) + + assertEquals(1, filters.size) + assertEquals(relay, filters[0].relay) + assertEquals(CommentKinds, filters[0].filter.kinds) + assertEquals(mapOf("I" to listOf("https://example.com/path?b=2&a=1")), filters[0].filter.tags) + assertEquals(100, filters[0].filter.limit) + } + + @Test + fun returnsNoFiltersForMalformedUrls() { + val relay = NormalizedRelayUrl("wss://relay.example/") + + val filters = filterPostsByUrl("https://", setOf(relay), null) + + assertTrue(filters.isEmpty()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt index 42ac89e2d8..bc2552f466 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt @@ -43,6 +43,11 @@ class UrlId( fun toScope(url: String) = Rfc3986.normalizeAndRemoveFragment(url) + fun toScopeOrNull(url: String) = + runCatching { toScope(url) } + .getOrNull() + ?.takeIf { parse(it) != null } + fun toKind(url: String) = KIND fun match(