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
This commit is contained in:
Claude
2026-03-25 19:29:55 +00:00
parent f42881a48e
commit cbee188ca5
15 changed files with 678 additions and 0 deletions
@@ -213,6 +213,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
@@ -1006,6 +1007,31 @@ class Account(
client.send(event, computeRelayListToBroadcast(event)) client.send(event, computeRelayListToBroadcast(event))
} }
suspend fun sendWebBookmark(
url: String,
title: String?,
description: String,
hashtags: List<String> = 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?) { fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return if (event == null) return
cache.justConsumeMyOwnEvent(event) cache.justConsumeMyOwnEvent(event)
@@ -206,6 +206,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.DualCase
@@ -1433,6 +1434,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified) ) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: WebBookmarkEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume( private fun consume(
event: CalendarDateSlotEvent, event: CalendarDateSlotEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -3209,6 +3216,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is VideoShortEvent -> consume(event, relay, wasVerified) is VideoShortEvent -> consume(event, relay, wasVerified)
is VoiceEvent -> consume(event, relay, wasVerified) is VoiceEvent -> consume(event, relay, wasVerified)
is VoiceReplyEvent -> consume(event, relay, wasVerified) is VoiceReplyEvent -> consume(event, relay, wasVerified)
is WebBookmarkEvent -> consume(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified)
is PaymentTargetsEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified)
else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false } else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false }
@@ -56,6 +56,8 @@ object ScrollStateKeys {
const val DISCOVER_CHATS = "DiscoverChatsFeed" const val DISCOVER_CHATS = "DiscoverChatsFeed"
const val SEARCH_SCREEN = "SearchFeed" const val SEARCH_SCREEN = "SearchFeed"
const val WEB_BOOKMARKS = "WebBookmarksFeed"
} }
object PagerStateKeys { object PagerStateKeys {
@@ -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.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen 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.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -200,6 +201,7 @@ fun AppNavigation(
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) } composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) } composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) } composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) } composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd 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.Settings
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -458,6 +459,14 @@ fun ListContent(
route = Route.BookmarkGroups, route = Route.BookmarkGroups,
) )
NavigationRow(
title = R.string.web_bookmarks,
icon = Icons.Outlined.Language,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.WebBookmarks,
)
NavigationRow( NavigationRow(
title = R.string.drafts, title = R.string.drafts,
icon = Icons.Outlined.Drafts, icon = Icons.Outlined.Drafts,
@@ -96,6 +96,8 @@ sealed class Route {
) )
} }
@Serializable object WebBookmarks : Route()
@Serializable object Drafts : Route() @Serializable object Drafts : Route()
@Serializable object AllSettings : Route() @Serializable object AllSettings : Route()
@@ -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.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter 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.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
class AccountFeedContentStates( class AccountFeedContentStates(
@@ -76,6 +77,8 @@ class AccountFeedContentStates(
val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache) val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache)
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
suspend fun init() { suspend fun init() {
notificationSummary.initializeSuspend() notificationSummary.initializeSuspend()
} }
@@ -104,6 +107,8 @@ class AccountFeedContentStates(
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.updateFeedWith(newNotes) drafts.updateFeedWith(newNotes)
webBookmarks.updateFeedWith(newNotes)
} }
fun deleteNotes(newNotes: Set<Note>) { fun deleteNotes(newNotes: Set<Note>) {
@@ -130,6 +135,8 @@ class AccountFeedContentStates(
notificationSummary.invalidateInsertData(newNotes) notificationSummary.invalidateInsertData(newNotes)
drafts.deleteFromFeed(newNotes) drafts.deleteFromFeed(newNotes)
webBookmarks.deleteFromFeed(newNotes)
} }
fun destroy() { fun destroy() {
@@ -269,6 +269,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -658,6 +659,7 @@ private fun kindDisplayName(kind: Int): Int =
VideoShortEvent.KIND -> R.string.kind_shorts VideoShortEvent.KIND -> R.string.kind_shorts
VoiceEvent.KIND -> R.string.kind_voice_msg VoiceEvent.KIND -> R.string.kind_voice_msg
VoiceReplyEvent.KIND -> R.string.kind_voice_reply VoiceReplyEvent.KIND -> R.string.kind_voice_reply
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
WikiNoteEvent.KIND -> R.string.kind_wiki WikiNoteEvent.KIND -> R.string.kind_wiki
else -> -1 else -> -1
} }
@@ -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<String>) -> 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))
}
},
)
}
@@ -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<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "/webBookmarks"
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
acceptableEvent(it)
}
override fun feed(): List<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
+17
View File
@@ -1818,6 +1818,7 @@
<string name="kind_shorts">Shorts</string> <string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Voice Msg</string> <string name="kind_voice_msg">Voice Msg</string>
<string name="kind_voice_reply">Voice Reply</string> <string name="kind_voice_reply">Voice Reply</string>
<string name="kind_web_bookmark">Web Bookmark</string>
<string name="kind_wiki">Wiki</string> <string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string> <string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string>
<string name="import_follow_list">Import Follow List</string> <string name="import_follow_list">Import Follow List</string>
@@ -1936,4 +1937,20 @@
<string name="event_sync_date_filter_all_time">All time</string> <string name="event_sync_date_filter_all_time">All time</string>
<string name="event_sync_date_filter_last_sync">Last sync: %1$s</string> <string name="event_sync_date_filter_last_sync">Last sync: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Since Last Sync</string> <string name="event_sync_date_filter_since_last_sync">Since Last Sync</string>
<string name="web_bookmarks">Web Bookmarks</string>
<string name="web_bookmarks_empty">No web bookmarks yet. Tap + to add one.</string>
<string name="web_bookmark_add_title">Add Web Bookmark</string>
<string name="web_bookmark_edit_title">Edit Web Bookmark</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://example.com</string>
<string name="web_bookmark_title_label">Title</string>
<string name="web_bookmark_title_placeholder">Bookmark title</string>
<string name="web_bookmark_description_label">Description</string>
<string name="web_bookmark_description_placeholder">A short description</string>
<string name="web_bookmark_tags_label">Tags (comma-separated)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Save</string>
<string name="web_bookmark_delete">Delete</string>
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
<string name="web_bookmark_open_url">Open URL</string>
</resources> </resources>
@@ -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<WebBookmarkEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<WebBookmarkEvent>.publishedAt(timestamp: Long) = addUnique(PublishedAtTag.assemble(timestamp))
@@ -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()
@@ -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<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<WebBookmarkEvent>.() -> Unit = {},
) = eventTemplate<WebBookmarkEvent>(KIND, description, createdAt) {
dTag(urlToDTag(url))
alt(ALT_DESCRIPTION)
bookmarkTitle?.let { title(it) }
publishedAt(createdAt)
hashtags(tags)
initializer()
}
}
}
@@ -156,6 +156,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
@@ -321,6 +322,7 @@ class EventFactory {
VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig) VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig)
VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig) VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig)
VoiceReplyEvent.KIND -> VoiceReplyEvent(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) 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) else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
} as T } as T