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 01/10] 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( From 443bfba65c213172892020341ec2f8592705d4da Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:31:29 +0000 Subject: [PATCH 02/10] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 158 ++++++++++++++++++ .../src/main/res/values-sl-rSI/strings.xml | 93 ++++++++++- docs/changelog/translators.json | 20 +-- 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index f69330f42e..f36c2c7505 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -276,6 +276,7 @@ Odblokuj Kopiuj ID użytkownika Odblokuj użytkownika + Ten użytkownik jest zablokowany/uciszony. Ich posty są ukryte. "npub, nazwa użytkownika, tekst" Wyczyść Logo aplikacji @@ -588,6 +589,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Polecane aplikacje Wybierz aplikacje Nostr, które chcesz publicznie polecić. Twoje rekomendacje pojawią się na Twoim profilu i pomogą innym użytkownikom znaleźć aplikacje z treściami, których ten klient nie może otworzyć. Nie znaleziono jeszcze żadnych aplikacji. Aplikacje będą się tu pojawiać w miarę ich wykrywania na Twoich transmiterach. + Szukaj aplikacji po nazwie + Brak aplikacji spełniających kryteria wyszukiwania. + Żadna rekomendowana aplikacja nie pasuje do tego filtra. Aplikacja bez nazwy Nie podaje, jakie treści obsługuje Poleć @@ -616,6 +620,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Zestawy Powtórzenia Waga + Ćwiczenia + Objętość Uwagi Godziny Minuty @@ -636,10 +642,82 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Medytacja Dieta Post + Układ + EMOM + AMRAP Aplikacje + Aplikacje + nApplets + nSites + Nie znaleziono żadnych witryn nSite. + Przeglądarka + Szukaj lub wprowadź adres + Odśwież + Wczytywanie przez Tor. Kliknij, aby użyć otwartej sieci. + Pobieranie przez otwartą sieć. Kliknij, aby skorzystać z sieci Tor. + Przeglądarka w aplikacji wymaga Androida 11 lub nowszego. + Nie można załadować tej aplikacji. + Otwórz + Wyczyść + Ulubione + Opcje + Usuń z historii + Ulubione aplikacje + Brak ulubionych aplikacji. Otwórz klienta lub witrynę internetową i kliknij gwiazdkę, aby przypiąć ją tutaj. + Dodaj do ulubionych + Usuń z ulubionych + Otwórz w osobnym oknie + Do czego ta aplikacja ma dostęp + Działa w środowisku izolowanym i nie ma żadnego specjalnego dostępu do Twojego konta. + Do czego ma dostęp + Przesyłanie danych przez Tor. + Wczytuje się z otwartej sieci. + Ta aplikacja nie została jeszcze załadowana. Otwórz ją z karty lub spróbuj ponownie za chwilę. + Opublikowane do transmiterów + Wgrano plik + Dokonano płatności + Ta aplikacja nie została jeszcze załadowana. Spróbuj ponownie za chwilę. + Najnowsze + uprawnienia nApplet + Zarządzaj uprawnieniami + Brak uprawnień nApplet + Uprawnienia, które przyznasz dla nApplets pojawią się tutaj. + Zapomnij o tym nApplet + Zablokowane + Odwołaj + nApplet bez tytułu + Nie znaleziono jeszcze żadnych nApplets. + nApplet %1$s… + Powłoka + Tożsamość + Operacje klawiatury + Transmitery + Schowek + Płatności + Sieć + Wgrane + Zapytaj jakie opcje są dostępne + Odczytaj swój klucz publiczny + Przypisz skróty klawiszowe + Przeczytaj, podpisz i opublikuj swoje wydarzenia + Własny prywatny schowek + Opłać faktury Lightning + Pobierz zasoby sieci web i blossom + Prześlij pliki na serwer multimediów + Motyw + Powiadomienia + Wiadomości + Dopasuj kolory aplikacji + Wyświetl powiadomienia + Wymieniaj wiadomości z innymi nappletami + Możliwość: %1$s + Zawsze zezwalaj + Zezwól jednorazowo + Nigdy nie zezwalaj + Nie teraz Źródło: %1$s v%1$s Pobierz @@ -1203,6 +1281,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. Podział powiadomień według obserwowanych Wyświetl dwie zakładki powiadomień — „Obserwowani” (osoby, które obserwujesz) oraz „Wszyscy”. Wskaźnik nieprzeczytanych powiadomień świeci się tylko w przypadku aktywności osób, które obserwujesz. + Pokaż wiadomości Obserwowani Wszyscy Optymalizacja baterii aktywna @@ -2223,9 +2302,13 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Tematy Osobisty fork Statyczna Witryna: %1$s + nApplet: %1$s + Uprawnienia: + Aplikacje & Witryny Strona główna Źródło: Serwery: + Otwórz OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. @@ -3015,6 +3098,81 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Sztuka na „Nowhere” Forum „Nowhere” + Grupy Marmot + Utwórz Grupę + Utwórz Grupę Marmot + Zostanie utworzona nowa grupa MLS. Następnie możesz dodać członków. + Grupy Marmot + Grupa %1$s… + %1$s… + Znane + Znane (%1$d) + Brak grup + Utwórz grupę lub zaakceptuj zaproszenie. + Brak zaproszeń + Gdy ktoś doda cię do grupy, będzie to widoczne tutaj, dopóki nie odpowiesz. + Brak wiadomości + + %1$d wiadomość + %1$d wiadomości + %1$d wiadomości + %1$d wiadomości + + + %1$d członek + %1$d członków + %1$d członków + %1$d członków + + Nazwa grupy + Wpisz nazwę grupy + Dodaj opis grupy (opcjonalnie) + Zmiany zostaną zatwierdzone w grupie za pośrednictwem MLS i przekazane wszystkim członkom. + Informacje o grupie zaktualizowane + Nie udało się zaktualizować: %1$s + Nie udało się utworzyć grupy: %1$s + Nie ustawiono transmiterów KeyPackage + Nie masz jeszcze listy transmiterów KeyPackage (MIP-00). Lista ta informuje innych użytkowników, gdzie opublikowano Twój KeyPackage, dzięki czemu mogą zapraszać Cię do czatów grupowych.\n\nUżyj do tego swoich obecnych transmiterów ze skrzynki wysyłkowej? + Użyj transmiterów wychodzących + Na razie pomiń + Informacje o grupie + Edytuj informacje o grupie + Opuść grupę + ta grupa + Dodawanie %1$s… + Nie udało się dodać %1$s: %2$s + nieznany błąd + Aby można było dodać dany pakiet, musi on zostać opublikowany jako KeyPackage (kind:30443). + Dodaj do grupy + Dodaj członka + Dodaj Członka + Nazwa, npub lub NIP-05 + (Ty) + - administrator + Członek usunięty + Nie udało się usunąć członka: %1$s + Usuń Członka + Czy na pewno chcesz usunąć \"%1$s\" z tej grupy? + Przyznaj + Przyznaj uprawnienia admina + Przyznaj uprawnienia administratora + Zrób \"%1$s\" administratorem tej grupy? Administratorzy mogą dodawać lub usuwać członków, zmieniać informacje o grupie i przyznawać uprawnienia administratora innym członkom. + Przyznane uprawnienia administracyjne + Nie udało się przyznać uprawnień admina: %1$s + Wycofaj + Cofnij uprawnienia administratora + Cofnij uprawnienia administratora + Odbierz uprawnienia administratora od „%1$s”? Użytkownik ten pozostanie członkiem grupy. + Uprawnienia administratora cofnięte + Nie udało się cofnąć upragnień admina: %1$s + Transmitery + brak wydarzeń + ostatnie wydarzenie%1$s + Nie jest członkiem tej grupy + Kopiuj + Zresetuj do automatycznego wyboru + Zapłać fakturę z DVM + Zapłać %1$s satoszy na DVM Usługa lightning odbiorcy w %1$s jest niedostępna. Została obliczona na podstawie adresu lightning \"%2$s\". Błąd: %3$s. Sprawdź, czy serwer jest gotowy i czy adres lightning jest poprawny Nie można rozwiązać %1$s. Sprawdź, czy jesteś połączony, czy serwer jest gotowy i czy lightning adres %2$s jest poprawny.\n\nWyjątkiem było: %3$s Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 14e838f341..7150d5df5e 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -69,7 +69,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila zapnil %1$s satov Anonimno izvaja vdor - ustvarjen izsek + izsek ustvarjen Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavite se s privatnim ključem, da boste lahko pošiljali Zape Uporabljaš javni ključ in javni ključi omogočajo le branje. @@ -658,9 +658,96 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem EMOM AMRAP Aplikacije + Applikacije + nApplets + nSites + nSites še niso najdena. + Brskalnik + Išči ali vpiši naslov + Ponovno naloži + Nalaganje prek Tor. Tapnite za uporabo odprtega spleta. + Nalaganje prek odprtega spleta. Tapnite za uporabo Tor. + Vgrajeni brskalnik zahteva Android 11 ali novejši. + Te aplikacije ni bilo mogoče naložiti. + Odpri + Počisti + Priljubljene + Možnosti + Odstrani iz zgodovine + Priljubljene aplikacije + Še ni priljubljenih aplikacij. Odprite spletni odjemalec ali nsite in tapnite zvezdico za pripenjanje. + dodaj med priljubljene + Odstrani iz priljubljenih + Odpri v novem oknu + Do česa lahko ta aplikacija dostopa + Deluje v peskovniku brez posebnega dostopa do vašega računa. + Dostop do + Nalaganje prek Tor + Nalaganje prek odprtega spleta + Ta aplikacija še ni naložena. Odprite jo s kartice ali poskusite znova čez trenutek. + Objavljeno na relejih + Datoteka naložena + Plačilo izvedeno + Ta aplikacija še ni naložena. Poskusite znova kasneje. + Nedavno + nApplet dovoljenja + Upravljanje dovoljenj + Še ni dovoljenj za nApplet + Tukaj bodo prikazana dovoljenja, ki jih dodelite nAppletom. + Pozabi ta nApplet + Blokirano + Prekliči + Neimenovan nApplet + Še ni najdenih nobenih nAppletov. + nApplet%1$s… + Shell + Identiteta + Tipkovne bližnjice + Releji + Shramba + Plačila + Mreža + Prenosi + Ta nApplet želi preveriti, katere zmogljivosti so na voljo + Prebere vaš javni ključ + Poveži tipkovne bližnjice + Branje, podpisovanje in objavljanje dogodkov + Lastna zasebna shramba + Plačaj Lightning račune + Pridobi spletne in Blossom vire + Nalaganje datotek na vaš medijski strežnik + Tema + Obvestila + Sporočanje + Prilagoditev barv aplikaciji + Prikaz obvestil + Izmenjava sporočil z drugimi nAppleti + Zmogljivost: %1$s + Vedno dovoli + Dovoli enkrat + Nikoli ne dovoli + Ne zdaj + Ta nApplet želi prebrati vaš javni ključ. + Ta nApplet želi prebrati vaše podatke o profilu in računu. + Ta nApplet želi v vašem imenu podpisati in objaviti dogodek vrste %1$d. + Ta nApplet želi v vašem imenu podpisati in objaviti dogodek vrste %1$d: + Ta nApplet želi v vašem imenu poslati šifriran dogodek. + Ta nApplet želi prebrati dogodke z vaših relejev. + Ta nApplet želi uporabiti svojo zasebno shrambo. + Ta nApplet želi plačati Lightning račun. + Ta nApplet želi pridobiti spletni vir. + Ta nApplet želi naložiti datoteko na vaš medijski strežnik. + Ta nApplet vam želi prikazati obvestila. + Ta stran želi podpisati dogodek vrste (kind) %1$d, z vašim Nostr ključem. + + Ta nApplet želi plačati Lightning račun za %1$d sat. + Ta nApplet želi plačati Lightning račun za %1$d sata. + Ta nApplet želi plačati Lightning račun za %1$d sate. + Ta nApplet želi plačati Lightning račun za %1$d satov. + Vir: %1$s v%1$s Prenos @@ -2249,9 +2336,13 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Teme Osebni fork Statična spletna stran: %1$s + nApplet: %1$s + Dovoljenja: + Aplikacije in spletna mesta Izhodiščno spletišče Vir: Strežniki: + Odpri OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index b01c961551..e7b00dd85d 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -82,6 +82,12 @@ "Hindi" ] }, + { + "user": "maxblake2015", + "languages": [ + "Polish" + ] + }, { "user": "anthony-robin", "languages": [ @@ -89,15 +95,15 @@ ] }, { - "user": "summoner001", + "user": "StellarStoic", "languages": [ - "Hungarian" + "Slovenian" ] }, { - "user": "maxblake2015", + "user": "summoner001", "languages": [ - "Polish" + "Hungarian" ] }, { @@ -112,12 +118,6 @@ "Spanish" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "vitorpamplona", "languages": [ From fe392402c3e1893dc0416d7dec5a8554ee3408cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:03:29 +0000 Subject: [PATCH 03/10] fix: extract MintPickerItem to fix Kotlin compiler OOM in MintPicker The deeply-nested Text() call inside DropdownMenu > forEach > DropdownMenuItem generated bytecode with MAXSTACK=26, which caused java.lang.OutOfMemoryError in the JVM bytecode frame analyzer (ConstantConditionEliminationMethodTransformer). Extracting DropdownMenuItem into a top-level private composable breaks the lambda nesting depth and lets the compiler succeed. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt --- .../loggedIn/wallet/CashuWalletScreen.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index 0bd1784621..9c11e7369d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -1234,17 +1234,25 @@ private fun MintPicker( } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { mints.forEach { m -> - DropdownMenuItem( - text = { Text(m) }, - onClick = { - onPick(m) - expanded = false - }, - ) + MintPickerItem(m) { + onPick(m) + expanded = false + } } } } +@Composable +private fun MintPickerItem( + mint: String, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { Text(mint) }, + onClick = onClick, + ) +} + // ============================================================================ // Send LN (melt) // ============================================================================ From d33d1e36fda06ed1eedd806de36dfb24c73b503e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:16:06 +0000 Subject: [PATCH 04/10] feat: move favorite to pull-down sheet, remove from OmniBar Remove the star icon from the browser URL bar (OmniBar) and add a favorite toggle row to both pull-down sheet surfaces instead: - Compose TopControlSheet (embedded web tabs and napplets): shows filled/outline star with "Add to favorites" / "Remove from favorites" sourced from FavoriteAppsRegistry; isFavorite state flows reactively through EmbeddedTabChrome so the label updates without reopening. - Native NappletControlSheet (full-screen NappletBrowserActivity): same toggle backed by a new MSG_TOGGLE_WEB_FAVORITE IPC message handled in NappletBrokerService; initial state is passed via intent so the star opens in the correct filled/outline state for the launch URL. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SBpBE7bQJ2JRni6sYG8UDo --- .../amethyst/favorites/FavoriteAppLauncher.kt | 3 +- .../amethyst/napplet/NappletBrokerService.kt | 17 +++++++ .../screen/loggedIn/browser/BrowserScreen.kt | 10 ---- .../loggedIn/browser/FavoriteWebAppScreen.kt | 17 ++++++- .../loggedIn/embed/EmbeddedTabChrome.kt | 4 ++ .../screen/loggedIn/embed/TopControlSheet.kt | 9 ++++ .../favorites/FavoriteNappletScreen.kt | 18 ++++++- .../napplethost/NappletBrowserActivity.kt | 23 +++++++++ .../napplethost/NappletControlSheet.kt | 47 +++++++++++++++++++ .../amethyst/napplethost/NappletIpc.kt | 14 ++++++ nappletHost/src/main/res/values/strings.xml | 4 ++ 11 files changed, 152 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index 049759b440..703ab00da4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -77,8 +77,9 @@ object FavoriteAppLauncher { ) { val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url)) + val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") val intent = - NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply { + NappletBrowserActivity.intent(context, url, proxyPort, useTor, isFavorite = isFavorite).apply { if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index fd2b6a68f3..8f2dba934d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -32,6 +32,7 @@ import android.os.RemoteException import android.os.SystemClock import android.util.Log import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -175,6 +177,21 @@ class NappletBrokerService : Service() { return true } + // The direct-WebView browser requests a favorite toggle for the current URL (main process only). + if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true + val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } + FavoriteAppsRegistry.init(applicationContext) + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebUrl(url, label, System.currentTimeMillis())) + } + return true + } + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { val data = msg.data ?: return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index ba72b350ee..e4e9d24fa4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -195,12 +195,6 @@ private fun BrowserLauncher( onValueChange = ::onValueChange, onClear = { field = TextFieldValue("") }, onOpen = { open(field.text) }, - onFavorite = { - val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar - FavoriteAppsRegistry.add( - FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()), - ) - }, ) }, bottomBar = { @@ -264,7 +258,6 @@ private fun OmniBar( onValueChange: (TextFieldValue) -> Unit, onClear: () -> Unit, onOpen: () -> Unit, - onFavorite: () -> Unit, ) { Row( modifier = @@ -309,9 +302,6 @@ private fun OmniBar( ), ) if (field.text.isNotBlank()) { - IconButton(onClick = onFavorite) { - Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) - } IconButton(onClick = onOpen) { Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringResource(R.string.browser_go)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt index 52256d3870..4020781557 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -44,9 +44,12 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds @@ -104,6 +107,9 @@ private fun EmbeddedFavoriteTab( // can opt one out and it must stick). Only meaningful when Tor is actually available. var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) } + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebUrl && it.url == currentUrl } } + val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val controller = @@ -121,7 +127,7 @@ private fun EmbeddedFavoriteTab( // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, torOn, proxyAvailable) { + remember(currentUrl, torOn, proxyAvailable, isFavorite) { EmbeddedTabChrome( title = hostLabel(currentUrl), isSandbox = false, @@ -133,6 +139,15 @@ private fun EmbeddedFavoriteTab( controller.setTor(torOn) WebUrlNetworkRegistry.set(url, torOn) }, + isFavorite = isFavorite, + onFavorite = { + val favId = "url:$currentUrl" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebUrl(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + } + }, ) } // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt index 753dffb034..42ef553923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt @@ -37,4 +37,8 @@ data class EmbeddedTabChrome( val onToggleTor: () -> Unit = {}, /** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */ val onInfo: (() -> Unit)? = null, + /** Whether the current URL/app is already saved as a favorite. */ + val isFavorite: Boolean = false, + /** Toggles the current site/app in the favorites registry; null when not applicable. */ + val onFavorite: (() -> Unit)? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt index 48c48049d3..0ebe85233d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt @@ -131,6 +131,15 @@ fun TopControlSheet( onExpandedChange(false) chrome.onOpenFull() } + chrome.onFavorite?.let { toggleFavorite -> + SheetItem( + if (chrome.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, + stringResource(if (chrome.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add), + ) { + onExpandedChange(false) + toggleFavorite() + } + } onConsole?.let { showConsole -> SheetItem( MaterialSymbols.Code, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt index 0cf2d6156c..e93588b49e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt @@ -53,9 +53,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -125,6 +127,9 @@ private fun EmbeddedNappletTab( var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } + val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } + val controller = remember(id) { EmbeddedTabFactory.acquireNapplet(context, coordinate, params, backgroundColor) @@ -138,15 +143,24 @@ private fun EmbeddedNappletTab( } } - // Stable per app (title/coordinate don't change), so the tab layer isn't recomposed every frame. + // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate) { + remember(title, coordinate, isFavorite) { EmbeddedTabChrome( title = title.ifBlank { coordinate }, isSandbox = true, onReload = { controller.reload() }, onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) }, onInfo = { showAccess = true }, + isFavorite = isFavorite, + onFavorite = { + val favId = "nostr:$coordinate" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + } + }, ) } // Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index dc3b8c9e6e..d45442ace0 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -590,8 +590,28 @@ class NappletBrowserActivity : ComponentActivity() { liveUrl = startUrl, onNavigate = { loadAddress(it) }, onConsole = { consolePanel?.toggle() }, + isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), + onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) }, ).also { controlSheet = it } + private fun sendFavoriteToggle(url: String) { + val host = + runCatching { + android.net.Uri + .parse(url) + .host + }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + val msg = + Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_FAVORITE_URL, url) + putString(NappletIpc.KEY_FAVORITE_LABEL, host) + } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + private fun buildConsolePanel(): View = NappletConsolePanel(this).also { it.onClearCallback = { controlSheet?.updateConsoleCount(0) } @@ -639,6 +659,7 @@ class NappletBrowserActivity : ComponentActivity() { private const val EXTRA_PROXY_PORT = "proxyPort" private const val EXTRA_USE_TOR = "useTor" private const val EXTRA_TITLE = "title" + private const val EXTRA_IS_FAVORITE = "isFavorite" fun intent( context: Context, @@ -646,6 +667,7 @@ class NappletBrowserActivity : ComponentActivity() { proxyPort: Int, useTor: Boolean, title: String = "", + isFavorite: Boolean = false, ): Intent = Intent() .setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity") @@ -653,6 +675,7 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(EXTRA_PROXY_PORT, proxyPort) .putExtra(EXTRA_USE_TOR, useTor) .putExtra(EXTRA_TITLE, title) + .putExtra(EXTRA_IS_FAVORITE, isFavorite) // Distinct task identity per URL for documentLaunchMode=intoExisting. .setData(Uri.parse(url)) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt index d474152177..78d3d5bec2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -67,6 +67,9 @@ class NappletControlSheet( // When non-null, a "Console" row is added to the pull-down sheet. The callback toggles the // browser's console log panel; the count label is updated via [updateConsoleCount]. private val onConsole: (() -> Unit)? = null, + // When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state. + isFavoriteInitially: Boolean = false, + private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null, ) : LinearLayout(context) { private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary) @@ -75,6 +78,7 @@ class NappletControlSheet( private var expanded = false private var torOn = torInitiallyOn private var currentUrl = liveUrl + private var isFavorite = isFavoriteInitially private val panel: LinearLayout private var torLabel: TextView? = null @@ -82,6 +86,7 @@ class NappletControlSheet( private var addressField: EditText? = null private var securityGlyph: TextView? = null private var consoleLabel: TextView? = null + private var favoriteLabel: TextView? = null init { orientation = VERTICAL @@ -156,6 +161,35 @@ class NappletControlSheet( }, ) } + onFavoriteToggle?.let { + val label = + TextView(context).apply { + text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + setTextColor(onSurface) + textSize = 15f + setPadding(dp(8), 0, 0, 0) + } + favoriteLabel = label + addView( + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(8), dp(10), dp(8), dp(10)) + isClickable = true + setOnClickListener { toggleFavorite() } + addView( + TextView(context).apply { + text = "★" + setTextColor(dimmed) + textSize = 18f + width = dp(28) + gravity = Gravity.CENTER + }, + ) + addView(label) + }, + ) + } } private fun titleRow(): View = @@ -254,6 +288,19 @@ class NappletControlSheet( // Don't fight the user while they're editing the field. addressField?.takeIf { !it.hasFocus() }?.setText(url) securityGlyph?.text = securityGlyphFor(url) + // Reset favorite state for the new URL (we don't know if it's a favorite without a round-trip). + if (onFavoriteToggle != null) { + isFavorite = false + favoriteLabel?.text = context.getString(R.string.browser_favorite_add) + } + } + + private fun toggleFavorite() { + val url = currentUrl?.takeIf { it.isNotBlank() } ?: return + isFavorite = !isFavorite + favoriteLabel?.text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + collapse() + onFavoriteToggle?.invoke(url, isFavorite) } private fun securityGlyphFor(url: String): String = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index fd2913e607..d740e5a03a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -89,6 +89,14 @@ object NappletIpc { */ const val MSG_RECORD_ICON = 10 + /** + * Host → broker (browser mode): toggle a URL in the main-process favorites registry. Carries + * [KEY_FAVORITE_URL] and [KEY_FAVORITE_LABEL]. The broker adds the URL if it isn't already + * a favorite, or removes it if it is — identical to the in-app star toggle on the home screen. + * Fire-and-forget; no reply needed. + */ + const val MSG_TOGGLE_WEB_FAVORITE = 11 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" @@ -107,6 +115,12 @@ object NappletIpc { /** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */ const val KEY_WEB_HOST = "webHost" + /** The full URL (e.g. `https://example.com`) to toggle as a web favorite. */ + const val KEY_FAVORITE_URL = "favoriteUrl" + + /** A human-readable label for the favorited URL (typically the host). */ + const val KEY_FAVORITE_LABEL = "favoriteLabel" + /** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */ const val KEY_FOREGROUND = "foreground" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 1fef9c9700..cd0e294a13 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -34,6 +34,10 @@ Loads over Tor Loads over the open web + + Add to favorites + Remove from favorites + Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again. From 5662c89d11fc1f961f12969a82501092cb5970a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 02:45:39 +0000 Subject: [PATCH 05/10] fix: extract ConsoleLogRow to fix Kotlin compiler OOM in BottomConsoleSheet The Text() calls inside the logs.forEach loop were 8 lambdas deep (Box > Column > AnimatedVisibility > Surface > Column > Column > forEach > Row), which caused java.lang.OutOfMemoryError in the Kotlin bytecode optimizer (ConstantConditionEliminationMethodTransformer). This broke CI when PR #3353 merged. Extract the Row content into a top-level ConsoleLogRow composable to reduce nesting depth. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt --- .../loggedIn/embed/BottomConsoleSheet.kt | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt index 88604ae38d..af3a79e9be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt @@ -128,43 +128,8 @@ fun BottomConsoleSheet( .verticalScroll(scrollState) .padding(horizontal = 8.dp, vertical = 4.dp), ) { - val dimColor = MaterialTheme.colorScheme.onSurfaceVariant logs.forEach { entry -> - val levelColor = consoleLevelColor(entry.level) - val srcShort = - entry.source - .substringAfterLast("/") - .substringAfterLast("\\") - .let { if (it.isBlank()) entry.source.takeLast(20) else it } - Row( - Modifier.fillMaxWidth().padding(vertical = 1.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - consoleLevelChar(entry.level), - color = levelColor, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - modifier = Modifier.width(14.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - buildAnnotatedString { - withStyle(SpanStyle(color = levelColor)) { - append(entry.message) - } - if (srcShort.isNotBlank()) { - withStyle(SpanStyle(color = dimColor)) { - append(" $srcShort:${entry.lineNumber}") - } - } - }, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - modifier = Modifier.weight(1f), - overflow = TextOverflow.Visible, - ) - } + ConsoleLogRow(entry) } } } @@ -203,6 +168,46 @@ fun BottomConsoleSheet( } } +@Composable +private fun ConsoleLogRow(entry: ConsoleLogEntry) { + val levelColor = consoleLevelColor(entry.level) + val dimColor = MaterialTheme.colorScheme.onSurfaceVariant + val srcShort = + entry.source + .substringAfterLast("/") + .substringAfterLast("\\") + .let { if (it.isBlank()) entry.source.takeLast(20) else it } + Row( + Modifier.fillMaxWidth().padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + consoleLevelChar(entry.level), + color = levelColor, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.width(14.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = levelColor)) { + append(entry.message) + } + if (srcShort.isNotBlank()) { + withStyle(SpanStyle(color = dimColor)) { + append(" $srcShort:${entry.lineNumber}") + } + } + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Visible, + ) + } +} + @Composable private fun consoleLevelColor(level: String): Color { val warningAmber = if (isSystemInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFE65100) From 50138fe6cb27d39d72c62277f50dd4a3d667ccb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:27:36 +0000 Subject: [PATCH 06/10] feat: distinct status-bar icon for the always-on relay service Notification small icons are rendered by the system as flat, alpha-only silhouettes, so the always-on relay-connection service showed the exact same solid gem as real notifications (DMs, zaps, mentions). The persistent service therefore kept reading as a fresh notification. Add an outlined (hollow) gem variant, `amethyst_service`, and point the foreground service notification at it so it's clearly distinguishable at a glance while staying on-brand. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0141R1Km7YsA2miFoxLkXLQk --- .../notifications/NotificationRelayService.kt | 2 +- .../main/res/drawable/amethyst_service.xml | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/res/drawable/amethyst_service.xml diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index dec5bcecb1..de2b6c2694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -342,7 +342,7 @@ class NotificationRelayService : Service() { .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) - .setSmallIcon(R.drawable.amethyst) + .setSmallIcon(R.drawable.amethyst_service) .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) diff --git a/amethyst/src/main/res/drawable/amethyst_service.xml b/amethyst/src/main/res/drawable/amethyst_service.xml new file mode 100644 index 0000000000..e69cb5ce8e --- /dev/null +++ b/amethyst/src/main/res/drawable/amethyst_service.xml @@ -0,0 +1,21 @@ + + + + From 8d036e0ac0b9330f1cebe9d627f529bf765cf961 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:12:20 +0000 Subject: [PATCH 07/10] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 24 ++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 31 +++++++++++++++++++ docs/changelog/translators.json | 24 +++++++------- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index f36c2c7505..fb0c2b0978 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -653,6 +653,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przeglądarka Szukaj lub wprowadź adres Odśwież + Konsola + Konsola (%1$d) + Wyczyść Wczytywanie przez Tor. Kliknij, aby użyć otwartej sieci. Pobieranie przez otwartą sieć. Kliknij, aby skorzystać z sieci Tor. Przeglądarka w aplikacji wymaga Androida 11 lub nowszego. @@ -718,6 +721,24 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Zezwól jednorazowo Nigdy nie zezwalaj Nie teraz + Ten nApplet chce odczytać Twój klucz publiczny. + Ten nApplet chce odczytać dane Twojego profilu i konta. + Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu. + Ta aplikacja nApplet chce podpisać i opublikować zdarzenie typu %1$d w Twoim imieniu: + Ten nApplet chce wysłać zaszyfrowane wydarzenie jako Ty. + Ten nApplet chce odczytywać wydarzenia z twoich transmiterów. + Ta aplikacja nApplet chce korzystać ze swojego prywatnego schowka. + Ta aplikacja nApplet chce zapłacić fakturę w systemie Lightning. + Ten nApplet chce pobrać zasób internetowy. + Ten nApplet chce przesłać plik na Twój serwer multimedialny. + Ten nApplet chce pokazywać Ci powiadomienia. + Ta witryna chce podpisać wydarzenie %1$d za pomocą Twojego klucza Nostr. + + Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d sat. + Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów. + Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszy. + Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów. + Źródło: %1$s v%1$s Pobierz @@ -1282,6 +1303,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Podział powiadomień według obserwowanych Wyświetl dwie zakładki powiadomień — „Obserwowani” (osoby, które obserwujesz) oraz „Wszyscy”. Wskaźnik nieprzeczytanych powiadomień świeci się tylko w przypadku aktywności osób, które obserwujesz. Pokaż wiadomości + W zakładce „Powiadomienia” znajdują się zarówno wiadomości bezpośrednie, jak i grupowe. Wyłącz tę opcję, aby wiadomości były wyświetlane wyłącznie w zakładce „Wiadomości”. Obserwowani Wszyscy Optymalizacja baterii aktywna @@ -3139,6 +3161,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Edytuj informacje o grupie Opuść grupę ta grupa + Czy na pewno chcesz opuścić grupę „%1$s”? Nie będziesz już otrzymywać wiadomości od tej grupy. + Nie udało się opuścić grupy: %1$s Dodawanie %1$s… Nie udało się dodać %1$s: %2$s nieznany błąd diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index e906b55454..bdf45002d0 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -628,6 +628,37 @@ nApplets nSites 尚未找到nSite。 + 浏览器 + 搜索或输入地址 + 重新加载 + 控制台 + 控制台(%1$d) + 清除 + 正通过 Tor 加载。点击使用 open web。 + 正通过 open web 加载 Tor。点击使用 Tor。 + 应用内浏览器需要 Android 11 或更高版本。 + 无法加载此应用。 + 打开 + 清除 + 收藏夹 + 选项 + 从历史记录中删除 + 喜欢的应用 + 还没有喜欢的应用。打开 web 客户端或 nsite 并点击⭐来固定它。 + 添加至收藏夹 + 从收藏中移除 + 在自己的窗口打开 + 此应用可以访问什么 + 在沙盒中运行,没有对账户的特殊访问权。 + 此应用可以访问什么 + 通过 Tor 加载。 + 通过 open web 加载。 + 此应用尚未加载。请从其卡片打开它,或稍后再试。 + 已发布到中继 + 上传了一个文件 + 支付了一笔款项 + 该应用尚未加载。稍后再试。 + 最近 nApplet 权限 管理权限 尚无nApplet 权限 diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index e7b00dd85d..f0df6ac334 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -76,18 +76,18 @@ "tag": "v1.12.6", "since": "2026-06-19T19:01:59-04:00", "translators": [ - { - "user": "rajs19420616", - "languages": [ - "Hindi" - ] - }, { "user": "maxblake2015", "languages": [ "Polish" ] }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, { "user": "anthony-robin", "languages": [ @@ -100,18 +100,18 @@ "Slovenian" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "hypnotichemionus4", "languages": [ "Chinese Simplified" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "BitByBit21", "languages": [ From d71c035669f6aa0c30aefac75e0ef2fa76d3513d Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 25 Jun 2026 12:04:54 +0200 Subject: [PATCH 08/10] fix(napplet): log failed temp-file cleanup in NappletBlobCache.put --- .../vitorpamplona/amethyst/napplethost/NappletBlobCache.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index 9c2afc23c2..a0b2636a65 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.napplethost import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File @@ -54,7 +55,9 @@ class NappletBlobCache( dir.mkdirs() val tmp = File(dir, "$sha256.tmp.${System.nanoTime()}") tmp.writeBytes(bytes) - if (!tmp.renameTo(target)) tmp.delete() + if (!tmp.renameTo(target) && !tmp.delete()) { + Log.w("NappletBlobCache") { "Failed to delete leftover temp file ${tmp.absolutePath} after a failed rename" } + } } } From 2ec072419adaf6e71085c2f4479b4cc29c4f03cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 07:44:08 +0000 Subject: [PATCH 09/10] fix: keep animated media inside its gallery grid cell Animated GIF/AVIF media in the multi-image gallery rendered with its own intrinsic aspect ratio via fillMaxWidth(), ignoring the ContentScale.Crop the grid asks for --- .../amethyst/ui/components/GifVideoView.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index b9b695a559..c5400f804d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -77,9 +77,21 @@ fun GifVideoView( val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current + // Mirror mediaSizingModifier() used by static images so animated media obeys the + // same sizing policy. In Crop contexts (e.g. the multi-image gallery grid) the parent + // cell has a fixed width AND height, so we must fill it and let the inner content crop; + // imposing our own aspectRatio()+fillMaxWidth() here would overflow the cell and spill + // over neighbouring content like the reaction row. + val sizeModifier = + when { + contentScale == ContentScale.Crop -> Modifier.fillMaxSize() + ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) + else -> Modifier.fillMaxWidth() + } + val containerModifier = - (if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier) - .fillMaxWidth() + borderModifier + .then(sizeModifier) .let { if (onDialog != null) it.clickable { onDialog() } else it } Box( From 020c08d5610e02950808b4afd356647a1861bc68 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 25 Jun 2026 13:30:38 +0200 Subject: [PATCH 10/10] Code review: - reuse mediaSizingModifier in GifVideoView --- .../amethyst/ui/components/GifVideoView.kt | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt index c5400f804d..e251521024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -24,9 +24,7 @@ import android.graphics.drawable.Animatable import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -77,21 +75,13 @@ fun GifVideoView( val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current - // Mirror mediaSizingModifier() used by static images so animated media obeys the - // same sizing policy. In Crop contexts (e.g. the multi-image gallery grid) the parent - // cell has a fixed width AND height, so we must fill it and let the inner content crop; - // imposing our own aspectRatio()+fillMaxWidth() here would overflow the cell and spill - // over neighbouring content like the reaction row. - val sizeModifier = - when { - contentScale == ContentScale.Crop -> Modifier.fillMaxSize() - ratio != null -> Modifier.fillMaxWidth().aspectRatio(ratio) - else -> Modifier.fillMaxWidth() - } - + // Share the static-image sizing policy so animated media obeys it too: in Crop + // contexts (e.g. the multi-image gallery grid) the cell has a fixed width AND height, + // so the content must fill and crop rather than impose its own aspect ratio, which + // would overflow the cell and spill over neighbouring content like the reaction row. val containerModifier = borderModifier - .then(sizeModifier) + .then(mediaSizingModifier(ratio, contentScale)) .let { if (onDialog != null) it.clickable { onDialog() } else it } Box(