Merge pull request #1905 from nrobi144/feat/desktop-cache-v2
feat(desktop): cache-centric architecture for desktop feeds
This commit is contained in:
+1
-1
@@ -52,7 +52,7 @@ class ThreadAssembler(
|
||||
?.getOrNull(1)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to see if there is an error in the tag and the root has replies
|
||||
val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
|
||||
val rootNote = cache.getNoteIfExists(markedAsRoot)
|
||||
if (rootNote?.replyTo?.isEmpty() == true) {
|
||||
return cache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
|
||||
Vendored
+4
-4
@@ -57,7 +57,7 @@ interface ICacheProvider {
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User if exists in cache, null otherwise
|
||||
*/
|
||||
fun getUserIfExists(pubkey: HexKey): Any?
|
||||
fun getUserIfExists(pubkey: HexKey): User?
|
||||
|
||||
/**
|
||||
* Counts users matching a predicate.
|
||||
@@ -75,7 +75,7 @@ interface ICacheProvider {
|
||||
* @param hexKey The note's ID in hex format
|
||||
* @return The Note if exists in cache, null otherwise
|
||||
*/
|
||||
fun getNoteIfExists(hexKey: HexKey): Any?
|
||||
fun getNoteIfExists(hexKey: HexKey): Note?
|
||||
|
||||
/**
|
||||
* Gets an existing Note or creates a new one if it doesn't exist.
|
||||
@@ -123,7 +123,7 @@ interface ICacheProvider {
|
||||
fun findUsersStartingWith(
|
||||
prefix: String,
|
||||
limit: Int = 50,
|
||||
): List<Any> = emptyList()
|
||||
): List<User> = emptyList()
|
||||
|
||||
/**
|
||||
* Gets or creates a User by public key hex.
|
||||
@@ -132,7 +132,7 @@ interface ICacheProvider {
|
||||
* @param pubkey The user's public key in hex format
|
||||
* @return The User (existing or newly created)
|
||||
*/
|
||||
fun getOrCreateUser(pubkey: HexKey): Any?
|
||||
fun getOrCreateUser(pubkey: HexKey): User?
|
||||
|
||||
fun justConsumeMyOwnEvent(event: Event): Boolean
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.nip02FollowList
|
||||
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
|
||||
/**
|
||||
* Narrow repository interface for Kind3FollowListState's settings needs.
|
||||
* Follows the established pattern of EphemeralChatRepository / PublicChatListRepository.
|
||||
*/
|
||||
interface Kind3FollowListRepository {
|
||||
val backupContactList: ContactListEvent?
|
||||
|
||||
fun updateContactListTo(event: ContactListEvent)
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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.nip02FollowList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class Kind3FollowListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
val settings: Kind3FollowListRepository,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val note = cache.getOrCreateAddressableNote(getFollowListAddress())
|
||||
|
||||
fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFollowListFlow(): StateFlow<NoteState> = note.flow().metadata.stateFlow
|
||||
|
||||
fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val innerFlow: Flow<Kind3Follows> =
|
||||
getFollowListFlow().transformLatest {
|
||||
emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
|
||||
}
|
||||
|
||||
val flow =
|
||||
innerFlow
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
buildKind3Follows(getFollowListEvent() ?: settings.backupContactList),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for all follows of a user
|
||||
val userList =
|
||||
flow
|
||||
.map { kind3Follows ->
|
||||
kind3Follows.authors.mapNotNull {
|
||||
runCatching { cache.getOrCreateUser(it) }.getOrNull()
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
flow.value.authors.mapNotNull {
|
||||
runCatching { cache.getOrCreateUser(it) }.getOrNull()
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
This contains a big OR of everything the user wants to see in the a single feed.
|
||||
*/
|
||||
@Immutable
|
||||
class Kind3Follows(
|
||||
val authors: Set<HexKey> = emptySet(),
|
||||
val authorsPlusMe: Set<HexKey>,
|
||||
)
|
||||
|
||||
fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows {
|
||||
// makes sure the output include only valid p tags
|
||||
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
|
||||
|
||||
return Kind3Follows(
|
||||
authors = verifiedFollowingUsers,
|
||||
authorsPlusMe = verifiedFollowingUsers + signer.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun follow(users: List<User>): ContactListEvent {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
val contacts =
|
||||
users.map {
|
||||
ContactTag(it.pubkeyHex, it.bestRelayHint(), null)
|
||||
}
|
||||
|
||||
return if (contactList != null) {
|
||||
ContactListEvent.followUsers(contactList, contacts, signer)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = contacts,
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(user: User): ContactListEvent {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null) {
|
||||
ContactListEvent.followUser(contactList, user.pubkeyHex, signer)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(user: User): ContactListEvent? {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
ContactListEvent.unfollowUser(
|
||||
contactList,
|
||||
user.pubkeyHex,
|
||||
signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupContactList?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts")
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
|
||||
getFollowListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
|
||||
(it.note.event as? ContactListEvent)?.let {
|
||||
settings.updateContactListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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.BookmarkListEvent
|
||||
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 BookmarkListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
class BookmarkList(
|
||||
val public: List<Note> = emptyList(),
|
||||
val private: List<Note> = emptyList(),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress())
|
||||
|
||||
fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey)
|
||||
|
||||
fun getBookmarkListFlow(): StateFlow<NoteState> = bookmarkList.flow().metadata.stateFlow
|
||||
|
||||
fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent
|
||||
|
||||
fun publicBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
return noteEvent?.publicBookmarks() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
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)
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList == null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent? {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList != null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(note: Note): BookmarkListEvent? {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList != null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Narrow repository interface for Nip65RelayListState's settings needs.
|
||||
* Follows the established pattern of EphemeralChatRepository / PublicChatListRepository.
|
||||
*/
|
||||
interface Nip65RelayListRepository {
|
||||
val backupNIP65RelayList: AdvertisedRelayListEvent?
|
||||
|
||||
fun updateNIP65RelayList(event: AdvertisedRelayListEvent)
|
||||
|
||||
/** Default relay set when no NIP-65 list is available (write relays). */
|
||||
val defaultOutboxRelays: Set<NormalizedRelayUrl>
|
||||
|
||||
/** Default relay set when no NIP-65 list is available (read relays). */
|
||||
val defaultInboxRelays: Set<NormalizedRelayUrl>
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.nip65RelayList
|
||||
|
||||
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.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class Nip65RelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
val settings: Nip65RelayListRepository,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val nip65ListNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress())
|
||||
|
||||
fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getNIP65RelayListFlow(): StateFlow<NoteState> = nip65ListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getNIP65RelayList(): AdvertisedRelayListEvent? = nip65ListNote.event as? AdvertisedRelayListEvent
|
||||
|
||||
fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
|
||||
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: settings.defaultOutboxRelays
|
||||
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: settings.defaultInboxRelays
|
||||
|
||||
fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
fun normalizeNIP65ReadRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: settings.defaultOutboxRelays
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet()
|
||||
|
||||
val outboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeNIP65WriteRelayListWithBackup(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val inboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65ReadRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeNIP65ReadRelayListWithBackup(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val outboxFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65WriteRelayListNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val inboxFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65ReadRelayListNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65ReadRelayListNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val allFlowNoDefaults =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65AllRelayListWithBackupNoDefaults(it.note) }
|
||||
.onStart { emit(normalizeNIP65AllRelayListWithBackupNoDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(relays: List<AdvertisedRelayInfo>): AdvertisedRelayListEvent {
|
||||
val nip65RelayList = getNIP65RelayList()
|
||||
|
||||
return if (nip65RelayList != null) {
|
||||
AdvertisedRelayListEvent.replaceRelayListWith(
|
||||
earlierVersion = nip65RelayList,
|
||||
newRelays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
AdvertisedRelayListEvent.createFromScratch(
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupNIP65RelayList?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start")
|
||||
getNIP65RelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}")
|
||||
(it.note.event as? AdvertisedRelayListEvent)?.let {
|
||||
settings.updateNIP65RelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
@@ -95,7 +94,7 @@ class ChatNewMessageState(
|
||||
if (currentRoom != null) {
|
||||
_recipientsMissingDmRelays.value =
|
||||
currentRoom.users.any { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey) as? User
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelays().isNullOrEmpty()
|
||||
}
|
||||
} else {
|
||||
@@ -141,7 +140,7 @@ class ChatNewMessageState(
|
||||
) {
|
||||
val pTags =
|
||||
room.users.mapNotNull { hexKey ->
|
||||
(cache.getOrCreateUser(hexKey) as? User)?.toPTag()
|
||||
cache.getOrCreateUser(hexKey)?.toPTag()
|
||||
}
|
||||
|
||||
val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>()
|
||||
|
||||
+1
-2
@@ -88,8 +88,7 @@ class SearchBarState(
|
||||
.debounce(debounceMs)
|
||||
.onEach { query ->
|
||||
if (query.length >= 2 && _bech32Results.value.isEmpty()) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
_cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List<User>
|
||||
_cachedUserResults.value = cache.findUsersStartingWith(query, 20)
|
||||
} else {
|
||||
_cachedUserResults.value = emptyList()
|
||||
}
|
||||
|
||||
Vendored
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.cache
|
||||
|
||||
import com.vitorpamplona.quartz.utils.cache.CacheOperations
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ConcurrentSkipListMap
|
||||
import java.util.function.BiConsumer
|
||||
|
||||
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
|
||||
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
|
||||
|
||||
fun keys() = cache.keys
|
||||
|
||||
fun get(key: K): V? {
|
||||
val softRef = cache.get(key) ?: return null
|
||||
val value = softRef.get()
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
cache.remove(key, softRef)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: K) = cache.remove(key)
|
||||
|
||||
fun removeIf(
|
||||
key: K,
|
||||
value: WeakReference<V>,
|
||||
) = cache.remove(key, value)
|
||||
|
||||
override fun size() = cache.size
|
||||
|
||||
fun isEmpty() = cache.isEmpty()
|
||||
|
||||
fun clear() = cache.clear()
|
||||
|
||||
fun containsKey(key: K) = cache.containsKey(key)
|
||||
|
||||
/**
|
||||
* Puts an object into the cache with a specified key.
|
||||
* The object is stored as a WeakReference.
|
||||
*
|
||||
* @param key The key to associate with the object.
|
||||
* @param value The object to cache.
|
||||
*/
|
||||
fun put(
|
||||
key: K,
|
||||
value: V,
|
||||
) {
|
||||
cache.put(key, WeakReference(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an object from the cache using its key.
|
||||
* Returns the object if it's still available (not garbage collected),
|
||||
* otherwise returns null. If the object has been garbage collected,
|
||||
* its entry is also removed from the cache.
|
||||
*
|
||||
* @param key The key of the object to retrieve.
|
||||
* @return The cached object, or null if it's no longer available.
|
||||
*/
|
||||
fun getOrCreate(
|
||||
key: K,
|
||||
builder: (key: K) -> V,
|
||||
): V {
|
||||
val softRef = cache[key]
|
||||
|
||||
return if (softRef == null) {
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
} else {
|
||||
val value = softRef.get()
|
||||
if (value != null) {
|
||||
value
|
||||
} else {
|
||||
// removes first to make sure the putIfAbsent works.
|
||||
// another thread may put in between
|
||||
cache.remove(key, softRef)
|
||||
val newObject = builder(key)
|
||||
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively cleans up the cache by removing entries whose weakly referenced
|
||||
* objects have been garbage collected. Single-pass iterator for efficiency.
|
||||
*/
|
||||
fun cleanUp() {
|
||||
val iter = cache.entries.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val entry = iter.next()
|
||||
if (entry.value.get() == null) {
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun forEach(consumer: BiConsumer<K, V>) {
|
||||
cache.forEach(BiConsumerWrapper(this, consumer))
|
||||
}
|
||||
|
||||
override fun forEach(
|
||||
from: K,
|
||||
to: K,
|
||||
consumer: BiConsumer<K, V>,
|
||||
) {
|
||||
cache
|
||||
.subMap(from, true, to, true)
|
||||
.forEach(BiConsumerWrapper(this, consumer))
|
||||
}
|
||||
|
||||
class BiConsumerWrapper<K : Any, V : Any>(
|
||||
val cache: LargeSoftCache<K, V>,
|
||||
val inner: BiConsumer<K, V>,
|
||||
) : BiConsumer<K, WeakReference<V>> {
|
||||
override fun accept(
|
||||
k: K,
|
||||
ref: WeakReference<V>,
|
||||
) {
|
||||
val value = ref.get()
|
||||
if (value == null) {
|
||||
cache.removeIf(k, ref)
|
||||
} else {
|
||||
inner.accept(k, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user