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
This commit is contained in:
Claude
2026-03-31 02:08:54 +00:00
parent 4256e3f3cf
commit b960a4692a
29 changed files with 1013 additions and 24 deletions
@@ -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)
}
}