Merge pull request #2056 from vitorpamplona/claude/migrate-bookmark-event-86Biv

Add support for legacy NIP-51 bookmark list (kind 30001)
This commit is contained in:
Vitor Pamplona
2026-03-31 10:22:24 -04:00
committed by GitHub
29 changed files with 1036 additions and 24 deletions
@@ -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,49 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
suspend fun migrateOldBookmarksToNew() {
if (!isWriteable()) return
val oldList = oldBookmarkState.getBookmarkList() ?: return
val oldPublic = oldList.publicBookmarks()
val oldPrivate = oldList.privateBookmarks(signer) ?: emptyList()
if (oldPublic.isEmpty() && oldPrivate.isEmpty()) return
val existingNewList = bookmarkState.getBookmarkList()
val newEvent =
if (existingNewList != null) {
val existingPublic = existingNewList.publicBookmarks()
val existingPrivate = existingNewList.privateBookmarks(signer) ?: emptyList()
val existingPublicIds = existingPublic.map { it.toTagIdOnly().toList() }.toSet()
val existingPrivateIds = existingPrivate.map { it.toTagIdOnly().toList() }.toSet()
val newPublic = oldPublic.filter { it.toTagIdOnly().toList() !in existingPublicIds }
val newPrivate = oldPrivate.filter { it.toTagIdOnly().toList() !in existingPrivateIds }
if (newPublic.isEmpty() && newPrivate.isEmpty()) return
val mergedPublic = existingPublic + newPublic
val mergedPrivate = existingPrivate + newPrivate
BookmarkListEvent.create(
publicBookmarks = mergedPublic,
privateBookmarks = mergedPrivate,
signer = signer,
)
} else {
BookmarkListEvent.create(
publicBookmarks = oldPublic,
privateBookmarks = oldPrivate,
signer = signer,
)
}
sendMyPublicAndPrivateOutbox(newEvent)
}
suspend fun addPin(note: Note) {
if (!isWriteable() || note.isDraft()) return
@@ -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
@@ -2555,6 +2556,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)
@@ -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
@@ -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,
@@ -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)
@@ -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,
@@ -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)
}
@@ -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<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -65,6 +65,8 @@ sealed class Route {
@Serializable object Bookmarks : Route()
@Serializable object OldBookmarks : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object ImportFollowsSelectUser : Route()
@@ -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) {
@@ -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<List<LabeledBookmarkList>>,
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,
)
}
},
)
}
@@ -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<List<LabeledBookmarkList>>,
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,
@@ -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,
)
}
}
}
}
}
}
@@ -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<Note>() {
override fun feedKey(): String =
account.oldBookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.private
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPrivateFeedViewModel(account) as T
}
}
@@ -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<Note>() {
override fun feedKey(): String =
account.oldBookmarkState.bookmarks.value
.hashCode()
.toString()
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.public
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPublicFeedViewModel(account) as T
}
}
@@ -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<Note> {
val newBookmarks = getBookmarksFromNew()
val oldBookmarks = getBookmarksFromOld()
return (newBookmarks + oldBookmarks).distinctBy { it.idHex }.reversed()
}
private fun getBookmarksFromNew(): List<Note> {
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<Note> {
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<BookmarkIdTag>): List<Note> =
bookmarks.mapNotNull {
when (it) {
is AddressBookmark -> LocalCache.getOrCreateAddressableNote(it.address)
is EventBookmark -> LocalCache.checkGetOrCreateNote(it.eventId)
}
}
}
@@ -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,
@@ -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
+7 -2
View File
@@ -408,8 +408,12 @@
<string name="manual_zaps">Manual Zap Splits</string>
<string name="bookmarks">Bookmarks</string>
<string name="bookmarks_title">Default Bookmarks</string>
<string name="bookmarks_explainer">Your default Bookmarks that many clients support</string>
<string name="bookmarks_title">Bookmarks</string>
<string name="bookmarks_explainer">Your bookmarks (kind 10003)</string>
<string name="old_bookmarks_title">Old Bookmarks</string>
<string name="old_bookmarks_explainer">Your old bookmarks (kind 30001). Migrate them to the new format.</string>
<string name="migrate_bookmarks_button">Move All to New Bookmarks</string>
<string name="migrate_bookmarks_success">Bookmarks migrated successfully</string>
<string name="drafts">Drafts</string>
<string name="polls">Polls</string>
<string name="private_bookmarks">Private Bookmarks</string>
@@ -1742,6 +1746,7 @@
<string name="kind_blossom_auth">Blossom Auth</string>
<string name="kind_broadcast_relays">Broadcast Relays</string>
<string name="kind_bookmark_list">Bookmark List</string>
<string name="kind_old_bookmark_list">Old Bookmark List</string>
<string name="kind_day_appointment">Day Appointment</string>
<string name="kind_calendar">Calendar</string>
<string name="kind_appointment">Appointment</string>
@@ -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<Note> = emptyList(),
val private: List<Note> = emptyList(),
)
val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress())
fun getBookmarkListAddress() = OldBookmarkListEvent.createBookmarkAddress(signer.pubKey)
fun getBookmarkListFlow(): StateFlow<NoteState> = bookmarkList.flow().metadata.stateFlow
fun getBookmarkList(): OldBookmarkListEvent? = bookmarkList.event as? OldBookmarkListEvent
fun publicBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? OldBookmarkListEvent
return noteEvent?.publicBookmarks() ?: emptyList()
}
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? OldBookmarkListEvent
return noteEvent?.privateBookmarks(signer) ?: emptyList()
}
@OptIn(FlowPreview::class)
val publicBookmarks: StateFlow<List<BookmarkIdTag>> =
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<List<BookmarkIdTag>> =
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<BookmarkIdTag>,
publicBookmarks: List<BookmarkIdTag>,
): 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<BookmarkList> =
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)
}
}
@@ -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 -----
/**
@@ -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 =
@@ -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,
@@ -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
}
}
}
@@ -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<Array<String>>,
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<BookmarkIdTag>? = 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<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = 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<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = emptyList(),
signer: NostrSigner,
dTag: String = DEFAULT_D_TAG_BOOKMARKS,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<BookmarkListEvent>.() -> 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)
@@ -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<Array<String>>,
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<BookmarkIdTag> = tags.mapNotNull(BookmarkIdTag::parse)
suspend fun privateBookmarks(signer: NostrSigner): List<BookmarkIdTag>? = 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<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = 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<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = emptyList(),
signer: NostrSigner,
dTag: String = DEFAULT_D_TAG_BOOKMARKS,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<OldBookmarkListEvent>.() -> 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()
}
}
}
@@ -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)