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/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 754d801de..28e417e46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -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" + } } 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/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index be00e4845..4fe59d6de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -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): Map> { @@ -337,6 +338,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.HASHTAGS is CommunityName -> FeedGroup.COMMUNITIES is PeopleListName -> FeedGroup.LISTS + is RelayName -> FeedGroup.RELAYS else -> FeedGroup.FEEDS } } 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 216f20f77..03022e303 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1568,6 +1568,7 @@ Hashtags Communities Lists + Relays Log off on device lock Private Message @@ -1819,6 +1820,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 @@ -1937,4 +1939,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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 5e3108b2e..6f432f058 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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" + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt new file mode 100644 index 000000000..1aa8b4de6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -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, +) : 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 sign( + createdAt: Long, + kind: Int, + tags: Array>, + 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> { + // 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) + } +} 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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt new file mode 100644 index 000000000..d6d6ed873 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt @@ -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( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) + + val event = signer.sign(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( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) { + client("OtherClient") + } + + val event = signer.sign(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( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) + + val event = signer.sign(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()) + } +}