Merge pull request #3315 from LubuSeb/lubu/url-comments-304

Add URL-scoped comment feeds
This commit is contained in:
Vitor Pamplona
2026-06-25 09:13:19 -04:00
committed by GitHub
23 changed files with 758 additions and 15 deletions
@@ -71,6 +71,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
@@ -116,6 +117,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)
@@ -209,6 +211,7 @@ class RelaySubscriptionsCoordinator(
profile,
hashtags,
geohashes,
urls,
relayFeed,
relayInfoNip66,
chess,
@@ -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) {
@@ -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)) }
},
)
}
}
}
@@ -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)
@@ -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()
}
}
}
}
}
@@ -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)
}
}
}
@@ -214,6 +214,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
@@ -460,6 +462,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Url> { UrlScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
@@ -570,6 +573,19 @@ fun BuildNavigation(
)
}
composableFromBottomArgs<Route.UrlPost> {
UrlPostScreen(
url = it.url,
message = it.message,
attachment = it.attachment,
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
nav,
)
}
composableFromBottomArgs<Route.GenericCommentPost> {
ReplyCommentPostScreen(
replyId = it.replyTo,
@@ -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)
}
@@ -477,6 +477,10 @@ sealed class Route {
val geohash: String,
) : Route()
@Serializable data class Url(
val url: String,
) : Route()
@Serializable data class ChessGame(
val gameId: String,
) : Route()
@@ -691,6 +695,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,
@@ -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)) },
)
}
}
@@ -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)) },
)
}
@@ -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,
)
}
}
@@ -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)
}
@@ -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,
)
}
@@ -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<NormalizedRelayUrl>,
val account: Account,
val cache: LocalCache,
) : AdditiveFeedFilter<Note>() {
val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + normalizedUrl
override fun feed(): List<Note> {
val notes =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it, normalizedUrl)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = 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<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -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<NormalizedRelayUrl>,
val account: Account,
) : AndroidFeedViewModel(
UrlFeedFilter(url, relays, account, LocalCache),
) {
@Suppress("UNCHECKED_CAST")
class Factory(
val url: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = UrlFeedViewModel(url, relays, account) as T
}
}
@@ -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<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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,
),
)
}
}
@@ -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<UrlQueryState>,
) : PerUniqueIdEoseManager<UrlQueryState, String>(client, allKeys) {
override fun updateFilter(
key: UrlQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterPostsByUrl(key.normalizedUrl, key.relays, since)
override fun id(key: UrlQueryState) = key.normalizedUrl
}
@@ -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<NormalizedRelayUrl>,
) {
val normalizedUrl = UrlId.toScopeOrNull(url).orEmpty()
}
@Stable
class UrlFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<UrlQueryState>() {
val group =
listOf(
UrlFeedFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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=%"))
}
}
@@ -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())
}
}
@@ -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(