From cbee188ca50b0d14a43453d83e627c493caf7d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 19:29:55 +0000 Subject: [PATCH] feat: implement NIP-B0 Web Bookmarking (kind 39701) Add support for NIP-B0 web bookmarks with a complete protocol implementation in Quartz and a full UI in Amethyst for adding, editing, browsing, and deleting web bookmarks. Quartz: - WebBookmarkEvent (kind 39701) as an addressable event - Tag builder/parser extensions for title, published_at, hashtags - Registered in EventFactory Amethyst: - WebBookmarksScreen with FAB to add, and per-card edit/delete/open - WebBookmarkEditDialog for add/edit with URL, title, description, tags - WebBookmarkFeedFilter querying addressable notes by kind + pubkey - Account.sendWebBookmark() and Account.deleteWebBookmark() - LocalCache.consume() for WebBookmarkEvent - Drawer navigation entry with Language icon - Route.WebBookmarks and navigation registration https://claude.ai/code/session_01UzfLJttwuJzovtb8HX5F9n --- .../vitorpamplona/amethyst/model/Account.kt | 26 ++ .../amethyst/model/LocalCache.kt | 8 + .../ui/feeds/RememberForeverStates.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 7 + .../loggedIn/relays/RelayInformationScreen.kt | 2 + .../webBookmarks/WebBookmarksScreen.kt | 398 ++++++++++++++++++ .../webBookmarks/dal/WebBookmarkFeedFilter.kt | 56 +++ amethyst/src/main/res/values/strings.xml | 17 + .../nipB0WebBookmarks/TagArrayBuilderExt.kt | 29 ++ .../quartz/nipB0WebBookmarks/TagArrayExt.kt | 32 ++ .../nipB0WebBookmarks/WebBookmarkEvent.kt | 86 ++++ .../quartz/utils/EventFactory.kt | 2 + 15 files changed, 678 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 32bb83c9b..dbe16ea3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -213,6 +213,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.containsAny @@ -1006,6 +1007,31 @@ class Account( client.send(event, computeRelayListToBroadcast(event)) } + suspend fun sendWebBookmark( + url: String, + title: String?, + description: String, + hashtags: List = emptyList(), + ) { + if (!isWriteable()) return + + val template = WebBookmarkEvent.build(url, title, description, tags = hashtags) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.send(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + + suspend fun deleteWebBookmark(event: WebBookmarkEvent) { + if (!isWriteable()) return + + val template = DeletionEvent.build(listOf(event)) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.send(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return cache.justConsumeMyOwnEvent(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 16d054ea6..d792bcb3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -206,6 +206,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.utils.DualCase @@ -1433,6 +1434,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: WebBookmarkEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: CalendarDateSlotEvent, relay: NormalizedRelayUrl?, @@ -3209,6 +3216,7 @@ object LocalCache : ILocalCache, ICacheProvider { is VideoShortEvent -> consume(event, relay, wasVerified) is VoiceEvent -> consume(event, relay, wasVerified) is VoiceReplyEvent -> consume(event, relay, wasVerified) + is WebBookmarkEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified) else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 2cc7b942c..f805be066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -56,6 +56,8 @@ object ScrollStateKeys { const val DISCOVER_CHATS = "DiscoverChatsFeed" const val SEARCH_SCREEN = "SearchFeed" + + const val WEB_BOOKMARKS = "WebBookmarksFeed" } object PagerStateKeys { 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 889b5058d..926bce501 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 @@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.nip01Core.core.Address @@ -200,6 +201,7 @@ fun AppNavigation( composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index dd1eb0605..269e22308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -51,6 +51,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.HorizontalDivider @@ -458,6 +459,14 @@ fun ListContent( route = Route.BookmarkGroups, ) + NavigationRow( + title = R.string.web_bookmarks, + icon = Icons.Outlined.Language, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.WebBookmarks, + ) + NavigationRow( title = R.string.drafts, icon = Icons.Outlined.Drafts, 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 765748f7b..f22434cb0 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 @@ -96,6 +96,8 @@ sealed class Route { ) } + @Serializable object WebBookmarks : Route() + @Serializable object Drafts : Route() @Serializable object AllSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index c2a2b12d7..aff1e9cb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import kotlinx.coroutines.CoroutineScope class AccountFeedContentStates( @@ -76,6 +77,8 @@ class AccountFeedContentStates( val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache) + val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) + suspend fun init() { notificationSummary.initializeSuspend() } @@ -104,6 +107,8 @@ class AccountFeedContentStates( notificationSummary.invalidateInsertData(newNotes) drafts.updateFeedWith(newNotes) + + webBookmarks.updateFeedWith(newNotes) } fun deleteNotes(newNotes: Set) { @@ -130,6 +135,8 @@ class AccountFeedContentStates( notificationSummary.invalidateInsertData(newNotes) drafts.deleteFromFeed(newNotes) + + webBookmarks.deleteFromFeed(newNotes) } fun destroy() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 139790f9a..2276ba5f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -269,6 +269,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -658,6 +659,7 @@ private fun kindDisplayName(kind: Int): Int = VideoShortEvent.KIND -> R.string.kind_shorts VoiceEvent.KIND -> R.string.kind_voice_msg VoiceReplyEvent.KIND -> R.string.kind_voice_reply + WebBookmarkEvent.KIND -> R.string.kind_web_bookmark WikiNoteEvent.KIND -> R.string.kind_wiki else -> -1 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt new file mode 100644 index 000000000..f7623950d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -0,0 +1,398 @@ +/* + * 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.webBookmarks + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent + +@Composable +fun WebBookmarksScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + RenderWebBookmarksScreen(accountViewModel.feedStates.webBookmarks, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderWebBookmarksScreen( + feedState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedState) + + var showAddDialog by remember { mutableStateOf(false) } + + if (showAddDialog) { + WebBookmarkEditDialog( + onDismiss = { showAddDialog = false }, + onSave = { url, title, description, tags -> + accountViewModel.launchSigner { + accountViewModel.account.sendWebBookmark(url, title, description, tags) + } + showAddDialog = false + }, + ) + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + ShorterTopAppBar( + title = { + Text(text = stringRes(id = R.string.web_bookmarks)) + }, + navigationIcon = { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + }, + ) + }, + floatingButton = { + FloatingActionButton( + onClick = { showAddDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.web_bookmark_add_title), + ) + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it).fillMaxHeight()) { + RefresheableBox(feedState) { + SaveableFeedState(feedState, ScrollStateKeys.WEB_BOOKMARKS) { listState -> + RenderFeedContentState( + feedContentState = feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = null, + onLoaded = { WebBookmarksFeedLoaded(it, listState, accountViewModel, nav) }, + ) + } + } + } + } +} + +@Composable +private fun WebBookmarksFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + WebBookmarkCard(item, accountViewModel, nav) + + HorizontalDivider(thickness = DividerThickness) + } + } +} + +@Composable +private fun WebBookmarkCard( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? WebBookmarkEvent ?: return + val uriHandler = LocalUriHandler.current + + var showEditDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showEditDialog) { + WebBookmarkEditDialog( + initialUrl = event.url(), + initialTitle = event.title() ?: "", + initialDescription = event.description(), + initialTags = event.hashtags().joinToString(", "), + onDismiss = { showEditDialog = false }, + onSave = { url, title, description, tags -> + accountViewModel.launchSigner { + accountViewModel.account.sendWebBookmark(url, title, description, tags) + } + showEditDialog = false + }, + ) + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.web_bookmark_delete)) }, + text = { Text(stringResource(R.string.web_bookmark_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + accountViewModel.launchSigner { + accountViewModel.account.deleteWebBookmark(event) + } + showDeleteDialog = false + }) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.no)) + } + }, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri(event.url()) } + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = event.title() ?: event.url(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = event.url(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row { + IconButton(onClick = { uriHandler.openUri(event.url()) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.web_bookmark_open_url), + ) + } + IconButton(onClick = { showEditDialog = true }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.web_bookmark_edit_title), + ) + } + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.web_bookmark_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + + if (event.description().isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = event.description(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + val tags = event.hashtags() + if (tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Row { + tags.forEach { tag -> + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(end = 8.dp), + ) + } + } + } + } +} + +@Composable +fun WebBookmarkEditDialog( + initialUrl: String = "", + initialTitle: String = "", + initialDescription: String = "", + initialTags: String = "", + onDismiss: () -> Unit, + onSave: (url: String, title: String?, description: String, tags: List) -> Unit, +) { + var url by remember { mutableStateOf(initialUrl) } + var title by remember { mutableStateOf(initialTitle) } + var description by remember { mutableStateOf(initialDescription) } + var tags by remember { mutableStateOf(initialTags) } + + val isEditing = initialUrl.isNotEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource( + if (isEditing) R.string.web_bookmark_edit_title else R.string.web_bookmark_add_title, + ), + ) + }, + text = { + Column { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.web_bookmark_url_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_url_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.web_bookmark_title_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_title_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.web_bookmark_description_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_description_placeholder)) }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text(stringResource(R.string.web_bookmark_tags_label)) }, + placeholder = { Text(stringResource(R.string.web_bookmark_tags_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (url.isNotBlank()) { + val normalizedUrl = if (!url.startsWith("http")) "https://$url" else url + val tagList = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() } + onSave(normalizedUrl, title.ifBlank { null }, description, tagList) + } + }, + enabled = url.isNotBlank(), + ) { + Text(stringResource(R.string.web_bookmark_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt new file mode 100644 index 000000000..ed628f5ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/dal/WebBookmarkFeedFilter.kt @@ -0,0 +1,56 @@ +/* + * 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.webBookmarks.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent + +class WebBookmarkFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "/webBookmarks" + + override fun applyFilter(newItems: Set): Set = + newItems.filterTo(HashSet()) { + acceptableEvent(it) + } + + override fun feed(): List { + val bookmarks = + LocalCache.addressables.filterIntoSet(WebBookmarkEvent.KIND, account.userProfile().pubkeyHex) { _, note -> + acceptableEvent(note) + } + + return sort(bookmarks) + } + + fun acceptableEvent(it: Note): Boolean { + val noteEvent = it.event + return noteEvent is WebBookmarkEvent && noteEvent.pubKey == account.userProfile().pubkeyHex + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 965229207..397e8e004 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1818,6 +1818,7 @@ Shorts Voice Msg Voice Reply + Web Bookmark Wiki Start with a great feed by following the same people as someone you trust. Import Follow List @@ -1936,4 +1937,20 @@ All time Last sync: %1$s Since Last Sync + Web Bookmarks + No web bookmarks yet. Tap + to add one. + Add Web Bookmark + Edit Web Bookmark + URL + https://example.com + Title + Bookmark title + Description + A short description + Tags (comma-separated) + nostr, tech, blog + Save + Delete + Delete this web bookmark? + Open URL diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayBuilderExt.kt new file mode 100644 index 000000000..b4766b8c4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.nipB0WebBookmarks + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.publishedAt(timestamp: Long) = addUnique(PublishedAtTag.assemble(timestamp)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayExt.kt new file mode 100644 index 000000000..316f9b00f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * 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.quartz.nipB0WebBookmarks + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag + +fun TagArray.webBookmarkTitle() = firstNotNullOfOrNull(TitleTag::parse) + +fun TagArray.webBookmarkPublishedAt() = firstNotNullOfOrNull(PublishedAtTag::parse) + +fun TagArray.webBookmarkHashtags() = hashtags() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt new file mode 100644 index 000000000..bddca68f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt @@ -0,0 +1,86 @@ +/* + * 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.quartz.nipB0WebBookmarks + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class WebBookmarkEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: TagArray, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun url(): String { + val dTagValue = dTag() + return if (dTagValue.isNotEmpty()) "https://$dTagValue" else "" + } + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + + fun hashtags() = tags.hashtags() + + fun description() = content + + companion object { + const val KIND = 39701 + const val ALT_DESCRIPTION = "Web Bookmark" + + fun urlToDTag(url: String): String = + url + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/') + + fun build( + url: String, + bookmarkTitle: String?, + description: String, + tags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(urlToDTag(url)) + alt(ALT_DESCRIPTION) + bookmarkTitle?.let { title(it) } + publishedAt(createdAt) + hashtags(tags) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 0239da5a8..bc5b346ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -156,6 +156,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent @@ -321,6 +322,7 @@ class EventFactory { VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig) VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig) VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig) + WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } as T