From b960a4692a76c747ebee9a3dc9af0430786d8b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 02:08:54 +0000 Subject: [PATCH] feat: migrate BookmarkListEvent from kind 30001 to 10003 Rename the existing BookmarkListEvent (kind 30001) to OldBookmarkListEvent and create a new BookmarkListEvent with kind 10003 as per the updated spec. - OldBookmarkListEvent (kind 30001): Kept as-is for backward compatibility, displayed as "Old Bookmarks" in the Bookmark Lists screen - BookmarkListEvent (kind 10003): New replaceable event, all new bookmark operations (save/check/remove) use this kind - Both kinds are displayed as separate items in the Bookmark Lists screen - Old Bookmarks screen includes a "Move All to New Bookmarks" button that migrates public bookmarks to public and private to private - Created PrivateReplaceableTagArrayEvent base class for replaceable events with encrypted private tags (kind 10003 is in the 10000-19999 range) - Updated all relay filters, search queries, and profile views to fetch both kinds - Updated Android and Desktop modules https://claude.ai/code/session_01U9sjQHQMVVHxYiesoXjqop --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++ .../amethyst/model/LocalCache.kt | 3 + .../model/nip51Lists/BookmarkListState.kt | 2 + .../FilterBookmarksAndReportsFromKey.kt | 2 + .../reqCommand/user/UserObservers.kt | 25 +- .../subassemblies/SearchPostsByText.kt | 2 + .../amethyst/ui/broadcast/BroadcastBanner.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 3 + .../list/ListOfBookmarkGroupsFeedView.kt | 52 ++++ .../list/ListOfBookmarkGroupsScreen.kt | 7 + .../old/OldBookmarkListScreen.kt | 176 ++++++++++++++ .../old/dal/OldBookmarkPrivateFeedFilter.kt | 36 +++ .../dal/OldBookmarkPrivateFeedViewModel.kt | 39 +++ .../old/dal/OldBookmarkPublicFeedFilter.kt | 36 +++ .../old/dal/OldBookmarkPublicFeedViewModel.kt | 39 +++ .../dal/UserProfileBookmarksFeedFilter.kt | 34 ++- .../datasource/FilterUserProfileLists.kt | 2 + .../loggedIn/relays/RelayInformationScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 9 +- .../model/nip51Lists/OldBookmarkListState.kt | 212 ++++++++++++++++ .../desktop/cache/DesktopLocalCache.kt | 18 ++ .../amethyst/desktop/model/DesktopIAccount.kt | 2 + .../subscriptions/SearchFilterFactory.kt | 2 + .../PrivateReplaceableTagArrayEvent.kt | 62 +++++ .../bookmarkList/BookmarkListEvent.kt | 15 +- .../bookmarkList/OldBookmarkListEvent.kt | 227 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 29 files changed, 1013 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedViewModel.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateReplaceableTagArrayEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7fa36f404..19030c9a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.PinListState import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache @@ -176,6 +177,7 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -320,6 +322,7 @@ class Account( val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) + val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) @@ -1815,6 +1818,25 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun migrateOldBookmarksToNew() { + if (!isWriteable()) return + + val oldList = oldBookmarkState.getBookmarkList() ?: return + val publicBookmarks = oldList.publicBookmarks() + val privateBookmarks = oldList.privateBookmarks(signer) ?: emptyList() + + if (publicBookmarks.isEmpty() && privateBookmarks.isEmpty()) return + + val newEvent = + BookmarkListEvent.create( + publicBookmarks = publicBookmarks, + privateBookmarks = privateBookmarks, + signer = signer, + ) + + sendMyPublicAndPrivateOutbox(newEvent) + } + suspend fun addPin(note: Note) { if (!isWriteable() || note.isDraft()) return 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 164f26858..fa616af96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -144,6 +144,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -580,6 +581,7 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consumeRegularEvent( event: Event, relay: NormalizedRelayUrl?, @@ -2555,6 +2557,7 @@ object LocalCache : ILocalCache, ICacheProvider { is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified) is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is OldBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index db9004b75..fb13e4d52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -21,3 +21,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState + +typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 970d910e0..1dbe220f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -34,6 +35,7 @@ val ReportsAndBookmarksFromKeyKinds = listOf( ReportEvent.KIND, BookmarkListEvent.KIND, + OldBookmarkListEvent.KIND, LabeledBookmarkListEvent.KIND, PinListEvent.KIND, RequestToVanishEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index d656913e7..30db8918c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -261,7 +262,7 @@ fun observeUserBookmarkCount( UserFinderFilterAssemblerSubscription(user, accountViewModel) // Subscribe in the LocalCache for changes that arrive in the device - val flow = + val newFlow = remember(user) { accountViewModel .bookmarks(user) @@ -274,7 +275,27 @@ fun observeUserBookmarkCount( .flowOn(Dispatchers.IO) } - return flow.collectAsStateWithLifecycle(0) + val oldFlow = + remember(user) { + accountViewModel + .oldBookmarks(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? OldBookmarkListEvent)?.countBookmarks() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + val combined = + remember(user) { + kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount -> + newCount + oldCount + } + } + + return combined.collectAsStateWithLifecycle(0) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index aeab21201..ee6d0605a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -61,6 +62,7 @@ val SearchPostsByTextKinds1 = BadgeDefinitionEvent.KIND, PeopleListEvent.KIND, BookmarkListEvent.KIND, + OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, PinListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 29508f69c..bd221606f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -439,6 +440,7 @@ fun Event.toKindName(): String = is VoiceEvent -> stringRes(R.string.voice_post) is VoiceReplyEvent -> stringRes(R.string.voice_reply) is BookmarkListEvent -> stringRes(R.string.bookmarks) + is OldBookmarkListEvent -> stringRes(R.string.bookmarks) else -> stringRes(R.string.post) } 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 8df8f6c71..fd567fa58 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfB import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen @@ -210,6 +211,7 @@ fun BuildNavigation( composableFromEnd { NamecoinSettingsScreen(nav) } composableFromEnd { OtsSettingsScreen(nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } 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 b7e953bc5..839b52984 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 @@ -65,6 +65,8 @@ sealed class Route { @Serializable object Bookmarks : Route() + @Serializable object OldBookmarks : Route() + @Serializable object BookmarkGroups : Route() @Serializable object ImportFollowsSelectUser : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index bca58da33..029e535c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -850,6 +851,8 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun oldBookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(OldBookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) fun addPin(note: Note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index e4d331cd6..2db79c105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes @@ -55,8 +56,10 @@ import kotlinx.coroutines.flow.StateFlow @Composable fun ListOfBookmarkGroupsFeedView( defaultBookmarks: BookmarkListState, + oldBookmarks: OldBookmarkListState, groupListFeedSource: StateFlow>, openDefaultBookmarks: () -> Unit, + openOldBookmarks: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -74,6 +77,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + OldBookmarkList(oldBookmarks, openOldBookmarks) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -135,3 +143,47 @@ fun DefaultBookmarkList( }, ) } + +@Composable +fun OldBookmarkList( + oldBookmarks: OldBookmarkListState, + openOldBookmarks: () -> Unit, +) { + val bookmarkState by oldBookmarks.bookmarks.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openOldBookmarks), + headlineContent = { + Text(stringRes(R.string.old_bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.old_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.BookmarkBorder, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = bookmarkState.public.size + bookmarkState.private.size, + articleBookmarksSize = 0, + ) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 51de36eba..d4d4a67fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -51,8 +52,10 @@ fun ListOfBookmarkGroupsScreen( ) { ListOfBookmarkGroupsFeed( defaultBookmarks = accountViewModel.account.bookmarkState, + oldBookmarks = accountViewModel.account.oldBookmarkState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, + openOldBookmarks = { nav.nav(Route.OldBookmarks) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -88,8 +91,10 @@ fun ListOfBookmarkGroupsScreen( @Composable fun ListOfBookmarkGroupsFeed( defaultBookmarks: BookmarkListState, + oldBookmarks: OldBookmarkListState, listSource: StateFlow>, openDefaultBookmarks: () -> Unit, + openOldBookmarks: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -115,8 +120,10 @@ fun ListOfBookmarkGroupsFeed( ) { ListOfBookmarkGroupsFeedView( defaultBookmarks = defaultBookmarks, + oldBookmarks = oldBookmarks, groupListFeedSource = listSource, openDefaultBookmarks = openDefaultBookmarks, + openOldBookmarks = openOldBookmarks, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt new file mode 100644 index 000000000..41438b91c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt @@ -0,0 +1,176 @@ +/* + * 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.bookmarkgroups.old + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.DriveFileMove +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import kotlinx.coroutines.launch + +@Composable +fun OldBookmarkListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val publicFeedViewModel: OldBookmarkPublicFeedViewModel = + viewModel( + key = "NostrOldBookmarkPublicFeedViewModel", + factory = OldBookmarkPublicFeedViewModel.Factory(accountViewModel.account), + ) + + val privateFeedViewModel: OldBookmarkPrivateFeedViewModel = + viewModel( + key = "NostrOldBookmarkPrivateFeedViewModel", + factory = OldBookmarkPrivateFeedViewModel.Factory(accountViewModel.account), + ) + + val bookmarkState by accountViewModel.account.oldBookmarkState.bookmarks + .collectAsStateWithLifecycle(null) + + LaunchedEffect(bookmarkState) { + publicFeedViewModel.invalidateData() + privateFeedViewModel.invalidateData() + } + + RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun RenderOldBookmarkScreen( + publicFeedViewModel: OldBookmarkPublicFeedViewModel, + privateFeedViewModel: OldBookmarkPrivateFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pagerState = rememberPagerState { 2 } + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + Column { + TopBarWithBackButton(stringRes(id = R.string.old_bookmarks_title), nav::popBack) + SecondaryScrollableTabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, + modifier = TabRowHeight, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringRes(R.string.private_bookmarks)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringRes(R.string.public_bookmarks)) }, + ) + } + } + }, + floatingButton = { + ExtendedFloatingActionButton( + text = { Text(stringRes(R.string.migrate_bookmarks_button)) }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.DriveFileMove, + contentDescription = stringRes(R.string.migrate_bookmarks_button), + ) + }, + onClick = { + accountViewModel.launchSigner { + accountViewModel.account.migrateOldBookmarksToNew() + coroutineScope.launch { + Toast + .makeText( + context, + context.getString(R.string.migrate_bookmarks_success), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + containerColor = MaterialTheme.colorScheme.primary, + ) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it).fillMaxHeight()) { + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> { + RefresheableFeedView( + privateFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + 1 -> { + RefresheableFeedView( + publicFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt new file mode 100644 index 000000000..d609c8732 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedFilter.kt @@ -0,0 +1,36 @@ +/* + * 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.bookmarkgroups.old.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class OldBookmarkPrivateFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.oldBookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List = account.oldBookmarkState.bookmarks.value.private +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedViewModel.kt new file mode 100644 index 000000000..8372ccc22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPrivateFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.bookmarkgroups.old.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class OldBookmarkPrivateFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(OldBookmarkPrivateFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = OldBookmarkPrivateFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt new file mode 100644 index 000000000..9f1ed745a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedFilter.kt @@ -0,0 +1,36 @@ +/* + * 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.bookmarkgroups.old.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class OldBookmarkPublicFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.oldBookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List = account.oldBookmarkState.bookmarks.value.public +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedViewModel.kt new file mode 100644 index 000000000..e02d8c15f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/dal/OldBookmarkPublicFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.bookmarkgroups.old.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class OldBookmarkPublicFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(OldBookmarkPublicFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = OldBookmarkPublicFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index 53fd1cf33..7ec40174e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark class UserProfileBookmarksFeedFilter( @@ -36,17 +38,29 @@ class UserProfileBookmarksFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex override fun feed(): List { + val newBookmarks = getBookmarksFromNew() + val oldBookmarks = getBookmarksFromOld() + + return (newBookmarks + oldBookmarks).distinctBy { it.idHex }.reversed() + } + + private fun getBookmarksFromNew(): List { val note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) val noteEvent = note.event as? BookmarkListEvent ?: return emptyList() - - val notes = - noteEvent.publicBookmarks().mapNotNull { - when (it) { - is AddressBookmark -> LocalCache.getOrCreateAddressableNote(it.address) - is EventBookmark -> LocalCache.checkGetOrCreateNote(it.eventId) - } - } - - return notes.reversed() + return resolveBookmarks(noteEvent.publicBookmarks()) } + + private fun getBookmarksFromOld(): List { + val note = LocalCache.getOrCreateAddressableNote(OldBookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + val noteEvent = note.event as? OldBookmarkListEvent ?: return emptyList() + return resolveBookmarks(noteEvent.publicBookmarks()) + } + + private fun resolveBookmarks(bookmarks: List): List = + bookmarks.mapNotNull { + when (it) { + is AddressBookmark -> LocalCache.getOrCreateAddressableNote(it.address) + is EventBookmark -> LocalCache.checkGetOrCreateNote(it.eventId) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 7783b9de9..9e078f7e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent @@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendatio val UserProfileListKinds = listOf( BookmarkListEvent.KIND, + OldBookmarkListEvent.KIND, PinListEvent.KIND, PeopleListEvent.KIND, FollowListEvent.KIND, 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 000fc6b54..f55fae3d9 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 @@ -205,6 +205,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -569,6 +570,7 @@ fun kindDisplayName(kind: Int): Int = BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays BookmarkListEvent.KIND -> R.string.kind_bookmark_list + OldBookmarkListEvent.KIND -> R.string.kind_old_bookmark_list CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment CalendarEvent.KIND -> R.string.kind_calendar CalendarTimeSlotEvent.KIND -> R.string.kind_appointment diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 26c6c2eb7..3e1b2c8db 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -408,8 +408,12 @@ Manual Zap Splits Bookmarks - Default Bookmarks - Your default Bookmarks that many clients support + Bookmarks + Your bookmarks (kind 10003) + Old Bookmarks + Your old bookmarks (kind 30001). Migrate them to the new format. + Move All to New Bookmarks + Bookmarks migrated successfully Drafts Polls Private Bookmarks @@ -1742,6 +1746,7 @@ Blossom Auth Broadcast Relays Bookmark List + Old Bookmark List Day Appointment Calendar Appointment diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt new file mode 100644 index 000000000..b72c57a26 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt @@ -0,0 +1,212 @@ +/* + * 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.commons.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class OldBookmarkListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + class BookmarkList( + val public: List = emptyList(), + val private: List = emptyList(), + ) + + val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress()) + + fun getBookmarkListAddress() = OldBookmarkListEvent.createBookmarkAddress(signer.pubKey) + + fun getBookmarkListFlow(): StateFlow = bookmarkList.flow().metadata.stateFlow + + fun getBookmarkList(): OldBookmarkListEvent? = bookmarkList.event as? OldBookmarkListEvent + + fun publicBookmarks(note: Note): List { + val noteEvent = note.event as? OldBookmarkListEvent + return noteEvent?.publicBookmarks() ?: emptyList() + } + + suspend fun privateBookmarks(note: Note): List { + val noteEvent = note.event as? OldBookmarkListEvent + return noteEvent?.privateBookmarks(signer) ?: emptyList() + } + + @OptIn(FlowPreview::class) + val publicBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + publicBookmarks(noteState.note) + }.onStart { + emit(publicBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + @OptIn(FlowPreview::class) + val privateBookmarks: StateFlow> = + getBookmarkListFlow() + .map { noteState -> + privateBookmarks(noteState.note) + }.onStart { + emit(privateBookmarks(bookmarkList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkEventIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val publicBookmarkAddressIdSet = + publicBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkEventIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is EventBookmark) it.eventId else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val privateBookmarkAddressIdSet = + privateBookmarks + .map { bookmark -> + bookmark + .mapNotNull { + if (it is AddressBookmark) it.address else null + }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun bookmarkList( + privateBookmarks: List, + publicBookmarks: List, + ): BookmarkList = + BookmarkList( + public = + publicBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + private = + privateBookmarks + .mapNotNull { + when (it) { + is EventBookmark -> cache.checkGetOrCreateNote(it.eventId) + is AddressBookmark -> cache.getOrCreateAddressableNote(it.address) + } + }.reversed(), + ) + + @OptIn(FlowPreview::class) + val bookmarks: StateFlow = + combineTransform(privateBookmarks, publicBookmarks) { private, public -> + emit(bookmarkList(private, public)) + }.onStart { + emit(bookmarkList(privateBookmarks.value, publicBookmarks.value)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + BookmarkList(), + ) + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!signer.isWriteable()) return false + + return if (note is AddressableNote) { + privateBookmarkAddressIdSet.value.contains(note.address) + } else { + privateBookmarkEventIdSet.value.contains(note.idHex) + } + } + + fun isInPublicBookmarks(note: Note): Boolean = + if (note is AddressableNote) { + publicBookmarkAddressIdSet.value.contains(note.address) + } else { + publicBookmarkEventIdSet.value.contains(note.idHex) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 6fbf9c266..e69a55272 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.DualCase @@ -213,6 +214,10 @@ class DesktopLocalCache : ICacheProvider { consumeBookmarkList(event) } + is OldBookmarkListEvent -> { + consumeOldBookmarkList(event) + } + is CommentEvent -> { consumeComment(event, relay) } @@ -419,6 +424,19 @@ class DesktopLocalCache : ICacheProvider { return true } + private fun consumeOldBookmarkList(event: OldBookmarkListEvent): Boolean { + val address = event.address() + val addressableNote = getOrCreateAddressableNote(address) + val author = getOrCreateUser(event.pubKey) + + // Only update if newer + val existingEvent = addressableNote.event + if (existingEvent != null && existingEvent.createdAt >= event.createdAt) return false + + addressableNote.loadEvent(event, author, emptyList()) + return true + } + // ----- NWC Payment operations ----- /** 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 9efb98238..3606c09ce 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList @@ -76,6 +77,7 @@ class DesktopIAccount( // ----- State Classes (pin important notes via strong refs for GC retention) ----- + val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) val bookmarkState = BookmarkListState(signer, localCache, scope) val kind3FollowList = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt index cfe8b7e01..cd232ed78 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -58,6 +59,7 @@ object SearchFilterFactory { BadgeDefinitionEvent.KIND, PeopleListEvent.KIND, BookmarkListEvent.KIND, + OldBookmarkListEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, PinListEvent.KIND, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateReplaceableTagArrayEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateReplaceableTagArrayEvent.kt new file mode 100644 index 000000000..67f348e0c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateReplaceableTagArrayEvent.kt @@ -0,0 +1,62 @@ +/* + * 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.nip51Lists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import kotlinx.coroutines.CancellationException + +@Immutable +abstract class PrivateReplaceableTagArrayEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: TagArray, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig) { + override fun isContentEncoded() = true + + suspend fun decrypt(signer: NostrSigner): TagArray { + if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException() + + return PrivateTagsInContent.decrypt(content, signer) + } + + suspend fun privateTags(signer: NostrSigner): TagArray? { + if (signer.pubKey != pubKey) { + return null + } + + return try { + PrivateTagsInContent.decrypt(content, signer) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt index f9e43e2df..502511735 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -31,10 +31,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -51,7 +50,7 @@ class BookmarkListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : PrivateReplaceableTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider { override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) @@ -71,11 +70,10 @@ class BookmarkListEvent( suspend fun privateBookmarks(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse) companion object { - const val KIND = 30001 + const val KIND = 10003 const val ALT = "List of bookmarks" - const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" - fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, DEFAULT_D_TAG_BOOKMARKS) + fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, "") suspend fun create( bookmarkIdTag: BookmarkIdTag, @@ -195,11 +193,10 @@ class BookmarkListEvent( title: String = "", publicBookmarks: List = emptyList(), privateBookmarks: List = emptyList(), - dTag: String = DEFAULT_D_TAG_BOOKMARKS, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): BookmarkListEvent { - val template = build(title, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + val template = build(title, publicBookmarks, privateBookmarks, signer, createdAt) return signer.sign(template) } @@ -208,7 +205,6 @@ class BookmarkListEvent( publicBookmarks: List = emptyList(), privateBookmarks: List = emptyList(), signer: NostrSigner, - dTag: String = DEFAULT_D_TAG_BOOKMARKS, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( @@ -216,7 +212,6 @@ class BookmarkListEvent( description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), createdAt = createdAt, ) { - dTag(dTag) alt(ALT) title(title) bookmarks(publicBookmarks) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt new file mode 100644 index 000000000..4bc1c7719 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/OldBookmarkListEvent.kt @@ -0,0 +1,227 @@ +/* + * 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.nip51Lists.bookmarkList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +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.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class OldBookmarkListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId) + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun countBookmarks() = tags.count(BookmarkIdTag::isTagged) + + fun publicBookmarks(): List = tags.mapNotNull(BookmarkIdTag::parse) + + suspend fun privateBookmarks(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse) + + companion object { + const val KIND = 30001 + const val ALT = "List of bookmarks" + const val DEFAULT_D_TAG_BOOKMARKS = "bookmark" + + fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, DEFAULT_D_TAG_BOOKMARKS) + + suspend fun create( + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent = + if (isPrivate) { + create( + publicBookmarks = emptyList(), + privateBookmarks = listOf(bookmarkIdTag), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicBookmarks = listOf(bookmarkIdTag), + privateBookmarks = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: OldBookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: OldBookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = + earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: OldBookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + title: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OldBookmarkListEvent { + val template = build(title, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + return signer.sign(template) + } + + suspend fun build( + title: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + signer: NostrSigner, + dTag: String = DEFAULT_D_TAG_BOOKMARKS, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + addUnique(TitleTag.assemble(title)) + addAll(publicBookmarks.map { it.toTagArray() }) + + 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 27173ee76..6c77feac1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -288,6 +289,7 @@ class EventFactory { BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + OldBookmarkListEvent.KIND -> OldBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)