no message

This commit is contained in:
Vitor Pamplona
2026-03-25 16:14:20 -04:00
20 changed files with 909 additions and 2 deletions
@@ -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<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?) {
if (event == null) return
cache.justConsumeMyOwnEvent(event)
@@ -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 }
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -91,9 +92,11 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey]
if (cached != null) return cached
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
return Account(
settings = accountSettings,
signer = signer,
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
otsResolverBuilder = otsResolverBuilder,
@@ -122,4 +125,8 @@ class AccountCacheState(
emptyMap()
}
}
companion object {
const val CLIENT_TAG_NAME = "Amethyst"
}
}
@@ -56,6 +56,8 @@ object ScrollStateKeys {
const val DISCOVER_CHATS = "DiscoverChatsFeed"
const val SEARCH_SCREEN = "SearchFeed"
const val WEB_BOOKMARKS = "WebBookmarksFeed"
}
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.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<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(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.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,
@@ -96,6 +96,8 @@ sealed class Route {
)
}
@Serializable object WebBookmarks : Route()
@Serializable object Drafts : Route()
@Serializable object AllSettings : Route()
@@ -328,6 +328,7 @@ private enum class FeedGroup(
HASHTAGS(R.string.feed_group_hashtags),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_lists),
RELAYS(R.string.feed_group_relays),
}
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
@@ -337,6 +338,7 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
is HashtagName -> FeedGroup.HASHTAGS
is CommunityName -> FeedGroup.COMMUNITIES
is PeopleListName -> FeedGroup.LISTS
is RelayName -> FeedGroup.RELAYS
else -> FeedGroup.FEEDS
}
}
@@ -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<Note>) {
@@ -130,6 +135,8 @@ class AccountFeedContentStates(
notificationSummary.invalidateInsertData(newNotes)
drafts.deleteFromFeed(newNotes)
webBookmarks.deleteFromFeed(newNotes)
}
fun destroy() {
@@ -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
}
@@ -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)
}
+18
View File
@@ -1568,6 +1568,7 @@
<string name="feed_group_hashtags">Hashtags</string>
<string name="feed_group_communities">Communities</string>
<string name="feed_group_lists">Lists</string>
<string name="feed_group_relays">Relays</string>
<string name="temporary_account">Log off on device lock</string>
<string name="private_message">Private Message</string>
@@ -1819,6 +1820,7 @@
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Voice Msg</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="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>
@@ -1937,4 +1939,20 @@
<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_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>
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -62,7 +63,7 @@ class DesktopIAccount(
val dmSendTracker: DmSendTracker,
private val scope: CoroutineScope,
) : IAccount {
override val signer: NostrSigner = accountState.signer
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
override val pubKey: String = accountState.pubKeyHex
@@ -221,4 +222,8 @@ class DesktopIAccount(
}
chatroomList.addMessage(roomKey, note)
}
companion object {
const val CLIENT_TAG_NAME = "Amethyst"
}
}
@@ -0,0 +1,97 @@
/*
* 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.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
/**
* A [NostrSigner] decorator that automatically appends a NIP-89 client tag
* to every event before delegating signing to the wrapped [inner] signer.
*
* This ensures all events produced by the application carry the client
* identification tag without requiring each call site to add it manually.
*
* Works with any signer implementation: internal, external (NIP-55/Amber),
* or remote (NIP-46/Bunker).
*/
class NostrSignerWithClientTag(
val inner: NostrSigner,
val clientTag: Array<String>,
) : NostrSigner(inner.pubKey) {
constructor(
inner: NostrSigner,
clientName: String,
) : this(inner, ClientTag.assemble(clientName))
constructor(
inner: NostrSigner,
clientName: String,
addressId: String?,
relayHint: NormalizedRelayUrl?,
) : this(inner, ClientTag.assemble(clientName, addressId, relayHint))
override fun isWriteable(): Boolean = inner.isWriteable()
override suspend fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T = inner.sign(createdAt, kind, appendClientTag(tags), content)
override suspend fun nip04Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String = inner.nip04Encrypt(plaintext, toPublicKey)
override suspend fun nip04Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String = inner.nip04Decrypt(ciphertext, fromPublicKey)
override suspend fun nip44Encrypt(
plaintext: String,
toPublicKey: HexKey,
): String = inner.nip44Encrypt(plaintext, toPublicKey)
override suspend fun nip44Decrypt(
ciphertext: String,
fromPublicKey: HexKey,
): String = inner.nip44Decrypt(ciphertext, fromPublicKey)
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event)
override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce)
override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport()
private fun appendClientTag(tags: Array<Array<String>>): Array<Array<String>> {
// Don't add if a client tag already exists
if (tags.any { it.size >= 2 && it[0] == ClientTag.TAG_NAME }) return tags
return tags + arrayOf(clientTag)
}
}
@@ -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.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
@@ -0,0 +1,117 @@
/*
* 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.nip89AppHandlers.clientTag
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NostrSignerWithClientTagTest {
private val keyPair = KeyPair()
private val innerSigner = NostrSignerInternal(keyPair)
@Test
fun addsClientTagToSignedEvent() =
runTest {
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
val template =
eventTemplate<TextNoteEvent>(
kind = TextNoteEvent.KIND,
description = "Hello Nostr",
)
val event = signer.sign<TextNoteEvent>(template)
assertTrue(event.verify())
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
assertEquals(1, clientTags.size)
assertEquals("Amethyst", clientTags[0][1])
}
@Test
fun doesNotDuplicateExistingClientTag() =
runTest {
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
val template =
eventTemplate<TextNoteEvent>(
kind = TextNoteEvent.KIND,
description = "Hello Nostr",
) {
client("OtherClient")
}
val event = signer.sign<TextNoteEvent>(template)
assertTrue(event.verify())
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
assertEquals(1, clientTags.size)
assertEquals("OtherClient", clientTags[0][1])
}
@Test
fun addsClientTagWithAddressAndRelay() =
runTest {
val signer =
NostrSignerWithClientTag(
innerSigner,
"Amethyst",
"31990:abc123:amethyst",
null,
)
val template =
eventTemplate<TextNoteEvent>(
kind = TextNoteEvent.KIND,
description = "Hello Nostr",
)
val event = signer.sign<TextNoteEvent>(template)
assertTrue(event.verify())
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
assertEquals(1, clientTags.size)
assertEquals("Amethyst", clientTags[0][1])
assertEquals("31990:abc123:amethyst", clientTags[0][2])
}
@Test
fun preservesSamePubKey() {
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
assertEquals(innerSigner.pubKey, signer.pubKey)
}
@Test
fun delegatesIsWriteable() {
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
assertEquals(innerSigner.isWriteable(), signer.isWriteable())
}
}