mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #1905 from nrobi144/feat/desktop-cache-v2
feat(desktop): cache-centric architecture for desktop feeds
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.cache.CacheCollectors
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
|
||||
+1
-279
@@ -20,282 +20,4 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
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: LocalCache,
|
||||
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 it self
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import junit.framework.TestCase.assertEquals
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
+8
-11
@@ -18,7 +18,7 @@
|
||||
* 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.model
|
||||
package com.vitorpamplona.amethyst.commons.model.cache
|
||||
|
||||
import com.vitorpamplona.quartz.utils.cache.CacheOperations
|
||||
import java.lang.ref.WeakReference
|
||||
@@ -59,7 +59,7 @@ class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
|
||||
|
||||
/**
|
||||
* Puts an object into the cache with a specified key.
|
||||
* The object is stored as a SoftReference.
|
||||
* The object is stored as a WeakReference.
|
||||
*
|
||||
* @param key The key to associate with the object.
|
||||
* @param value The object to cache.
|
||||
@@ -105,19 +105,16 @@ class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
|
||||
|
||||
/**
|
||||
* Proactively cleans up the cache by removing entries whose weakly referenced
|
||||
* objects have been garbage collected. While `get` handles cleanup on access,
|
||||
* this method can be called periodically or when memory pressure is high.
|
||||
* objects have been garbage collected. Single-pass iterator for efficiency.
|
||||
*/
|
||||
fun cleanUp() {
|
||||
val keysToRemove = mutableMapOf<K, WeakReference<V>>()
|
||||
cache.forEach { key, softRef ->
|
||||
if (softRef.get() == null) {
|
||||
keysToRemove.put(key, softRef)
|
||||
val iter = cache.entries.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val entry = iter.next()
|
||||
if (entry.value.get() == null) {
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach { key, value ->
|
||||
cache.remove(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun forEach(consumer: BiConsumer<K, V>) {
|
||||
@@ -80,6 +80,8 @@ compose.desktop {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
|
||||
|
||||
jvmArgs += "-Xmx2g"
|
||||
|
||||
nativeDistributions {
|
||||
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
|
||||
@@ -457,9 +457,17 @@ fun App(
|
||||
RelayUrlNormalizer.normalizeOrNull(it)
|
||||
}.toSet(),
|
||||
localCache = localCache,
|
||||
)
|
||||
).also { it.startCleanupLoop() }
|
||||
}
|
||||
|
||||
// Clear cache and subscriptions on logout
|
||||
LaunchedEffect(accountState) {
|
||||
if (accountState is AccountState.LoggedOut) {
|
||||
subscriptionsCoordinator.clear()
|
||||
localCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load saved account on startup
|
||||
DisposableEffect(Unit) {
|
||||
relayManager.addDefaultRelays()
|
||||
@@ -728,6 +736,7 @@ fun MainContent(
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
@@ -744,6 +753,7 @@ fun MainContent(
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
lastRelayEventAt = lastRelayEvent,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+297
-19
@@ -26,20 +26,34 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
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.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -50,28 +64,34 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* Supports searching users by name prefix for the search functionality.
|
||||
*/
|
||||
class DesktopLocalCache : ICacheProvider {
|
||||
private val users = ConcurrentHashMap<HexKey, User>()
|
||||
private val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
private val addressableNotes = ConcurrentHashMap<String, AddressableNote>()
|
||||
val users = LargeSoftCache<HexKey, User>()
|
||||
val notes = LargeSoftCache<HexKey, Note>()
|
||||
val addressableNotes = LargeSoftCache<String, AddressableNote>()
|
||||
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
|
||||
|
||||
private val eventStream = DesktopCacheEventStream()
|
||||
val eventStream = DesktopCacheEventStream()
|
||||
|
||||
/** Cached follow set for the logged-in user. Thread-safe + Compose-observable. */
|
||||
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
|
||||
|
||||
companion object {
|
||||
}
|
||||
|
||||
val paymentTracker = NwcPaymentTracker()
|
||||
|
||||
// ----- User operations -----
|
||||
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = users.get(pubkey)
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User =
|
||||
users.getOrPut(pubkey) {
|
||||
// Create placeholder notes for relay lists
|
||||
users.getOrCreate(pubkey) {
|
||||
val nip65Note = getOrCreateNote("nip65:$pubkey")
|
||||
val dmNote = getOrCreateNote("dm:$pubkey")
|
||||
User(pubkey, nip65Note, dmNote)
|
||||
}
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) }
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = users.count { key, user -> predicate(key, user) }
|
||||
|
||||
override fun findUsersStartingWith(
|
||||
prefix: String,
|
||||
@@ -92,9 +112,10 @@ class DesktopLocalCache : ICacheProvider {
|
||||
)
|
||||
|
||||
// Search by name/displayName/nip05/lud16
|
||||
return users.values
|
||||
.filter { user ->
|
||||
val metadata = user.metadataOrNull()
|
||||
val results = mutableListOf<User>()
|
||||
users.forEach { _, user ->
|
||||
val metadata = user.metadataOrNull()
|
||||
val matches =
|
||||
if (metadata == null) {
|
||||
user.pubkeyHex.startsWith(prefix, true) ||
|
||||
user.pubkeyNpub().startsWith(prefix, true)
|
||||
@@ -103,7 +124,10 @@ class DesktopLocalCache : ICacheProvider {
|
||||
user.pubkeyHex.startsWith(prefix, true) ||
|
||||
user.pubkeyNpub().startsWith(prefix, true)
|
||||
}
|
||||
}.sortedWith(
|
||||
if (matches) results.add(user)
|
||||
}
|
||||
return results
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.metadataOrNull()?.anyNameStartsWith(dualCase) == false },
|
||||
{ it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false },
|
||||
@@ -128,6 +152,216 @@ class DesktopLocalCache : ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Event consumption -----
|
||||
|
||||
/**
|
||||
* Routes an event to the appropriate consume method.
|
||||
* Returns true if the event was consumed (new), false if already seen.
|
||||
*/
|
||||
fun consume(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean =
|
||||
when (event) {
|
||||
is MetadataEvent -> {
|
||||
consumeMetadata(event)
|
||||
true
|
||||
}
|
||||
|
||||
is TextNoteEvent -> {
|
||||
consumeTextNote(event, relay)
|
||||
}
|
||||
|
||||
is ReactionEvent -> {
|
||||
consumeReaction(event, relay)
|
||||
}
|
||||
|
||||
is LnZapRequestEvent -> {
|
||||
consumeZapRequest(event, relay)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
consumeZap(event, relay)
|
||||
}
|
||||
|
||||
is RepostEvent -> {
|
||||
consumeRepost(event, relay)
|
||||
}
|
||||
|
||||
is ContactListEvent -> {
|
||||
consumeContactList(event)
|
||||
}
|
||||
|
||||
is LongTextNoteEvent -> {
|
||||
consumeLongTextNote(event, relay)
|
||||
}
|
||||
|
||||
is BookmarkListEvent -> {
|
||||
consumeBookmarkList(event)
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 1 text note event.
|
||||
* Creates/updates Note in cache and links reply relationships.
|
||||
*/
|
||||
private fun consumeTextNote(
|
||||
event: TextNoteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) }
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
relay?.let { note.addRelay(it) }
|
||||
repliesTo.forEach { it.addReply(note) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 7 reaction event.
|
||||
* Links reaction to target notes via e-tags and a-tags.
|
||||
*/
|
||||
private fun consumeReaction(
|
||||
event: ReactionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val reactedTo =
|
||||
event.originalPost().mapNotNull { getNoteIfExists(it) } +
|
||||
event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) }
|
||||
note.loadEvent(event, author, reactedTo)
|
||||
relay?.let { note.addRelay(it) }
|
||||
reactedTo.forEach { it.addReaction(note) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 9734 zap request event.
|
||||
* Must be consumed before the corresponding LnZapEvent (kind 9735).
|
||||
*/
|
||||
private fun consumeZapRequest(
|
||||
event: LnZapRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
relay?.let { note.addRelay(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 9735 zap receipt event.
|
||||
* Links zap to target notes via the embedded zap request.
|
||||
*/
|
||||
private fun consumeZap(
|
||||
event: LnZapEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Get or consume the embedded zap request
|
||||
val zapRequestEvent = event.zapRequest
|
||||
val zapRequestNote =
|
||||
if (zapRequestEvent != null) {
|
||||
consumeZapRequest(zapRequestEvent, relay)
|
||||
getOrCreateNote(zapRequestEvent.id)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val zappedNotes =
|
||||
event.zappedPost().mapNotNull { getNoteIfExists(it) } +
|
||||
event.taggedAddresses().mapNotNull { addressableNotes.get(it.toValue()) }
|
||||
|
||||
note.loadEvent(event, author, zappedNotes)
|
||||
relay?.let { note.addRelay(it) }
|
||||
|
||||
// Link zap to target notes
|
||||
if (zapRequestNote != null) {
|
||||
zappedNotes.forEach { it.addZap(zapRequestNote, note) }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 6 repost event.
|
||||
* Links repost to target note via e-tag.
|
||||
*/
|
||||
private fun consumeRepost(
|
||||
event: RepostEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) }
|
||||
val repliesTo = listOfNotNull(boostedNote)
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
relay?.let { note.addRelay(it) }
|
||||
boostedNote?.addBoost(note)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 3 contact list event (replaceable).
|
||||
* Updates the cached followedUsers set.
|
||||
*/
|
||||
private var lastContactListCreatedAt = 0L
|
||||
|
||||
private fun consumeContactList(event: ContactListEvent): Boolean {
|
||||
// Replaceable event — only accept newer contact lists
|
||||
if (event.createdAt <= lastContactListCreatedAt) return false
|
||||
lastContactListCreatedAt = event.createdAt
|
||||
_followedUsers.value = event.verifiedFollowKeySet()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 30023 long-form text note event.
|
||||
* Creates Note in cache like TextNoteEvent.
|
||||
*/
|
||||
private fun consumeLongTextNote(
|
||||
event: LongTextNoteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
relay?.let { note.addRelay(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a kind 30001 bookmark list event (addressable/replaceable).
|
||||
* Stores in addressableNotes cache.
|
||||
*/
|
||||
private fun consumeBookmarkList(event: BookmarkListEvent): 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 -----
|
||||
|
||||
/**
|
||||
@@ -199,17 +433,17 @@ class DesktopLocalCache : ICacheProvider {
|
||||
|
||||
// ----- Note operations -----
|
||||
|
||||
override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey]
|
||||
override fun getNoteIfExists(hexKey: HexKey): Note? = notes.get(hexKey)
|
||||
|
||||
override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey)
|
||||
|
||||
fun getOrCreateNote(hexKey: HexKey): Note =
|
||||
notes.getOrPut(hexKey) {
|
||||
notes.getOrCreate(hexKey) {
|
||||
Note(hexKey)
|
||||
}
|
||||
|
||||
override fun getOrCreateAddressableNote(key: Address): AddressableNote =
|
||||
addressableNotes.getOrPut(key.toValue()) {
|
||||
addressableNotes.getOrCreate(key.toValue()) {
|
||||
AddressableNote(key)
|
||||
}
|
||||
|
||||
@@ -258,17 +492,51 @@ class DesktopLocalCache : ICacheProvider {
|
||||
eventStream.emitDeletedNotes(notes)
|
||||
}
|
||||
|
||||
// ----- Profile count cache -----
|
||||
|
||||
private val followerCounts = ConcurrentHashMap<HexKey, Int>()
|
||||
private val followingCounts = ConcurrentHashMap<HexKey, Int>()
|
||||
|
||||
fun getCachedFollowerCount(pubkey: HexKey): Int = followerCounts[pubkey] ?: 0
|
||||
|
||||
fun getCachedFollowingCount(pubkey: HexKey): Int = followingCounts[pubkey] ?: 0
|
||||
|
||||
fun cacheFollowerCount(
|
||||
pubkey: HexKey,
|
||||
count: Int,
|
||||
) {
|
||||
followerCounts[pubkey] = count
|
||||
}
|
||||
|
||||
fun cacheFollowingCount(
|
||||
pubkey: HexKey,
|
||||
count: Int,
|
||||
) {
|
||||
followingCounts[pubkey] = count
|
||||
}
|
||||
|
||||
// ----- Memory Cleanup -----
|
||||
|
||||
fun cleanMemory() {
|
||||
notes.cleanUp()
|
||||
addressableNotes.cleanUp()
|
||||
users.cleanUp()
|
||||
}
|
||||
|
||||
// ----- Stats -----
|
||||
|
||||
fun userCount(): Int = users.size
|
||||
fun userCount(): Int = users.size()
|
||||
|
||||
fun noteCount(): Int = notes.size
|
||||
fun noteCount(): Int = notes.size()
|
||||
|
||||
fun clear() {
|
||||
users.clear()
|
||||
notes.clear()
|
||||
addressableNotes.clear()
|
||||
deletedEvents.clear()
|
||||
_followedUsers.value = emptySet()
|
||||
followerCounts.clear()
|
||||
followingCounts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +544,18 @@ class DesktopLocalCache : ICacheProvider {
|
||||
* Desktop implementation of ICacheEventStream.
|
||||
*/
|
||||
class DesktopCacheEventStream : ICacheEventStream {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
|
||||
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
|
||||
private val _newEventBundles =
|
||||
MutableSharedFlow<Set<Note>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val _deletedEventBundles =
|
||||
MutableSharedFlow<Set<Note>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val newEventBundles: SharedFlow<Set<Note>> = _newEventBundles
|
||||
override val deletedEventBundles: SharedFlow<Set<Note>> = _deletedEventBundles
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.desktop.feeds
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
|
||||
/**
|
||||
* Global feed: all kind 1 text notes, sorted by createdAt desc.
|
||||
*/
|
||||
class DesktopGlobalFeedFilter(
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "global"
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes
|
||||
.filterIntoSet { _, note -> note.event is TextNoteEvent }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is TextNoteEvent }
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 2500
|
||||
}
|
||||
|
||||
/**
|
||||
* Following feed: kind 1 text notes from followed pubkeys.
|
||||
*/
|
||||
class DesktopFollowingFeedFilter(
|
||||
private val cache: DesktopLocalCache,
|
||||
private val followedPubkeys: () -> Set<HexKey>,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "following-${followedPubkeys().hashCode()}"
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val follows = followedPubkeys()
|
||||
return cache.notes
|
||||
.filterIntoSet { _, note ->
|
||||
note.event is TextNoteEvent && note.author?.pubkeyHex in follows
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
val follows = followedPubkeys()
|
||||
return newItems.filterTo(HashSet()) {
|
||||
it.event is TextNoteEvent && it.author?.pubkeyHex in follows
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 2500
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread feed: root note + all replies (graph walk via Note.replies).
|
||||
*/
|
||||
class DesktopThreadFilter(
|
||||
private val noteId: HexKey,
|
||||
private val cache: DesktopLocalCache,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String = "thread-$noteId"
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val root = cache.getNoteIfExists(noteId) ?: return emptyList()
|
||||
// Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList)
|
||||
val seen = LinkedHashSet<Note>()
|
||||
seen.add(root)
|
||||
collectReplies(root, seen)
|
||||
return seen.sortedWith(compareBy { it.createdAt() ?: 0 })
|
||||
}
|
||||
|
||||
private fun collectReplies(
|
||||
note: Note,
|
||||
seen: LinkedHashSet<Note>,
|
||||
) {
|
||||
for (reply in note.replies) {
|
||||
if (seen.add(reply)) {
|
||||
collectReplies(reply, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun limit(): Int = Int.MAX_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile feed: all kind 1 notes by a specific pubkey.
|
||||
*/
|
||||
class DesktopProfileFeedFilter(
|
||||
private val pubkey: HexKey,
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "profile-$pubkey"
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes
|
||||
.filterIntoSet { _, note ->
|
||||
note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
||||
newItems.filterTo(HashSet()) {
|
||||
it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark feed: notes by ID set (from BookmarkListEvent).
|
||||
*/
|
||||
class DesktopBookmarkFeedFilter(
|
||||
private val bookmarkedIds: () -> Set<HexKey>,
|
||||
private val cache: DesktopLocalCache,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String = "bookmarks-${bookmarkedIds().hashCode()}"
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
bookmarkedIds()
|
||||
.mapNotNull { cache.getNoteIfExists(it) }
|
||||
.filter { it.event != null }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
|
||||
override fun limit(): Int = 2500
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads feed: kind 30023 long-form content.
|
||||
*/
|
||||
class DesktopReadsFeedFilter(
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "reads"
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes
|
||||
.filterIntoSet { _, note -> note.event is LongTextNoteEvent }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent }
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification feed: events that tag the logged-in user.
|
||||
* Includes reactions, zaps, replies, reposts targeting the user's notes.
|
||||
*/
|
||||
class DesktopNotificationFeedFilter(
|
||||
private val userPubKeyHex: HexKey,
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
companion object {
|
||||
val NOTIFICATION_KINDS =
|
||||
setOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
)
|
||||
}
|
||||
|
||||
override fun feedKey(): String = "notifications-$userPubKeyHex"
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes
|
||||
.filterIntoSet { _, note -> isNotificationForUser(note) }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isNotificationForUser(it) }
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 2500
|
||||
|
||||
private fun isNotificationForUser(note: Note): Boolean {
|
||||
val event = note.event ?: return false
|
||||
return event.kind in NOTIFICATION_KINDS &&
|
||||
event.pubKey != userPubKeyHex &&
|
||||
event.isTaggedUser(userPubKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search feed: notes matching a text query (content search).
|
||||
* Results are populated by relay search subscriptions that route through cache.
|
||||
*/
|
||||
class DesktopSearchFeedFilter(
|
||||
private val query: String,
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "search-$query"
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val lowerQuery = query.lowercase()
|
||||
return cache.notes
|
||||
.filterIntoSet { _, note ->
|
||||
val event = note.event ?: return@filterIntoSet false
|
||||
event is TextNoteEvent && event.content.lowercase().contains(lowerQuery)
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
.take(limit())
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
val lowerQuery = query.lowercase()
|
||||
return newItems.filterTo(HashSet()) {
|
||||
val event = it.event
|
||||
event is TextNoteEvent && event.content.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
override fun limit(): Int = 500
|
||||
}
|
||||
+41
-1
@@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.INwcSignerState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
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.nip65RelayList.Nip65RelayListRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
@@ -31,6 +36,7 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
@@ -42,6 +48,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -67,6 +74,39 @@ class DesktopIAccount(
|
||||
|
||||
override val pubKey: String = accountState.pubKeyHex
|
||||
|
||||
// ----- State Classes (pin important notes via strong refs for GC retention) -----
|
||||
|
||||
val bookmarkState = BookmarkListState(signer, localCache, scope)
|
||||
|
||||
val kind3FollowList =
|
||||
Kind3FollowListState(
|
||||
signer,
|
||||
localCache,
|
||||
scope,
|
||||
object : Kind3FollowListRepository {
|
||||
override val backupContactList: ContactListEvent? = null
|
||||
|
||||
override fun updateContactListTo(event: ContactListEvent) { /* no persistence yet */ }
|
||||
},
|
||||
)
|
||||
|
||||
val nip65RelayList =
|
||||
Nip65RelayListState(
|
||||
signer,
|
||||
localCache,
|
||||
scope,
|
||||
object : Nip65RelayListRepository {
|
||||
override val backupNIP65RelayList: AdvertisedRelayListEvent? = null
|
||||
|
||||
override fun updateNIP65RelayList(event: AdvertisedRelayListEvent) { /* no persistence yet */ }
|
||||
|
||||
override val defaultOutboxRelays = relayManager.connectedRelays.value
|
||||
override val defaultInboxRelays = relayManager.connectedRelays.value
|
||||
},
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
override val showSensitiveContent: Boolean? = null
|
||||
|
||||
override val hiddenWordsCase: List<DualCase> = emptyList()
|
||||
@@ -97,7 +137,7 @@ class DesktopIAccount(
|
||||
|
||||
override fun isWriteable(): Boolean = !accountState.isReadOnly
|
||||
|
||||
override fun followingKeySet(): Set<String> = emptySet()
|
||||
override fun followingKeySet(): Set<String> = kind3FollowList.flow.value.authors
|
||||
|
||||
override fun isHidden(user: User): Boolean = false
|
||||
|
||||
|
||||
+183
-7
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
|
||||
import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -34,6 +35,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.management.ManagementFactory
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Desktop-specific relay subscriptions coordinator.
|
||||
@@ -86,6 +99,116 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
},
|
||||
)
|
||||
|
||||
// Event bundler: batches consumed notes before emitting to SharedFlow
|
||||
// 250ms for desktop (Android uses 1000ms to save battery)
|
||||
private val eventBundler =
|
||||
BasicBundledInsert<Note>(
|
||||
delay = 250,
|
||||
dispatcher = Dispatchers.IO,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
// Screen-triggered subscription Jobs — keyed by subId for proper cancellation
|
||||
private val screenSubscriptions = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// Last event received from any subscription — drives RelayHealthIndicator
|
||||
private val _lastEventAt = MutableStateFlow<Long?>(null)
|
||||
val lastEventAt: StateFlow<Long?> = _lastEventAt.asStateFlow()
|
||||
|
||||
/**
|
||||
* Central event router — consumes an event into the cache and emits to event stream.
|
||||
* Called from relay onEvent callbacks. Non-blocking (launches on IO dispatcher).
|
||||
* Try-catch per event ensures one bad event doesn't kill the pipeline.
|
||||
*/
|
||||
fun consumeEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val consumed = localCache.consume(event, relay)
|
||||
if (consumed) {
|
||||
_lastEventAt.value = System.currentTimeMillis()
|
||||
val note = localCache.getNoteIfExists(event.id) ?: return@launch
|
||||
eventBundler.invalidateList(note) { batch ->
|
||||
localCache.eventStream.emitNewNotes(batch)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a consolidated interaction subscription for the given note IDs.
|
||||
* Subscribes to kinds 7 (reactions), 9735 (zaps), 6 (reposts), and 1 (replies)
|
||||
* targeting these notes. Returns a subId for cleanup via [releaseInteractions].
|
||||
*/
|
||||
fun requestInteractions(
|
||||
noteIds: List<String>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): String {
|
||||
val subId = generateSubId("interactions-${noteIds.hashCode()}")
|
||||
|
||||
// Cancel any existing subscription with this ID
|
||||
screenSubscriptions.remove(subId)?.cancel()
|
||||
client.close(subId)
|
||||
|
||||
if (noteIds.isEmpty() || relays.isEmpty()) return subId
|
||||
|
||||
val filters =
|
||||
listOf(
|
||||
// Reactions (kind 7) targeting these notes
|
||||
Filter(
|
||||
kinds = listOf(com.vitorpamplona.quartz.nip25Reactions.ReactionEvent.KIND),
|
||||
tags = mapOf("e" to noteIds),
|
||||
),
|
||||
// Zap receipts (kind 9735) targeting these notes
|
||||
Filter(
|
||||
kinds = listOf(com.vitorpamplona.quartz.nip57Zaps.LnZapEvent.KIND),
|
||||
tags = mapOf("e" to noteIds),
|
||||
),
|
||||
// Reposts (kind 6) targeting these notes
|
||||
Filter(
|
||||
kinds = listOf(com.vitorpamplona.quartz.nip18Reposts.RepostEvent.KIND),
|
||||
tags = mapOf("e" to noteIds),
|
||||
),
|
||||
)
|
||||
|
||||
val listener =
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
consumeEvent(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
val job =
|
||||
scope.launch {
|
||||
client.openReqSubscription(
|
||||
subId = subId,
|
||||
filters = relays.associateWith { filters },
|
||||
listener = listener,
|
||||
)
|
||||
}
|
||||
screenSubscriptions[subId] = job
|
||||
|
||||
return subId
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a screen-triggered interaction subscription.
|
||||
*/
|
||||
fun releaseInteractions(subId: String) {
|
||||
screenSubscriptions.remove(subId)?.cancel()
|
||||
client.close(subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the coordinator.
|
||||
* Call once when app starts or user logs in.
|
||||
@@ -127,13 +250,6 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
feedMetadata.loadMetadataForPubkeys(pubkeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reactions for specific notes.
|
||||
*/
|
||||
fun loadReactionsForNotes(noteIds: List<HexKey>) {
|
||||
feedMetadata.loadReactionsForNotes(noteIds)
|
||||
}
|
||||
|
||||
// -- DM Subscription Support --
|
||||
|
||||
/** Active DM subscription IDs for cleanup */
|
||||
@@ -234,8 +350,68 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
* Call when switching accounts or during cleanup.
|
||||
*/
|
||||
fun clear() {
|
||||
// Clean up screen-triggered subscriptions
|
||||
screenSubscriptions.forEach { (subId, job) ->
|
||||
job.cancel()
|
||||
client.close(subId)
|
||||
}
|
||||
screenSubscriptions.clear()
|
||||
_lastEventAt.value = null
|
||||
|
||||
unsubscribeFromDms()
|
||||
feedMetadata.clear()
|
||||
rateLimiter.reset()
|
||||
cleanupJob?.cancel()
|
||||
}
|
||||
|
||||
// ----- Memory Cleanup -----
|
||||
|
||||
private val memoryBean = ManagementFactory.getMemoryMXBean()
|
||||
private var lastCleanupTime = 0L
|
||||
private var cleanupJob: Job? = null
|
||||
|
||||
/**
|
||||
* Starts a periodic memory cleanup coroutine.
|
||||
* Checks heap usage every 30s, runs cleanup at >75% heap or every 5 minutes.
|
||||
*/
|
||||
fun startCleanupLoop() {
|
||||
cleanupJob =
|
||||
scope.launch(Dispatchers.Default) {
|
||||
delay(2.minutes)
|
||||
while (isActive) {
|
||||
delay(30.seconds)
|
||||
val heapPct = heapUsagePercent()
|
||||
val elapsed = System.currentTimeMillis() - lastCleanupTime
|
||||
if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) {
|
||||
runCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runCleanup() {
|
||||
val ops =
|
||||
listOf<Pair<String, suspend () -> Unit>>(
|
||||
"cleanMemory" to { localCache.cleanMemory() },
|
||||
"cleanObservers" to { cleanObservers() },
|
||||
)
|
||||
ops.forEach { (name, op) ->
|
||||
try {
|
||||
op()
|
||||
} catch (e: Exception) {
|
||||
println("Cleanup $name failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
lastCleanupTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun cleanObservers() {
|
||||
localCache.notes.forEach { _, note -> note.clearFlow() }
|
||||
localCache.addressableNotes.forEach { _, note -> note.clearFlow() }
|
||||
}
|
||||
|
||||
private fun heapUsagePercent(): Double {
|
||||
val heap = memoryBean.heapMemoryUsage
|
||||
return heap.used.toDouble() / heap.max.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
+27
-3
@@ -76,7 +76,8 @@ fun BookmarksScreen(
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Tab state
|
||||
@@ -120,6 +121,27 @@ fun BookmarksScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Seed from cache — bookmark list event may already be in addressableNotes
|
||||
LaunchedEffect(account.pubKeyHex) {
|
||||
val address = BookmarkListEvent.createBookmarkAddress(account.pubKeyHex)
|
||||
val cachedNote = localCache.getOrCreateAddressableNote(address)
|
||||
val cachedEvent = cachedNote.event as? BookmarkListEvent
|
||||
if (cachedEvent != null) {
|
||||
bookmarkList = cachedEvent
|
||||
publicBookmarkIds =
|
||||
cachedEvent
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
// Seed public events from cache
|
||||
publicBookmarkIds.forEach { id ->
|
||||
val note = localCache.getNoteIfExists(id)
|
||||
val event = note?.event
|
||||
if (event != null) publicEventState.addItem(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list (kind 30001)
|
||||
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
@@ -188,7 +210,8 @@ fun BookmarksScreen(
|
||||
FilterBuilders.byIds(publicBookmarkIds),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
publicEventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
@@ -209,7 +232,8 @@ fun BookmarksScreen(
|
||||
FilterBuilders.byIds(privateBookmarkIds),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
privateEventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
|
||||
+11
-8
@@ -31,19 +31,22 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
|
||||
*/
|
||||
fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
|
||||
val npub =
|
||||
try {
|
||||
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
|
||||
} catch (e: Exception) {
|
||||
pubKey.take(16) + "..."
|
||||
}
|
||||
val user = (cache?.getUserIfExists(pubKey) as? User)
|
||||
|
||||
val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture()
|
||||
val displayName =
|
||||
user?.toBestDisplayName()
|
||||
?: try {
|
||||
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
|
||||
} catch (e: Exception) {
|
||||
pubKey.take(16) + "..."
|
||||
}
|
||||
|
||||
val pictureUrl = user?.profilePicture()
|
||||
|
||||
return NoteDisplayData(
|
||||
id = id,
|
||||
pubKeyHex = pubKey,
|
||||
pubKeyDisplay = npub,
|
||||
pubKeyDisplay = displayName,
|
||||
profilePictureUrl = pictureUrl,
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
|
||||
+253
-461
@@ -45,49 +45,36 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
data class LightboxState(
|
||||
val urls: List<String>,
|
||||
@@ -97,11 +84,12 @@ data class LightboxState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Note card with action buttons.
|
||||
* Note card that reads counts from the Note model (cache-backed).
|
||||
* Event is extracted from Note for signing operations in NoteActionsRow.
|
||||
*/
|
||||
@Composable
|
||||
fun FeedNoteCard(
|
||||
event: Event,
|
||||
note: Note,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn?,
|
||||
@@ -112,15 +100,25 @@ fun FeedNoteCard(
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
zapReceipts: List<ZapReceipt> = emptyList(),
|
||||
reactionCount: Int = 0,
|
||||
replyCount: Int = 0,
|
||||
repostCount: Int = 0,
|
||||
bookmarkList: BookmarkListEvent? = null,
|
||||
isBookmarked: Boolean = false,
|
||||
onBookmarkChanged: (BookmarkListEvent) -> Unit = {},
|
||||
) {
|
||||
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
|
||||
val event = note.event ?: return
|
||||
|
||||
// Observe Note.flowSet for live count updates
|
||||
val flowSet = remember(note) { note.flow() }
|
||||
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
|
||||
val repliesState by flowSet.replies.stateFlow.collectAsState()
|
||||
val zapsState by flowSet.zaps.stateFlow.collectAsState()
|
||||
|
||||
// Read counts from Note model (re-read on each stateFlow emission)
|
||||
val reactionCount = note.countReactions()
|
||||
val replyCount = note.replies.size
|
||||
val repostCount = note.boosts.size
|
||||
val zapAmount = note.zapsAmount
|
||||
|
||||
// Clean up flowSet when card leaves composition
|
||||
DisposableEffect(note) {
|
||||
onDispose { note.clearFlow() }
|
||||
}
|
||||
|
||||
Column {
|
||||
NoteCard(
|
||||
@@ -144,15 +142,12 @@ fun FeedNoteCard(
|
||||
onReplyClick = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = zapReceipts.size,
|
||||
zapAmountSats = zapAmountSats,
|
||||
zapReceipts = zapReceipts,
|
||||
zapCount = note.zaps.size,
|
||||
zapAmountSats = zapAmount.toLong(),
|
||||
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = isBookmarked,
|
||||
onBookmarkChanged = onBookmarkChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,57 +166,25 @@ fun FeedScreen(
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
// Configured relay URLs only — stabilized with distinctUntilChanged() to prevent
|
||||
// subscription churn from relay status changes (pings, connect/disconnect).
|
||||
// openReqSubscription connects relays on demand; no need to wait for connectedRelays.
|
||||
val configuredRelays by remember {
|
||||
relayManager.relayStatuses
|
||||
.map { it.keys }
|
||||
.distinctUntilChanged()
|
||||
}.collectAsState(emptySet())
|
||||
val scope = rememberCoroutineScope()
|
||||
val eventState =
|
||||
remember {
|
||||
EventCollectionState<Event>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareByDescending { it.createdAt },
|
||||
maxSize = 200,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
val events by eventState.items.collectAsState()
|
||||
val followedUsers by localCache.followedUsers.collectAsState()
|
||||
|
||||
// Available relay URLs — openReqSubscription triggers connection on-demand
|
||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
||||
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
|
||||
// Track reaction event IDs per target event to deduplicate
|
||||
var reactionIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
|
||||
// Track reply/repost event IDs per target event to deduplicate
|
||||
var replyIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
|
||||
var repostIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
|
||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// Track EOSE to know when initial load is complete
|
||||
var eoseReceivedCount by remember { mutableStateOf(0) }
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
||||
if (allRelayUrls.isNotEmpty() && account != null) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = allRelayUrls,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
val follows = event.verifiedFollowKeySet()
|
||||
followedUsers = follows
|
||||
}
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -229,83 +192,28 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Load user's bookmark list
|
||||
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "bookmarks-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(account.pubKeyHex),
|
||||
kinds = listOf(BookmarkListEvent.KIND),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
// Extract public bookmarked event IDs
|
||||
val pubIds =
|
||||
event
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Clear events and reset EOSE when feed mode changes
|
||||
remember(feedMode) {
|
||||
eventState.clear()
|
||||
eoseReceivedCount = 0
|
||||
}
|
||||
|
||||
// Subscribe to feed based on mode
|
||||
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
||||
rememberSubscription(allRelayUrls, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty()) return@rememberSubscription null
|
||||
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
createGlobalFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Store metadata events in cache
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
}
|
||||
eventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
eoseReceivedCount++
|
||||
relays = allRelayUrls,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FeedMode.FOLLOWING -> {
|
||||
if (followedUsers.isNotEmpty()) {
|
||||
val follows = followedUsers.toList()
|
||||
if (follows.isNotEmpty()) {
|
||||
createFollowingFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
followedUsers = followedUsers.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Store metadata events in cache
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
}
|
||||
eventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
eoseReceivedCount++
|
||||
relays = allRelayUrls,
|
||||
followedUsers = follows,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -315,344 +223,142 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to zaps for visible events
|
||||
val eventIds = events.map { it.id }
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
|
||||
val viewModel =
|
||||
remember(feedMode) {
|
||||
val filter =
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
DesktopGlobalFeedFilter(localCache)
|
||||
}
|
||||
|
||||
FeedMode.FOLLOWING -> {
|
||||
DesktopFollowingFeedFilter(localCache) {
|
||||
localCache.followedUsers.value
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopFeedViewModel(filter, localCache)
|
||||
}
|
||||
|
||||
createZapsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent) {
|
||||
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
|
||||
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
|
||||
zapsByEvent =
|
||||
zapsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptyList()
|
||||
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
|
||||
this[targetEventId] = existing + receipt
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Cancel old ViewModel's viewModelScope on recreation
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose { viewModel.destroy() }
|
||||
}
|
||||
|
||||
// Subscribe to metadata for zap senders (to show display names)
|
||||
val zapSenderPubkeys =
|
||||
zapsByEvent.values
|
||||
.flatten()
|
||||
.map { it.senderPubKey }
|
||||
.distinct()
|
||||
rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
val feedState by viewModel.feedState.feedContent.collectAsState()
|
||||
|
||||
// Only fetch metadata for users we don't have yet
|
||||
val missingPubkeys =
|
||||
zapSenderPubkeys.filter { pubkey ->
|
||||
localCache
|
||||
.getUserIfExists(pubkey)
|
||||
?.metadataOrNull()
|
||||
?.flow
|
||||
?.value == null
|
||||
// Load metadata for visible notes via Coordinator (rate-limited)
|
||||
LaunchedEffect(feedState, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
|
||||
val notes = viewModel.feedState.visibleNotes()
|
||||
if (notes.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForNotes(notes)
|
||||
}
|
||||
if (missingPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createBatchMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
pubKeyHexList = missingPubkeys,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to reactions for visible events
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createReactionsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ReactionEvent) {
|
||||
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
|
||||
reactionIdsByEvent =
|
||||
reactionIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptySet()
|
||||
this[targetEventId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to replies for visible events
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Find the event this is replying to
|
||||
val replyToId =
|
||||
event.tags
|
||||
.filter { it.size >= 2 && it[0] == "e" }
|
||||
.lastOrNull()
|
||||
?.get(1) ?: return@createRepliesSubscription
|
||||
if (replyToId in eventIds) {
|
||||
replyIdsByEvent =
|
||||
replyIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[replyToId] ?: emptySet()
|
||||
this[replyToId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to reposts for visible events
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepostsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is RepostEvent) {
|
||||
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
|
||||
repostIdsByEvent =
|
||||
repostIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptySet()
|
||||
this[targetEventId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to metadata for note authors + mentioned users
|
||||
val authorPubkeys = events.map { it.pubKey }.distinct()
|
||||
val mentionedPubkeys =
|
||||
remember(events) {
|
||||
val parser = UrlParser()
|
||||
events
|
||||
.flatMap { event ->
|
||||
val urls = parser.parseValidUrls(event.content)
|
||||
extractMentionedPubkeys(urls.bech32s)
|
||||
}.distinct()
|
||||
}
|
||||
val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() }
|
||||
|
||||
// Use coordinator for rate-limited metadata loading (preferred)
|
||||
LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback subscription if coordinator not available
|
||||
rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
// Skip if using coordinator
|
||||
if (subscriptionsCoordinator != null) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
// Only fetch metadata for users we don't have yet
|
||||
val missingPubkeys =
|
||||
allPubkeys.filter { pubkey ->
|
||||
localCache
|
||||
.getUserIfExists(pubkey)
|
||||
?.metadataOrNull()
|
||||
?.flow
|
||||
?.value == null
|
||||
// Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms)
|
||||
DisposableEffect(feedMode, subscriptionsCoordinator) {
|
||||
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
|
||||
val relays = relayManager.relayStatuses.value.keys
|
||||
// Initial subscription with whatever notes are visible now
|
||||
val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id }
|
||||
val subId =
|
||||
if (noteIds.isNotEmpty()) {
|
||||
coordinator.requestInteractions(noteIds, relays)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (missingPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createBatchMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
pubKeyHexList = missingPubkeys,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with compose button — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Feed mode selector
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = {
|
||||
feedMode = FeedMode.GLOBAL
|
||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||
},
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = {
|
||||
feedMode = FeedMode.FOLLOWING
|
||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||
},
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" • ${followedUsers.size} followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New Post button (primary action)
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
// Header with compose button
|
||||
FeedHeader(
|
||||
feedMode = feedMode,
|
||||
account = account,
|
||||
connectedRelays = connectedRelays,
|
||||
followedUsersCount = followedUsers.size,
|
||||
onFeedModeChange = { mode ->
|
||||
feedMode = mode
|
||||
DesktopPreferences.feedMode = mode
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
onCompose = onCompose,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||
LoadingState("Loading followed users...")
|
||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||
LoadingState("Loading notes...")
|
||||
} else if (events.isEmpty() && initialLoadComplete) {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
// Feed content based on FeedState
|
||||
when (val state = feedState) {
|
||||
is FeedState.Loading -> {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else {
|
||||
LoadingState("Loading notes...")
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Empty -> {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
)
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
EmptyState(
|
||||
title = "Error loading feed",
|
||||
description = state.errorMessage,
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
val loadedState by state.feed.collectAsState()
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(loadedState.list, key = { it.idHex }) { note ->
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = note.event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
}
|
||||
|
||||
// Reply dialog
|
||||
if (replyToEvent != null && account != null) {
|
||||
@@ -674,5 +380,91 @@ fun FeedScreen(
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed header with title, mode selector, relay count, and compose button.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FeedHeader(
|
||||
feedMode: FeedMode,
|
||||
account: AccountState.LoggedIn?,
|
||||
connectedRelays: Set<Any>,
|
||||
followedUsersCount: Int,
|
||||
onFeedModeChange: (FeedMode) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" \u2022 $followedUsersCount followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription
|
||||
@@ -109,10 +110,12 @@ sealed class NotificationItem(
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
val scope = rememberCoroutineScope()
|
||||
val notificationState =
|
||||
remember {
|
||||
@@ -125,6 +128,38 @@ fun NotificationsScreen(
|
||||
}
|
||||
val notifications by notificationState.items.collectAsState()
|
||||
|
||||
// Seed from cache — reactions, zaps already consumed are in cache
|
||||
LaunchedEffect(Unit) {
|
||||
val myPubKey = account.pubKeyHex
|
||||
val cached =
|
||||
localCache.notes.filterIntoSet { _, note ->
|
||||
val event = note.event ?: return@filterIntoSet false
|
||||
when (event) {
|
||||
is ReactionEvent -> event.pubKey != myPubKey
|
||||
is LnZapEvent -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
cached.forEach { note ->
|
||||
val event = note.event ?: return@forEach
|
||||
val notification =
|
||||
when (event) {
|
||||
is ReactionEvent -> {
|
||||
NotificationItem.Reaction(event, event.createdAt, event.content)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
NotificationItem.Zap(event, event.createdAt, event.amount?.toLong())
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
notification?.let { notificationState.addItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Load metadata for notification authors via coordinator
|
||||
LaunchedEffect(notifications, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && notifications.isNotEmpty()) {
|
||||
@@ -143,7 +178,8 @@ fun NotificationsScreen(
|
||||
createNotificationsSubscription(
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
// Skip events from the user themselves (except zaps)
|
||||
if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) {
|
||||
return@createNotificationsSubscription
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -62,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription
|
||||
@@ -180,12 +182,14 @@ fun ReadsScreen(
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val eventState =
|
||||
@@ -204,6 +208,18 @@ fun ReadsScreen(
|
||||
var eoseReceivedCount by remember { mutableStateOf(0) }
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Seed from cache — long-form notes already consumed are in cache
|
||||
LaunchedEffect(Unit) {
|
||||
val cached =
|
||||
localCache.notes.filterIntoSet { _, note ->
|
||||
note.event is LongTextNoteEvent
|
||||
}
|
||||
cached.forEach { note ->
|
||||
(note.event as? LongTextNoteEvent)?.let { eventState.addItem(it) }
|
||||
}
|
||||
if (cached.isNotEmpty()) eoseReceivedCount++
|
||||
}
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
|
||||
val connectedRelays = connectedRelays
|
||||
@@ -239,7 +255,8 @@ fun ReadsScreen(
|
||||
FeedMode.GLOBAL -> {
|
||||
createLongFormFeedSubscription(
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
if (event is LongTextNoteEvent) {
|
||||
eventState.addItem(event)
|
||||
}
|
||||
|
||||
+151
-359
@@ -42,48 +42,38 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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.tags.EventBookmark
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
|
||||
/**
|
||||
* Desktop Thread Screen - displays a note and all its replies in a thread view.
|
||||
*
|
||||
* Uses the shared drawReplyLevel modifier from commons to display reply nesting.
|
||||
* Uses DesktopFeedViewModel + DesktopThreadFilter for cache-backed display.
|
||||
* Keeps relay subscriptions to populate cache with thread data.
|
||||
*/
|
||||
@Composable
|
||||
fun ThreadScreen(
|
||||
@@ -99,115 +89,47 @@ fun ThreadScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
onReply: (Event) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// State for the root note
|
||||
var rootNote by remember { mutableStateOf<Event?>(null) }
|
||||
|
||||
// State for reply events
|
||||
val replyEventState =
|
||||
remember(noteId) {
|
||||
EventCollectionState<Event>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareBy { it.createdAt },
|
||||
maxSize = 500,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
val replyEvents by replyEventState.items.collectAsState()
|
||||
|
||||
// Cache for calculating reply levels
|
||||
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
|
||||
|
||||
// Track EOSE to know when initial load is complete
|
||||
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
|
||||
var repliesEoseReceived by remember(noteId) { mutableStateOf(false) }
|
||||
|
||||
// Track zaps per event
|
||||
var zapsByEvent by remember(noteId) { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
|
||||
// Track reaction event IDs per target event to deduplicate
|
||||
var reactionIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
|
||||
// Track reply/repost event IDs per target event to deduplicate
|
||||
var replyIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
|
||||
var repostIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
|
||||
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
|
||||
|
||||
// Bookmark state
|
||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
|
||||
// Lightbox state
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
|
||||
// Load metadata for thread authors + mentioned users via coordinator
|
||||
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null) {
|
||||
val pubkeys = mutableListOf<String>()
|
||||
rootNote?.let { pubkeys.add(it.pubKey) }
|
||||
pubkeys.addAll(replyEvents.map { it.pubKey })
|
||||
// Track EOSE for root note subscription
|
||||
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
|
||||
|
||||
// Also load metadata for users mentioned in note content
|
||||
val parser = UrlParser()
|
||||
val allEvents = listOfNotNull(rootNote) + replyEvents
|
||||
val mentionedPubkeys =
|
||||
allEvents.flatMap { event ->
|
||||
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s)
|
||||
}
|
||||
pubkeys.addAll(mentionedPubkeys)
|
||||
|
||||
if (pubkeys.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list
|
||||
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(account.pubKeyHex),
|
||||
kinds = listOf(BookmarkListEvent.KIND),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
val pubIds =
|
||||
event
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
// DesktopFeedViewModel reads thread from cache (root + replies via graph walk)
|
||||
val threadViewModel =
|
||||
remember(noteId) {
|
||||
DesktopFeedViewModel(
|
||||
DesktopThreadFilter(noteId, localCache),
|
||||
localCache,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
DisposableEffect(threadViewModel) {
|
||||
onDispose { threadViewModel.destroy() }
|
||||
}
|
||||
val feedState by threadViewModel.feedState.feedContent.collectAsState()
|
||||
val threadNotes =
|
||||
if (feedState is FeedState.Loaded) {
|
||||
val loaded by (feedState as FeedState.Loaded).feed.collectAsState()
|
||||
loaded.list
|
||||
} else {
|
||||
kotlinx.collections.immutable.persistentListOf()
|
||||
}
|
||||
|
||||
// Subscribe to the root note
|
||||
// Level cache for reply nesting
|
||||
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
|
||||
|
||||
// Keep relay subscriptions to populate cache — root note
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createNoteSubscription(
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event.id == noteId) {
|
||||
rootNote = event
|
||||
levelCache[event.id] = 0
|
||||
}
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
levelCache[event.id] = 0
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
rootNoteEoseReceived = true
|
||||
@@ -218,129 +140,52 @@ fun ThreadScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to replies
|
||||
// Keep relay subscription for replies
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createThreadRepliesSubscription(
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
replyEventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
repliesEoseReceived = true
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to zaps for thread events
|
||||
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createZapsSubscription(
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent) {
|
||||
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
|
||||
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
|
||||
zapsByEvent =
|
||||
zapsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptyList()
|
||||
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
|
||||
this[targetEventId] = existing + receipt
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Request interaction data — keyed on noteId (stable), not threadNotes (changes on every bundle)
|
||||
DisposableEffect(noteId, subscriptionsCoordinator) {
|
||||
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
|
||||
val noteIds = threadNotes.mapNotNull { it.event?.id }
|
||||
val relays = relayManager.relayStatuses.value.keys
|
||||
val subId =
|
||||
if (noteIds.isNotEmpty()) {
|
||||
coordinator.requestInteractions(noteIds, relays)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
|
||||
}
|
||||
|
||||
// Subscribe to reactions for thread events
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
// Load metadata for thread authors via coordinator
|
||||
LaunchedEffect(threadNotes, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) {
|
||||
subscriptionsCoordinator.loadMetadataForNotes(threadNotes)
|
||||
}
|
||||
|
||||
createReactionsSubscription(
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ReactionEvent) {
|
||||
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
|
||||
reactionIdsByEvent =
|
||||
reactionIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptySet()
|
||||
this[targetEventId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to replies for thread events (for counts)
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepliesSubscription(
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
val replyToId =
|
||||
event.tags
|
||||
.filter { it.size >= 2 && it[0] == "e" }
|
||||
.lastOrNull()
|
||||
?.get(1) ?: return@createRepliesSubscription
|
||||
if (replyToId in allEventIds) {
|
||||
replyIdsByEvent =
|
||||
replyIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[replyToId] ?: emptySet()
|
||||
this[replyToId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to reposts for thread events
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepostsSubscription(
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is RepostEvent) {
|
||||
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
|
||||
repostIdsByEvent =
|
||||
repostIdsByEvent.toMutableMap().apply {
|
||||
val existing = this[targetEventId] ?: emptySet()
|
||||
this[targetEventId] = existing + event.id
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate reply level for an event based on e-tags
|
||||
fun calculateLevel(event: Event): Int {
|
||||
// Calculate reply level for a note based on e-tags
|
||||
fun calculateLevel(note: Note): Int {
|
||||
val event = note.event ?: return 1
|
||||
levelCache[event.id]?.let { return it }
|
||||
|
||||
// Find the event this is replying to (last e-tag or marked reply/root)
|
||||
val replyToId = findReplyToId(event)
|
||||
val level =
|
||||
if (replyToId == null || replyToId == noteId) {
|
||||
1 // Direct reply to root
|
||||
1
|
||||
} else {
|
||||
(levelCache[replyToId] ?: 0) + 1
|
||||
}
|
||||
@@ -348,6 +193,9 @@ fun ThreadScreen(
|
||||
return level
|
||||
}
|
||||
|
||||
val rootNote = threadNotes.firstOrNull { it.idHex == noteId }
|
||||
val replyNotes = threadNotes.filter { it.idHex != noteId }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with back button
|
||||
@@ -370,165 +218,111 @@ fun ThreadScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||
LoadingState("Loading thread...")
|
||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note (no reply level indicator)
|
||||
item(key = noteId) {
|
||||
Column {
|
||||
NoteCard(
|
||||
note = rootNote!!.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = rootNote!!,
|
||||
when {
|
||||
connectedRelays.isEmpty() -> {
|
||||
LoadingState("Connecting to relays...")
|
||||
}
|
||||
|
||||
feedState is FeedState.Loading && !rootNoteEoseReceived -> {
|
||||
LoadingState("Loading thread...")
|
||||
}
|
||||
|
||||
rootNote == null && rootNoteEoseReceived -> {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note
|
||||
if (rootNote != null) {
|
||||
item(key = noteId) {
|
||||
Column {
|
||||
FeedNoteCard(
|
||||
note = rootNote,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { rootNote.event?.let { onReply(it) } },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyNotes, key = { it.idHex }) { note ->
|
||||
val level = calculateLevel(note)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected = MaterialTheme.colorScheme.outlineVariant,
|
||||
).clickable {
|
||||
note.event?.let { onNavigateToThread(it.id) }
|
||||
},
|
||||
) {
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(rootNote!!) },
|
||||
onReply = { note.event?.let { onReply(it) } },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = rootZaps.size,
|
||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||
zapReceipts = rootZaps,
|
||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||
replyCount = repliesByEvent[noteId] ?: 0,
|
||||
repostCount = repostsByEvent[noteId] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index ->
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected =
|
||||
if (event.id == noteId) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
).clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(event) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = eventZaps.size,
|
||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||
zapReceipts = eventZaps,
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
// Empty/loading state for replies
|
||||
if (replyNotes.isEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Empty state for no replies
|
||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"Loading replies...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
val lb = lightboxState
|
||||
@@ -541,7 +335,7 @@ fun ThreadScreen(
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -552,13 +346,11 @@ private fun findReplyToId(event: Event): String? {
|
||||
val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
|
||||
if (eTags.isEmpty()) return null
|
||||
|
||||
// Check for NIP-10 marked tags first
|
||||
val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" }
|
||||
if (replyTag != null) return replyTag[1]
|
||||
|
||||
val rootTag = eTags.find { it.size >= 4 && it[3] == "root" }
|
||||
if (rootTag != null && eTags.size == 1) return rootTag[1]
|
||||
|
||||
// Fall back to positional (last e-tag is the reply-to)
|
||||
return eTags.lastOrNull()?.get(1)
|
||||
}
|
||||
|
||||
+103
-88
@@ -59,6 +59,7 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -74,24 +75,24 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
|
||||
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner
|
||||
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
@@ -123,14 +124,25 @@ fun UserProfileScreen(
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
|
||||
// User metadata
|
||||
var displayName by remember { mutableStateOf<String?>(null) }
|
||||
var about by remember { mutableStateOf<String?>(null) }
|
||||
var picture by remember { mutableStateOf<String?>(null) }
|
||||
var followersCount by remember { mutableStateOf(0) }
|
||||
var followingCount by remember { mutableStateOf(0) }
|
||||
// User metadata — seed from cache so returning to profile is instant
|
||||
val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) }
|
||||
val cachedMetadata = remember(pubKeyHex) { cachedUser?.metadataOrNull() }
|
||||
var displayName by remember { mutableStateOf(cachedMetadata?.bestName()) }
|
||||
var about by remember {
|
||||
mutableStateOf(
|
||||
cachedMetadata
|
||||
?.flow
|
||||
?.value
|
||||
?.info
|
||||
?.about,
|
||||
)
|
||||
}
|
||||
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
|
||||
var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) }
|
||||
var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) }
|
||||
|
||||
// Profile editing state (only for own profile)
|
||||
val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex
|
||||
@@ -141,21 +153,50 @@ fun UserProfileScreen(
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// User's posts
|
||||
val eventState =
|
||||
remember {
|
||||
EventCollectionState<Event>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareByDescending { it.createdAt },
|
||||
maxSize = 200,
|
||||
scope = scope,
|
||||
// User's posts — cache-backed via DesktopFeedViewModel
|
||||
val profileViewModel =
|
||||
remember(pubKeyHex) {
|
||||
DesktopFeedViewModel(
|
||||
DesktopProfileFeedFilter(pubKeyHex, localCache),
|
||||
localCache,
|
||||
)
|
||||
}
|
||||
val events by eventState.items.collectAsState()
|
||||
var postsLoading by remember { mutableStateOf(true) }
|
||||
var postsError by remember { mutableStateOf<String?>(null) }
|
||||
DisposableEffect(profileViewModel) {
|
||||
onDispose { profileViewModel.destroy() }
|
||||
}
|
||||
val profileFeedState by profileViewModel.feedState.feedContent.collectAsState()
|
||||
val profileLoadedNotes =
|
||||
if (profileFeedState is FeedState.Loaded) {
|
||||
val loaded by (profileFeedState as FeedState.Loaded).feed.collectAsState()
|
||||
loaded.list
|
||||
} else {
|
||||
kotlinx.collections.immutable.persistentListOf()
|
||||
}
|
||||
var retryTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("profile-notes-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.textNotesFromAuthors(
|
||||
authors = listOf(pubKeyHex),
|
||||
limit = 200,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Tab and gallery state
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||
@@ -206,13 +247,6 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear posts when profile changes
|
||||
remember(pubKeyHex, retryTrigger) {
|
||||
eventState.clear()
|
||||
postsLoading = true
|
||||
postsError = null
|
||||
}
|
||||
|
||||
// Subscribe to user metadata
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
@@ -255,8 +289,9 @@ fun UserProfileScreen(
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
// Count the number of people this user follows
|
||||
followingCount = event.verifiedFollowKeySet().size
|
||||
val count = event.verifiedFollowKeySet().size
|
||||
followingCount = count
|
||||
localCache.cacheFollowingCount(pubKeyHex, count)
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
@@ -272,9 +307,8 @@ fun UserProfileScreen(
|
||||
// Subscribe to followers (contact lists that tag this user)
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
// Clear previous followers when subscription restarts
|
||||
// Clear dedup set but keep cached followersCount visible until new data arrives
|
||||
followerAuthors.clear()
|
||||
followersCount = 0
|
||||
|
||||
SubscriptionConfig(
|
||||
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
|
||||
@@ -290,7 +324,9 @@ fun UserProfileScreen(
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Count unique authors who follow this user
|
||||
if (followerAuthors.add(event.pubKey)) {
|
||||
followersCount = followerAuthors.size
|
||||
val count = followerAuthors.size
|
||||
followersCount = count
|
||||
localCache.cacheFollowerCount(pubKeyHex, count)
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
@@ -300,28 +336,6 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to user posts
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
postsLoading = true
|
||||
postsError = null
|
||||
createUserPostsSubscription(
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
eventState.addItem(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
postsLoading = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
postsLoading = false
|
||||
postsError = "No relays configured"
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to picture events (kind 20) for gallery tab
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
@@ -708,35 +722,8 @@ fun UserProfileScreen(
|
||||
// Tab content
|
||||
when (selectedTab) {
|
||||
0 -> {
|
||||
when {
|
||||
postsError != null -> {
|
||||
item(key = "error") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Failed to load posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
postsError!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postsLoading -> {
|
||||
when (profileFeedState) {
|
||||
is FeedState.Loading -> {
|
||||
item(key = "loading") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
@@ -755,7 +742,7 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
events.isEmpty() -> {
|
||||
is FeedState.Empty -> {
|
||||
item(key = "empty") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
@@ -770,10 +757,38 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
is FeedState.FeedError -> {
|
||||
item(key = "error") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Failed to load posts",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
(profileFeedState as FeedState.FeedError).errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
// loadedNotes collected outside LazyColumn in profileLoadedNotes
|
||||
items(profileLoadedNotes, key = { it.idHex }) { note ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
|
||||
+2
-1
@@ -80,7 +80,8 @@ fun NewDmDialog(
|
||||
val cachedUsers by searchState.cachedUserResults.collectAsState()
|
||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// NIP-50 relay search when local cache has few/no results
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.desktop.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Displays relay health as elapsed time since last event.
|
||||
* Hidden when < 30s (healthy). Shows "45s" or "3m" when stale.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayHealthIndicator(
|
||||
lastEventReceivedAt: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (lastEventReceivedAt == null) return
|
||||
|
||||
// Tick every 5s to update elapsed display
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
now = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
val elapsedMs = now - lastEventReceivedAt
|
||||
if (elapsedMs < 30_000) return // healthy, don't show
|
||||
|
||||
val text = formatElapsed(elapsedMs)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatElapsed(elapsedMs: Long): String {
|
||||
val seconds = elapsedMs / 1000
|
||||
return when {
|
||||
seconds < 60 -> "${seconds}s ago"
|
||||
seconds < 3600 -> "${seconds / 60}m ago"
|
||||
else -> "${seconds / 3600}h ago"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -220,7 +220,7 @@ internal fun RootContent(
|
||||
}
|
||||
|
||||
DeckColumnType.Notifications -> {
|
||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||
NotificationsScreen(relayManager, localCache, account, subscriptionsCoordinator)
|
||||
}
|
||||
|
||||
DeckColumnType.Messages -> {
|
||||
|
||||
+8
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -108,6 +109,7 @@ fun SinglePaneLayout(
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
lastRelayEventAt: Long? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
||||
@@ -150,6 +152,12 @@ fun SinglePaneLayout(
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Relay health — shows elapsed time since last event (hidden when <30s)
|
||||
RelayHealthIndicator(
|
||||
lastEventReceivedAt = lastRelayEventAt,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.desktop.viewmodels
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Desktop-specific FeedViewModel that loads existing cache data on creation.
|
||||
*
|
||||
* The base FeedViewModel only sets up event stream collectors — it doesn't
|
||||
* load data already in cache. This means navigating back to a screen would
|
||||
* show Loading forever until a new relay event arrives. This subclass fixes
|
||||
* that by calling refreshSuspended() on init.
|
||||
*/
|
||||
class DesktopFeedViewModel(
|
||||
filter: FeedFilter<Note>,
|
||||
cacheProvider: ICacheProvider,
|
||||
) : FeedViewModel(filter, cacheProvider) {
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
feedState.refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP,
|
||||
* so Desktop composables use this for cleanup via DisposableEffect.
|
||||
*/
|
||||
fun destroy() {
|
||||
viewModelScope.cancel()
|
||||
}
|
||||
}
|
||||
Vendored
+404
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.desktop.cache
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for the Coordinator → Cache → ViewModel pipeline.
|
||||
*
|
||||
* These tests use a stub INostrClient (no real relay connections) and exercise
|
||||
* the full event consumption path through DesktopRelaySubscriptionsCoordinator.
|
||||
*
|
||||
* Key invariant being tested: when coordinator.consumeEvent() is called,
|
||||
* the event should flow through cache → eventStream → ViewModel.feedState.
|
||||
*/
|
||||
class CoordinatorPipelineTest {
|
||||
private val userPubKey = "a".repeat(64)
|
||||
private val followedPubKey = "b".repeat(64)
|
||||
private val dummySig = "0".repeat(128)
|
||||
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
|
||||
|
||||
private suspend fun waitForBundler() = delay(600)
|
||||
|
||||
/**
|
||||
* Stub INostrClient — records subscription calls but doesn't connect to any relay.
|
||||
* This lets us test the coordinator's event routing without network dependencies.
|
||||
*/
|
||||
private class StubNostrClient : INostrClient {
|
||||
val openedSubs = mutableMapOf<String, Pair<Map<NormalizedRelayUrl, List<Filter>>, IRequestListener?>>()
|
||||
|
||||
override fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
|
||||
|
||||
override fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
|
||||
|
||||
override fun connect() {}
|
||||
|
||||
override fun disconnect() {}
|
||||
|
||||
override fun close() {}
|
||||
|
||||
override fun reconnect(
|
||||
onlyIfChanged: Boolean,
|
||||
ignoreRetryDelays: Boolean,
|
||||
) {}
|
||||
|
||||
override fun isActive() = false
|
||||
|
||||
override fun renewFilters(relay: IRelayClient) {}
|
||||
|
||||
override fun openReqSubscription(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
) {
|
||||
openedSubs[subId] = filters to listener
|
||||
}
|
||||
|
||||
override fun queryCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {}
|
||||
|
||||
override fun close(subId: String) {
|
||||
openedSubs.remove(subId)
|
||||
}
|
||||
|
||||
override fun send(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {}
|
||||
|
||||
override fun subscribe(listener: IRelayClientListener) {}
|
||||
|
||||
override fun unsubscribe(listener: IRelayClientListener) {}
|
||||
|
||||
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
|
||||
}
|
||||
|
||||
private fun createCoordinator(
|
||||
cache: DesktopLocalCache,
|
||||
scope: CoroutineScope,
|
||||
): Pair<DesktopRelaySubscriptionsCoordinator, StubNostrClient> {
|
||||
val client = StubNostrClient()
|
||||
val coordinator =
|
||||
DesktopRelaySubscriptionsCoordinator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
indexRelays = setOf(relayUrl),
|
||||
localCache = cache,
|
||||
)
|
||||
return coordinator to client
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Coordinator → Cache → ViewModel flow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `consumeEvent routes text note into cache and triggers ViewModel update`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
waitForBundler()
|
||||
assertIs<FeedState.Empty>(vm.feedState.feedContent.value)
|
||||
|
||||
// Simulate relay event arriving through coordinator
|
||||
val event =
|
||||
TextNoteEvent(
|
||||
id = "n1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = "Hello from relay",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(event, relayUrl)
|
||||
|
||||
waitForBundler()
|
||||
|
||||
val state = vm.feedState.feedContent.value
|
||||
assertIs<FeedState.Loaded>(
|
||||
state,
|
||||
"ViewModel should be Loaded after coordinator.consumeEvent()",
|
||||
)
|
||||
assertTrue(
|
||||
vm.feedState.visibleNotes().any { it.idHex == event.id },
|
||||
"Note should appear in feed",
|
||||
)
|
||||
|
||||
vm.destroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consumeEvent updates lastEventAt timestamp`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially")
|
||||
|
||||
val event =
|
||||
TextNoteEvent(
|
||||
id = "n1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = "test",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(event, relayUrl)
|
||||
waitForBundler()
|
||||
|
||||
assertTrue(coordinator.lastEventAt.value != null, "lastEventAt should be set after consumeEvent")
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contact list consumed via coordinator updates followedUsers`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val contactEvent =
|
||||
ContactListEvent(
|
||||
id = "cl1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = arrayOf(arrayOf("p", followedPubKey)),
|
||||
content = "",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(contactEvent, relayUrl)
|
||||
waitForBundler()
|
||||
|
||||
assertTrue(
|
||||
cache.followedUsers.value.contains(followedPubKey),
|
||||
"followedUsers should contain the followed pubkey after contact list consumption",
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `following feed shows notes after contact list and text notes arrive via coordinator`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
// Step 1: Contact list arrives
|
||||
val contactEvent =
|
||||
ContactListEvent(
|
||||
id = "cl1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = arrayOf(arrayOf("p", followedPubKey)),
|
||||
content = "",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(contactEvent, relayUrl)
|
||||
waitForBundler()
|
||||
|
||||
// Step 2: Create following feed ViewModel
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
waitForBundler()
|
||||
assertIs<FeedState.Empty>(vm.feedState.feedContent.value)
|
||||
|
||||
// Step 3: Text note from followed user arrives
|
||||
val textEvent =
|
||||
TextNoteEvent(
|
||||
id = "n1".padEnd(64, '0'),
|
||||
pubKey = followedPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = "Note from followed user",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(textEvent, relayUrl)
|
||||
waitForBundler()
|
||||
|
||||
val state = vm.feedState.feedContent.value
|
||||
assertIs<FeedState.Loaded>(
|
||||
state,
|
||||
"Following feed should show notes from followed users",
|
||||
)
|
||||
assertTrue(vm.feedState.visibleNotes().size == 1)
|
||||
|
||||
vm.destroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `following feed remains empty when no contact list has been consumed`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
// No contact list consumed — followedUsers is empty
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
waitForBundler()
|
||||
|
||||
// Text note arrives but not from a followed user (because no follows)
|
||||
val textEvent =
|
||||
TextNoteEvent(
|
||||
id = "n1".padEnd(64, '0'),
|
||||
pubKey = followedPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = "Note that won't show",
|
||||
sig = dummySig,
|
||||
)
|
||||
coordinator.consumeEvent(textEvent, relayUrl)
|
||||
waitForBundler()
|
||||
|
||||
assertIs<FeedState.Empty>(
|
||||
vm.feedState.feedContent.value,
|
||||
"Following feed should be empty when no contact list loaded — " +
|
||||
"this is the bug the user sees (0 notes, 0 followed)",
|
||||
)
|
||||
|
||||
vm.destroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Duplicate event handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `duplicate events are not double-counted in feed`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, _) = createCoordinator(cache, scope)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
waitForBundler()
|
||||
|
||||
val event =
|
||||
TextNoteEvent(
|
||||
id = "n1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = "test",
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
// Consume same event twice (can happen with multiple relays)
|
||||
coordinator.consumeEvent(event, relayUrl)
|
||||
coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/"))
|
||||
waitForBundler()
|
||||
|
||||
assertTrue(
|
||||
vm.feedState.visibleNotes().size == 1,
|
||||
"Same event from multiple relays should appear only once",
|
||||
)
|
||||
|
||||
vm.destroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Interaction subscriptions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `requestInteractions opens subscription on client`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, client) = createCoordinator(cache, scope)
|
||||
|
||||
val noteIds = listOf("n1".padEnd(64, '0'))
|
||||
val subId = coordinator.requestInteractions(noteIds, setOf(relayUrl))
|
||||
|
||||
// openReqSubscription is launched in scope — wait for it
|
||||
delay(200)
|
||||
|
||||
assertTrue(client.openedSubs.containsKey(subId), "Interaction subscription should be opened")
|
||||
|
||||
coordinator.releaseInteractions(subId)
|
||||
assertTrue(!client.openedSubs.containsKey(subId), "Subscription should be closed after release")
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestInteractions with empty noteIds returns without opening subscription`() =
|
||||
runBlocking {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val cache = DesktopLocalCache()
|
||||
val (coordinator, client) = createCoordinator(cache, scope)
|
||||
|
||||
coordinator.requestInteractions(emptyList(), setOf(relayUrl))
|
||||
delay(200)
|
||||
|
||||
assertTrue(client.openedSubs.isEmpty(), "Should not open subscription for empty noteIds")
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
Vendored
+608
@@ -0,0 +1,608 @@
|
||||
/*
|
||||
* 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.desktop.cache
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for the Desktop cache → filter → ViewModel pipeline.
|
||||
*
|
||||
* These tests verify that events consumed into DesktopLocalCache flow through
|
||||
* feed filters and into DesktopFeedViewModel's FeedState correctly.
|
||||
*
|
||||
* The test structure mirrors how the app works:
|
||||
* 1. Events arrive from relays
|
||||
* 2. DesktopLocalCache.consume() stores them + emits via eventStream
|
||||
* 3. DesktopFeedViewModel collects eventStream and updates FeedState
|
||||
* 4. FeedFilter determines which notes appear in which feed
|
||||
*/
|
||||
class DesktopCachePipelineTest {
|
||||
// Deterministic test keys
|
||||
private val userPubKey = "a".repeat(64)
|
||||
private val followedPubKey = "b".repeat(64)
|
||||
private val unfollowedPubKey = "c".repeat(64)
|
||||
private val dummySig = "0".repeat(128)
|
||||
private val relayUrl =
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
||||
.NormalizedRelayUrl("wss://relay.test/")
|
||||
|
||||
/** Wait for async bundling (250ms bundler + margin) */
|
||||
private suspend fun waitForBundler() = delay(500)
|
||||
|
||||
private fun textNote(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
content: String = "Hello world",
|
||||
createdAt: Long = System.currentTimeMillis() / 1000,
|
||||
replyToId: String? = null,
|
||||
): TextNoteEvent {
|
||||
val tags =
|
||||
if (replyToId != null) {
|
||||
arrayOf(arrayOf("e", replyToId, "", "reply"))
|
||||
} else {
|
||||
emptyArray()
|
||||
}
|
||||
return TextNoteEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = dummySig,
|
||||
)
|
||||
}
|
||||
|
||||
private fun contactList(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
follows: List<String>,
|
||||
createdAt: Long = System.currentTimeMillis() / 1000,
|
||||
): ContactListEvent =
|
||||
ContactListEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags = follows.map { arrayOf("p", it) }.toTypedArray(),
|
||||
content = "",
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
private fun reaction(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
targetNoteId: String,
|
||||
createdAt: Long = System.currentTimeMillis() / 1000,
|
||||
): ReactionEvent =
|
||||
ReactionEvent(
|
||||
id = id,
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("e", targetNoteId)),
|
||||
content = "+",
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Cache consumption basics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `consume text note creates Note in cache`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
val consumed = cache.consume(event, relayUrl)
|
||||
|
||||
assertTrue(consumed, "First consume should return true")
|
||||
val note = cache.getNoteIfExists("note1".padEnd(64, '0'))
|
||||
assertTrue(note != null, "Note should exist in cache after consume")
|
||||
assertEquals(event.id, note.event?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consume same note twice returns false`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
cache.consume(event, relayUrl)
|
||||
val secondConsume = cache.consume(event, relayUrl)
|
||||
|
||||
assertTrue(!secondConsume, "Second consume of same event should return false")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consume contact list updates followedUsers`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey))
|
||||
|
||||
cache.consume(event, relayUrl)
|
||||
|
||||
assertEquals(setOf(followedPubKey), cache.followedUsers.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer contact list replaces older`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
val newer =
|
||||
contactList(
|
||||
"cl2".padEnd(64, '0'),
|
||||
userPubKey,
|
||||
listOf(followedPubKey, unfollowedPubKey),
|
||||
createdAt = 200,
|
||||
)
|
||||
|
||||
cache.consume(old, relayUrl)
|
||||
cache.consume(newer, relayUrl)
|
||||
|
||||
assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older contact list is rejected`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200)
|
||||
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
|
||||
cache.consume(newer, relayUrl)
|
||||
cache.consume(old, relayUrl)
|
||||
|
||||
assertEquals(
|
||||
setOf(followedPubKey, unfollowedPubKey),
|
||||
cache.followedUsers.value,
|
||||
"Older contact list should not overwrite newer",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consume reaction links to target note`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val noteId = "note1".padEnd(64, '0')
|
||||
val note = textNote(noteId, userPubKey)
|
||||
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId)
|
||||
|
||||
cache.consume(note, relayUrl)
|
||||
cache.consume(react, relayUrl)
|
||||
|
||||
val cachedNote = cache.getNoteIfExists(noteId)!!
|
||||
assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Event stream emission
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `consume emits to eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val collected = mutableListOf<Set<Note>>()
|
||||
|
||||
val job =
|
||||
launch(Dispatchers.IO) {
|
||||
cache.eventStream.newEventBundles.collect { collected.add(it) }
|
||||
}
|
||||
|
||||
// Give collector time to start
|
||||
delay(50)
|
||||
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(event, relayUrl)
|
||||
val note = cache.getNoteIfExists(event.id)!!
|
||||
cache.emitNewNotes(setOf(note))
|
||||
|
||||
delay(100)
|
||||
job.cancel()
|
||||
|
||||
assertTrue(collected.isNotEmpty(), "EventStream should emit after consume + emitNewNotes")
|
||||
assertTrue(collected.any { batch -> batch.any { it.idHex == event.id } })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Filter logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GlobalFeedFilter includes all text notes`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
|
||||
// Add notes from different authors
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl)
|
||||
cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl)
|
||||
|
||||
val feed = filter.feed()
|
||||
assertEquals(3, feed.size, "Global feed should contain all text notes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter only includes notes from followed users`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
|
||||
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val feed = filter.feed()
|
||||
|
||||
assertEquals(1, feed.size, "Following feed should only contain notes from followed users")
|
||||
assertEquals("n1".padEnd(64, '0'), feed[0].idHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter returns empty when no follows`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { emptySet() }
|
||||
val feed = filter.feed()
|
||||
|
||||
assertTrue(feed.isEmpty(), "Following feed should be empty when no follows")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ProfileFeedFilter only shows notes from target pubkey`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl)
|
||||
|
||||
val filter = DesktopProfileFeedFilter(followedPubKey, cache)
|
||||
val feed = filter.feed()
|
||||
|
||||
assertEquals(1, feed.size)
|
||||
assertEquals(followedPubKey, feed[0].author?.pubkeyHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ThreadFilter returns root and replies`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val rootId = "root".padEnd(64, '0')
|
||||
val replyId = "reply".padEnd(64, '0')
|
||||
|
||||
cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl)
|
||||
cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl)
|
||||
|
||||
val filter = DesktopThreadFilter(rootId, cache)
|
||||
val feed = filter.feed()
|
||||
|
||||
assertEquals(2, feed.size, "Thread should contain root + reply")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NotificationFeedFilter shows events tagging user`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val noteId = "note1".padEnd(64, '0')
|
||||
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl)
|
||||
|
||||
// Reaction from someone else targeting user's note
|
||||
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200)
|
||||
cache.consume(react, relayUrl)
|
||||
|
||||
val filter = DesktopNotificationFeedFilter(userPubKey, cache)
|
||||
val feed = filter.feed()
|
||||
|
||||
// ReactionEvent tags "e" not "p" — notification filter requires isTaggedUser
|
||||
// This test documents the current behavior
|
||||
val reactNote = cache.getNoteIfExists("react1".padEnd(64, '0'))
|
||||
val reactEvent = reactNote?.event
|
||||
assertTrue(reactEvent != null, "Reaction event should exist in cache")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. ViewModel integration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ViewModel starts in Loading then transitions to Loaded after refresh`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl)
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
// Wait for init refresh
|
||||
waitForBundler()
|
||||
|
||||
val state = vm.feedState.feedContent.value
|
||||
assertIs<FeedState.Loaded>(state, "After consuming notes and refreshing, state should be Loaded")
|
||||
|
||||
val notes = vm.feedState.visibleNotes()
|
||||
assertEquals(1, notes.size)
|
||||
vm.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ViewModel shows Empty when cache has no matching notes`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
waitForBundler()
|
||||
|
||||
val state = vm.feedState.feedContent.value
|
||||
assertIs<FeedState.Empty>(state, "ViewModel should show Empty when no notes in cache")
|
||||
vm.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ViewModel updates when new notes arrive via eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
waitForBundler()
|
||||
assertIs<FeedState.Empty>(vm.feedState.feedContent.value)
|
||||
|
||||
// Simulate relay event arriving
|
||||
val event = textNote("n1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(event, relayUrl)
|
||||
val note = cache.getNoteIfExists(event.id)!!
|
||||
cache.emitNewNotes(setOf(note))
|
||||
|
||||
waitForBundler()
|
||||
|
||||
val state = vm.feedState.feedContent.value
|
||||
assertIs<FeedState.Loaded>(state, "ViewModel should transition to Loaded after new notes arrive")
|
||||
assertEquals(1, vm.feedState.visibleNotes().size)
|
||||
vm.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Following ViewModel only shows followed users notes via eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
waitForBundler()
|
||||
|
||||
// Add followed user's note
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100)
|
||||
cache.consume(e1, relayUrl)
|
||||
val note1 = cache.getNoteIfExists(e1.id)!!
|
||||
cache.emitNewNotes(setOf(note1))
|
||||
waitForBundler()
|
||||
|
||||
assertEquals(1, vm.feedState.visibleNotes().size, "Should show followed user's note")
|
||||
|
||||
// Add unfollowed user's note
|
||||
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200)
|
||||
cache.consume(e2, relayUrl)
|
||||
val note2 = cache.getNoteIfExists(e2.id)!!
|
||||
cache.emitNewNotes(setOf(note2))
|
||||
waitForBundler()
|
||||
|
||||
assertEquals(1, vm.feedState.visibleNotes().size, "Should NOT show unfollowed user's note")
|
||||
vm.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Following ViewModel feed is empty when followedUsers is empty`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache()
|
||||
// No contact list consumed — followedUsers remains empty
|
||||
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
|
||||
cache.consume(e1, relayUrl)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
waitForBundler()
|
||||
|
||||
assertIs<FeedState.Empty>(
|
||||
vm.feedState.feedContent.value,
|
||||
"Following feed should be empty when no contact list loaded",
|
||||
)
|
||||
vm.destroy()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Cache clear
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `clear resets all cache state`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl)
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assertEquals(0, cache.noteCount())
|
||||
assertEquals(0, cache.userCount())
|
||||
assertTrue(cache.followedUsers.value.isEmpty())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Feed ordering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `global feed is sorted newest first`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl)
|
||||
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl)
|
||||
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl)
|
||||
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
val feed = filter.feed()
|
||||
|
||||
assertEquals("new".padEnd(64, '0'), feed[0].idHex, "Newest note should be first")
|
||||
assertEquals("old".padEnd(64, '0'), feed[2].idHex, "Oldest note should be last")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. Metadata consumption
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `consumeMetadata updates user info`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val metadata =
|
||||
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
|
||||
id = "meta1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""",
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
cache.consume(metadata, relayUrl)
|
||||
|
||||
val user = cache.getUserIfExists(userPubKey)
|
||||
assertTrue(user != null, "User should exist after metadata consumption")
|
||||
// Metadata parsing may vary, but user object should be created
|
||||
assertEquals(userPubKey, user.pubkeyHex)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Additive filter incremental updates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
|
||||
// Create a text note
|
||||
val textEvent = textNote("t1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(textEvent, relayUrl)
|
||||
val textNote = cache.getNoteIfExists(textEvent.id)!!
|
||||
|
||||
// Create a reaction (not a text note)
|
||||
val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0'))
|
||||
cache.consume(reactEvent, relayUrl)
|
||||
val reactNote = cache.getNoteIfExists(reactEvent.id)!!
|
||||
|
||||
val filtered = filter.applyFilter(setOf(textNote, reactNote))
|
||||
|
||||
assertEquals(1, filtered.size, "applyFilter should only pass TextNoteEvents")
|
||||
assertTrue(filtered.first().event is TextNoteEvent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FollowingFeedFilter applyFilter respects follow set`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
|
||||
cache.consume(e1, relayUrl)
|
||||
val note1 = cache.getNoteIfExists(e1.id)!!
|
||||
|
||||
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey)
|
||||
cache.consume(e2, relayUrl)
|
||||
val note2 = cache.getNoteIfExists(e2.id)!!
|
||||
|
||||
val filtered = filter.applyFilter(setOf(note1, note2))
|
||||
|
||||
assertEquals(1, filtered.size, "applyFilter should only include followed users")
|
||||
assertEquals(followedPubKey, filtered.first().author?.pubkeyHex)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 10. Profile count caching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `profile follower count is cached and survives clear of note cache`() {
|
||||
val cache = DesktopLocalCache()
|
||||
|
||||
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
|
||||
|
||||
cache.cacheFollowerCount(userPubKey, 42)
|
||||
assertEquals(42, cache.getCachedFollowerCount(userPubKey))
|
||||
|
||||
// Updating again overwrites
|
||||
cache.cacheFollowerCount(userPubKey, 100)
|
||||
assertEquals(100, cache.getCachedFollowerCount(userPubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile following count is cached`() {
|
||||
val cache = DesktopLocalCache()
|
||||
|
||||
cache.cacheFollowingCount(userPubKey, 150)
|
||||
assertEquals(150, cache.getCachedFollowingCount(userPubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear resets profile count caches`() {
|
||||
val cache = DesktopLocalCache()
|
||||
cache.cacheFollowerCount(userPubKey, 42)
|
||||
cache.cacheFollowingCount(userPubKey, 150)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
|
||||
assertEquals(0, cache.getCachedFollowingCount(userPubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata is available from cache after consumption`() {
|
||||
val cache = DesktopLocalCache()
|
||||
val metadata =
|
||||
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
|
||||
id = "meta1".padEnd(64, '0'),
|
||||
pubKey = userPubKey,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
tags = emptyArray(),
|
||||
content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""",
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
cache.consume(metadata, relayUrl)
|
||||
|
||||
val user = cache.getUserIfExists(userPubKey)!!
|
||||
val cached = user.metadataOrNull()
|
||||
assertTrue(cached != null, "Metadata should be cached after consumption")
|
||||
assertEquals("Test User", cached.bestName())
|
||||
assertEquals(
|
||||
"A test user",
|
||||
cached.flow.value
|
||||
?.info
|
||||
?.about,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Desktop Cache Architecture — Navigation Persistence
|
||||
|
||||
**Date:** 2026-03-17
|
||||
**Status:** Brainstorm
|
||||
**Branch:** `feat/desktop-media` (current), will need dedicated branch
|
||||
|
||||
## What We're Building
|
||||
|
||||
A cache-centric data architecture for Amethyst Desktop that mirrors Android Amethyst's pattern: `DesktopLocalCache` as the single source of truth, `FeedFilter` query objects, and `FeedViewModel` for reactive UI state. This ensures loaded data (notes, metadata, reactions, zaps) survives navigation between screens.
|
||||
|
||||
### The Problem
|
||||
|
||||
- Feed events live in per-screen `EventCollectionState` inside `remember {}` — destroyed on navigation
|
||||
- Navigating search → thread → back loses all loaded notes and search results
|
||||
- Metadata is re-fetched per-screen via `loadMetadataForPubkeys()` even though `DesktopLocalCache` already holds it
|
||||
- Zaps, reactions, reply counts are tracked per-screen in mutable state — lost on navigation
|
||||
- UX feels broken: back navigation shows loading spinners for already-seen data
|
||||
|
||||
### The Goal
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Screen creates EventCollectionState in `remember` | Screen observes FeedViewModel backed by cache |
|
||||
| Events stored per-screen, lost on navigation | Events stored in DesktopLocalCache singleton |
|
||||
| Metadata re-fetched per screen | Metadata cached, available immediately |
|
||||
| Back navigation = full reload | Back navigation = instant (data in cache) |
|
||||
|
||||
## Why This Approach
|
||||
|
||||
**Mirror Android Amethyst's cache-centric design** rather than inventing a new repository pattern:
|
||||
|
||||
1. **Proven pattern** — Android Amethyst handles millions of events this way
|
||||
2. **Shared code** — `FeedFilter`, `FeedViewModel`, `FeedContentState` already exist in `commons/`
|
||||
3. **Future merge safety** — staying aligned with upstream means less divergence
|
||||
4. **Natural fit** — `DesktopLocalCache` already implements `ICacheProvider` and `ICacheEventStream`
|
||||
|
||||
### Android's Architecture (what we're mirroring)
|
||||
|
||||
```
|
||||
Relays → LocalCache (stores ALL events) → ICacheEventStream
|
||||
↓
|
||||
FeedViewModel subscribes
|
||||
↓
|
||||
FeedFilter.feed() queries cache
|
||||
↓
|
||||
FeedContentState (Loading/Loaded/Empty)
|
||||
↓
|
||||
UI collects StateFlow
|
||||
```
|
||||
|
||||
### Desktop's Current Architecture (broken)
|
||||
|
||||
```
|
||||
Relays → Screen composable (EventCollectionState in remember)
|
||||
↓
|
||||
UI renders from local state
|
||||
↓
|
||||
[navigation] → state destroyed → reload from scratch
|
||||
```
|
||||
|
||||
### Desktop's Target Architecture
|
||||
|
||||
```
|
||||
Relays → DesktopLocalCache (stores ALL events) → DesktopCacheEventStream
|
||||
↓
|
||||
FeedViewModel subscribes
|
||||
↓
|
||||
FeedFilter queries cache
|
||||
↓
|
||||
Screen observes FeedContentState
|
||||
↓
|
||||
[navigation] → cache persists → instant back
|
||||
```
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Architecture | Cache-centric (mirror Android) | Proven, shared code, merge-safe |
|
||||
| Migration | Incremental (3 phases) | Each phase is a standalone UX improvement |
|
||||
| Storage | In-memory only (no disk) | Matches Android, sufficient for navigation persistence |
|
||||
| Feed queries | FeedFilter pattern from commons | Already exists, well-tested |
|
||||
| State management | FeedViewModel + FeedContentState | Already in commons, handles Loading/Loaded/Empty |
|
||||
| Thumbnails | Out of scope | Already survive navigation (singleton cache) |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Store ALL events in DesktopLocalCache
|
||||
|
||||
**Goal:** Make the cache the source of truth instead of per-screen state.
|
||||
|
||||
**Changes:**
|
||||
- `DesktopLocalCache`: Store note events (not just metadata) when received from relays
|
||||
- Relay subscription handlers: Call `localCache.consume(event)` for ALL event types
|
||||
- `DesktopCacheEventStream`: Emit to `newEventBundles` / `deletedEventBundles` flows
|
||||
- Zaps, reactions, reposts: Store relationship data in Note model (like Android)
|
||||
|
||||
**What it fixes:** Data accumulates in a singleton — screens can query it on mount.
|
||||
|
||||
### Phase 2: Create Desktop FeedFilters
|
||||
|
||||
**Goal:** Query the cache instead of holding per-screen event lists.
|
||||
|
||||
**Changes:**
|
||||
- `DesktopGlobalFeedFilter` — queries cache for kind 1 events, sorted by createdAt
|
||||
- `DesktopFollowingFeedFilter` — queries cache for events from followed users
|
||||
- `DesktopThreadFilter` — queries cache for root + replies to a note ID
|
||||
- `DesktopProfileFeedFilter` — queries cache for events by a specific pubkey
|
||||
- `DesktopBookmarkFeedFilter` — queries cache for bookmarked event IDs
|
||||
|
||||
**What it fixes:** Screens get data from cache immediately, no relay round-trip on back navigation.
|
||||
|
||||
### Phase 3: Migrate Screens to FeedViewModel
|
||||
|
||||
**Goal:** Replace per-screen `EventCollectionState` with shared `FeedViewModel`.
|
||||
|
||||
**Changes per screen:**
|
||||
- Replace `val eventState = remember { EventCollectionState(...) }` with `val viewModel = remember { FeedViewModel(filter, localCache) }`
|
||||
- Replace `events by eventState.items.collectAsState()` with `feedState by viewModel.feedContent.collectAsState()`
|
||||
- Remove per-screen relay subscription handlers (cache handles it)
|
||||
- Remove per-screen zap/reaction/reply tracking (stored in Note model)
|
||||
|
||||
**Migration order:** FeedScreen → ThreadScreen → UserProfileScreen → SearchResultsList → BookmarksScreen → ReadsScreen → NotificationsScreen
|
||||
|
||||
**What it fixes:** Full navigation persistence, cleaner screen composables, shared ViewModel pattern.
|
||||
|
||||
## Existing Code to Reuse
|
||||
|
||||
| Component | Location | Status |
|
||||
|-----------|----------|--------|
|
||||
| `ICacheProvider` | `commons/model/cache/ICacheProvider.kt` | ✅ Already implemented by DesktopLocalCache |
|
||||
| `ICacheEventStream` | `commons/model/cache/ICacheEventStream.kt` | ✅ Already implemented by DesktopCacheEventStream |
|
||||
| `FeedFilter<T>` | `commons/ui/feeds/FeedFilter.kt` | ✅ Ready to subclass |
|
||||
| `AdditiveFeedFilter<T>` | `commons/ui/feeds/AdditiveFeedFilter.kt` | ✅ Optimized for incremental updates |
|
||||
| `FeedViewModel` | `commons/viewmodels/FeedViewModel.kt` | ⚠️ May need adaptation for desktop lifecycle |
|
||||
| `FeedContentState` | `commons/ui/feeds/FeedContentState.kt` | ✅ Ready to use |
|
||||
| `User` / `Note` models | `commons/model/` | ✅ Already used by DesktopLocalCache |
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| **ViewModel lifecycle** | App-level singletons | Desktop has no Activity lifecycle. Create ViewModels at startup, keep alive forever. Simple and matches desktop mental model. |
|
||||
| **Cache eviction** | LRU eviction | Cap cache per type (e.g., 10k notes, 5k users). Desktop has more RAM but still finite. Defensive choice. |
|
||||
| **Subscription management** | Centralized coordinator | `DesktopRelaySubscriptionsCoordinator` manages all feed subs. Screens request what they need, coordinator deduplicates. Already partially exists. |
|
||||
|
||||
## Resolved Questions (continued)
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| **Event consumption scope** | Full port of Android's consume methods | Future-proof. Port all event kind handlers from Android LocalCache to DesktopLocalCache. |
|
||||
@@ -0,0 +1,569 @@
|
||||
---
|
||||
title: "feat: Desktop Cache-Centric Architecture for Navigation Persistence"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-03-18
|
||||
origin: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Desktop Cache-Centric Architecture for Navigation Persistence
|
||||
|
||||
## Overview
|
||||
|
||||
Migrate Amethyst Desktop from per-screen event state (`EventCollectionState` in `remember {}`) to Android's cache-centric pattern where `DesktopLocalCache` is the single source of truth. Feeds query the cache via `FeedFilter`, `FeedViewModel` subscribes to the cache event stream, and data survives navigation.
|
||||
|
||||
Three-phase incremental migration. Each phase is a standalone PR that improves UX.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
| Symptom | Root Cause |
|
||||
|---------|------------|
|
||||
| Back navigation shows loading spinners | `EventCollectionState` destroyed when composable leaves composition |
|
||||
| Metadata re-fetched per screen | Screens call `loadMetadataForPubkeys()` instead of reading cache |
|
||||
| Zap/reaction counts lost on navigate | Tracked in per-screen mutable state, not in `Note` model |
|
||||
| Search results vanish on thread → back | Search screen's `EventCollectionState` is gone |
|
||||
| Wasted network/relay resources | Same events re-fetched on every navigation; duplicate REQ filters |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Mirror Android Amethyst's architecture (see brainstorm: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`):
|
||||
|
||||
```
|
||||
Relays ──→ DesktopLocalCache.consume(event)
|
||||
│
|
||||
├──→ Store in maps (users, notes, addressableNotes)
|
||||
├──→ Update Note relationships (replies, reactions, zaps)
|
||||
└──→ DesktopCacheEventStream.emitNewNotes(note)
|
||||
│
|
||||
▼
|
||||
FeedViewModel.collect { newNotes →
|
||||
feedState.updateFeedWith(newNotes)
|
||||
}
|
||||
│
|
||||
▼
|
||||
Screen observes feedState.feedContent (Loading/Loaded/Empty)
|
||||
```
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Store ALL Events in DesktopLocalCache
|
||||
|
||||
**Goal:** Make `DesktopLocalCache` the source of truth.
|
||||
|
||||
**Branch:** `feat/desktop-cache-phase1`
|
||||
|
||||
#### 1.1 Switch cache backing to `LargeCache` with size enforcement
|
||||
|
||||
**File:** `desktopApp/.../cache/DesktopLocalCache.kt`
|
||||
|
||||
Replace `ConcurrentHashMap` with `LargeCache` from quartz — same backing store Android uses (`ConcurrentSkipListMap` on JVM), lock-free reads (CAS-based), rich query APIs (`filterIntoSet`, `mapNotNull`, range queries). Desktop keeps strong references (no `WeakReference` wrapper — that's Android-only `LargeSoftCache` for mobile memory pressure).
|
||||
|
||||
`LargeCache` chosen over `LruCache` because:
|
||||
- **Lock-free reads** — `ConcurrentSkipListMap` vs `synchronized` on every `get()`. Critical at 1000 events/sec.
|
||||
- **Rich query API** — `filterIntoSet`, `mapNotNull` match Android's filter patterns exactly.
|
||||
- **No snapshot overhead** — `LruCache.snapshot()` copies the entire map; `LargeCache` iterates in-place.
|
||||
|
||||
Add size enforcement via a `BoundedLargeCache` wrapper that checks size on `put()` and evicts oldest entries when cap is exceeded:
|
||||
|
||||
```kotlin
|
||||
class BoundedLargeCache<K : Comparable<K>, V>(
|
||||
private val maxSize: Int,
|
||||
private val evictPercent: Float = 0.1f, // Remove 10% when cap hit
|
||||
) {
|
||||
private val inner = LargeCache<K, V>()
|
||||
|
||||
fun get(key: K): V? = inner.get(key)
|
||||
fun put(key: K, value: V) {
|
||||
inner.put(key, value)
|
||||
enforceSize()
|
||||
}
|
||||
fun getOrCreate(key: K, builder: (K) -> V): V = inner.getOrCreate(key, builder).also { enforceSize() }
|
||||
fun remove(key: K): V? = inner.remove(key)
|
||||
fun clear() = inner.clear()
|
||||
fun size(): Int = inner.size()
|
||||
fun values(): Iterable<V> = inner.values()
|
||||
fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = inner.filterIntoSet(consumer)
|
||||
// ... delegate other LargeCache methods as needed
|
||||
|
||||
private fun enforceSize() {
|
||||
if (inner.size() > maxSize) {
|
||||
val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1)
|
||||
// ConcurrentSkipListMap keys are sorted — first N keys are "oldest" by insertion order
|
||||
val keys = inner.keys().take(toRemove)
|
||||
keys.forEach { inner.remove(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
private val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
|
||||
private val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
|
||||
private val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
|
||||
|
||||
companion object {
|
||||
const val MAX_NOTES = 50_000 // ~100-150MB at ~2-3KB/note
|
||||
const val MAX_USERS = 25_000 // ~25-50MB at ~1-2KB/user
|
||||
const val MAX_ADDRESSABLE = 10_000
|
||||
}
|
||||
```
|
||||
|
||||
Note: `ConcurrentSkipListMap` keys are sorted, so `keys().take(N)` removes the lexicographically smallest hex keys — not strictly "oldest by time." For true time-based eviction, the `enforceSize()` could sort by `note.event?.createdAt` instead, but the simple key-based approach is cheaper and good enough (hex keys from Nostr events are effectively random, so eviction is approximately random).
|
||||
|
||||
#### 1.2 Port consume methods from Android LocalCache
|
||||
|
||||
**File:** `desktopApp/.../cache/DesktopLocalCache.kt`
|
||||
|
||||
Start with 4 new event kinds (kind 9734 required for zap processing). Add remaining kinds per-screen in Phase 3.
|
||||
|
||||
| Kind | Event Type | Phase | Notes |
|
||||
|------|-----------|-------|-------|
|
||||
| 0 | `MetadataEvent` | Done | Already exists (`consumeMetadata`) |
|
||||
| 1 | `TextNoteEvent` | 1 | Core feed content |
|
||||
| 7 | `ReactionEvent` | 1 | Reaction counts on Note |
|
||||
| 9734 | `LnZapRequestEvent` | 1 | Required before kind 9735 can process |
|
||||
| 9735 | `LnZapEvent` | 1 | Zap counts on Note |
|
||||
|
||||
Each consume method follows Android's pattern. Use `event.tagsWithoutCitations()` for reply parsing (handles both NIP-10 marked and legacy positional tags, excluding inline nostr: citations):
|
||||
|
||||
```kotlin
|
||||
fun consume(event: TextNoteEvent, relay: NormalizedRelayUrl?): Boolean {
|
||||
val note = checkGetOrCreateNote(event.id) ?: return false
|
||||
if (note.event != null) return false // already have it
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
repliesTo.forEach { it.addReply(note) }
|
||||
refreshObservers(note)
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
For reactions, check both `e`-tags and `a`-tags (reactions to addressable events like articles):
|
||||
```kotlin
|
||||
fun consume(event: ReactionEvent, relay: NormalizedRelayUrl?): Boolean {
|
||||
val note = checkGetOrCreateNote(event.id) ?: return false
|
||||
if (note.event != null) return false
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val reactedTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
note.loadEvent(event, author, reactedTo)
|
||||
reactedTo.forEach { it.addReaction(note) }
|
||||
refreshObservers(note)
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Bridge relay callbacks to cache consumption
|
||||
|
||||
**Problem:** Relay `onEvent` callbacks are non-suspend. `emitNewNotes()` is suspend.
|
||||
|
||||
**Solution:** Use `BasicBundledInsert` from commons (already exists, battle-tested in Android) with 250ms delay (snappier than Android's 1000ms — desktop has mains power, users expect faster updates).
|
||||
|
||||
**File:** `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt`
|
||||
|
||||
```kotlin
|
||||
private val eventBundler = BasicBundledInsert<Note>(
|
||||
delay = 250, // 250ms for desktop (Android uses 1000ms to save battery)
|
||||
dispatcher = Dispatchers.IO,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val consumed = localCache.consume(event, relay)
|
||||
if (consumed) {
|
||||
val note = localCache.getNoteIfExists(event.id) as? Note ?: return@launch
|
||||
eventBundler.invalidateList(note) { batch ->
|
||||
localCache.eventStream.emitNewNotes(batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep per-screen `rememberSubscription` — just change the `onEvent` callback to route through cache. Per-screen subscriptions handle lifecycle automatically (auto-cleanup on navigate away).
|
||||
|
||||
```kotlin
|
||||
// Before (per-screen state):
|
||||
onEvent = { event, _, _, _ -> eventState.addItem(event) }
|
||||
|
||||
// After (routes to cache):
|
||||
onEvent = { event, _, relay, _ -> coordinator.consumeEvent(event, relay) }
|
||||
```
|
||||
|
||||
#### 1.4 Fix SharedFlow configuration
|
||||
|
||||
**Problem:** `MutableSharedFlow(replay = 0)` with no buffer drops events and blocks emitters on slow collectors. Android uses `extraBufferCapacity=100, DROP_OLDEST`.
|
||||
|
||||
**File:** `desktopApp/.../cache/DesktopLocalCache.kt` (DesktopCacheEventStream)
|
||||
|
||||
```kotlin
|
||||
class DesktopCacheEventStream : ICacheEventStream {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Dropped emissions are fine — events are already in the cache. The flow signals "something changed," not "here is the data."
|
||||
|
||||
#### 1.5 Set JVM memory limit
|
||||
|
||||
**File:** `desktopApp/build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
compose.desktop.application {
|
||||
jvmArgs += "-Xmx2g"
|
||||
}
|
||||
```
|
||||
|
||||
Size enforcement is handled by `BoundedLargeCache` (section 1.1). `-Xmx2g` is a safety net for the JVM heap overall.
|
||||
|
||||
**Eviction safety:** Feed lists hold strong references to `Note` objects. When `BoundedLargeCache` evicts a key, the `Note` object survives in the feed list (GC won't collect it). On next `FeedFilter.feed()` refresh, the evicted note won't appear — acceptable (feed shows most recent N items). If a user clicks an evicted note, `checkGetOrCreateNote(id)` creates a shell Note that triggers a relay re-fetch.
|
||||
|
||||
#### 1.6 Add cache clear on logout
|
||||
|
||||
**File:** `desktopApp/.../account/AccountManager.kt`
|
||||
|
||||
Cancel coordinator scope BEFORE clearing cache to prevent race between in-flight `consume()` calls and `clear()`.
|
||||
|
||||
```kotlin
|
||||
fun logout() {
|
||||
coordinator.clear() // Stop subscriptions first
|
||||
localCache.clear() // Then clear cache
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 1 Acceptance Criteria
|
||||
|
||||
- [ ] `DesktopLocalCache` backed by `BoundedLargeCache` with size caps (50k notes, 25k users, 10k addressable)
|
||||
- [ ] Consume methods for kinds 1, 7, 9734, 9735
|
||||
- [ ] Relay `onEvent` callbacks route through `coordinator.consumeEvent()`
|
||||
- [ ] `BasicBundledInsert(250ms)` batches events before `emitNewNotes()`
|
||||
- [ ] `DesktopCacheEventStream` has `extraBufferCapacity = 64, DROP_OLDEST`
|
||||
- [ ] `-Xmx2g` JVM arg set
|
||||
- [ ] Cache cleared on logout (coordinator first, then cache)
|
||||
- [ ] `createGlobalFeedSubscription` / `createFollowingFeedSubscription` confirmed routing through `consumeEvent`
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Create Desktop FeedFilters
|
||||
|
||||
**Goal:** Query cache instead of holding per-screen event lists.
|
||||
|
||||
**Branch:** `feat/desktop-cache-phase2`
|
||||
|
||||
#### 2.1 Desktop feed filters
|
||||
|
||||
**Directory:** `desktopApp/.../feeds/`
|
||||
|
||||
| Filter | Query Strategy | Base Class | Limit |
|
||||
|--------|---------------|------------|-------|
|
||||
| `DesktopGlobalFeedFilter` | `notes.filterIntoSet { kind == 1 }` | `AdditiveFeedFilter<Note>` | 2500 |
|
||||
| `DesktopFollowingFeedFilter` | Same + author in account's follow list | `AdditiveFeedFilter<Note>` | 2500 |
|
||||
| `DesktopThreadFilter(noteId)` | Root note + `Note.replies` graph walk | `FeedFilter<Note>` | unlimited |
|
||||
| `DesktopProfileFeedFilter(pubkey)` | `notes.filterIntoSet { author == pubkey }` | `AdditiveFeedFilter<Note>` | 1000 |
|
||||
| `DesktopBookmarkFeedFilter(ids)` | Direct lookup by ID set | `FeedFilter<Note>` | 2500 |
|
||||
| `DesktopReadsFeedFilter` | `notes.filterIntoSet { kind == 30023 }` | `AdditiveFeedFilter<Note>` | 500 |
|
||||
| `DesktopNotificationFeedFilter` | Events tagging logged-in user (reactions, zaps, replies, reposts) | `AdditiveFeedFilter<Note>` | 2500 |
|
||||
| `DesktopSearchFeedFilter(query)` | Cache search + relay search results stored in cache | `AdditiveFeedFilter<Note>` | 500 |
|
||||
|
||||
Limits are ~5x Android's (desktop has more screen space and RAM). Filters use `BoundedLargeCache.filterIntoSet` — same API as Android's `LocalCache`.
|
||||
|
||||
The initial `feed()` scan runs only once on first load or `feedKey()` change. After that, `updateListWith()` uses `applyFilter()` + `sort()` incrementally — O(batch_size) not O(cache_size).
|
||||
|
||||
Following filter reads followed pubkeys from account state (same pattern as Android's `HomeConversationsFeedFilter` which reads `account.liveHomeFollowLists`).
|
||||
|
||||
Notification filter mirrors Android's `NotificationFeedFilter`: filters for events that tag the logged-in user across relevant kinds (kind 1, 6, 7, 9735), with mute list support. Simplified from Android's 20+ kinds to the kinds desktop actually displays.
|
||||
|
||||
```kotlin
|
||||
class DesktopGlobalFeedFilter(
|
||||
private val cache: DesktopLocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes.filterIntoSet { _, note -> note.event?.kind == 1 }
|
||||
.sortedByDescending { it.event?.createdAt ?: 0 }
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
||||
newItems.filter { it.event?.kind == 1 }.toSet()
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> =
|
||||
items.sortedByDescending { it.event?.createdAt ?: 0 }.take(limit())
|
||||
|
||||
override fun limit(): Int = 2500
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 2 Acceptance Criteria
|
||||
|
||||
- [ ] All 8 feed filters implemented and compile
|
||||
- [ ] `applyFilter()` and `sort()` work for incremental updates
|
||||
- [ ] Notification filter correctly identifies events tagging logged-in user
|
||||
- [ ] Following filter reads follow list from account state
|
||||
- [ ] Unit tests for each filter with mock cache data
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Migrate Screens to FeedViewModel
|
||||
|
||||
**Goal:** Replace `EventCollectionState` with `FeedViewModel` pattern across all screens.
|
||||
|
||||
**Branch:** `feat/desktop-cache-phase3`
|
||||
|
||||
#### 3.1 Create DesktopFeedViewModel
|
||||
|
||||
`FeedViewModel.init` in commons only sets up stream collectors — it doesn't load existing cache data. Navigation back would show `Loading` forever until a new relay event arrives. Fix by calling `refreshSuspended()` on init. Consider upstreaming to base `FeedViewModel` in commons.
|
||||
|
||||
**File:** `desktopApp/.../viewmodels/DesktopFeedViewModel.kt`
|
||||
|
||||
```kotlin
|
||||
class DesktopFeedViewModel(
|
||||
filter: FeedFilter<Note>,
|
||||
cacheProvider: ICacheProvider,
|
||||
) : FeedViewModel(filter, cacheProvider) {
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
feedState.refreshSuspended()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 ViewModel lifecycle management
|
||||
|
||||
**Singleton feeds** — created in `Main.kt` alongside other app-level state (`relayManager`, `localCache`, `accountState`). Standard Kotlin JVM pattern: create at app startup, pass down as parameters.
|
||||
|
||||
**File:** `desktopApp/.../Main.kt`
|
||||
|
||||
```kotlin
|
||||
// App-level state (created once)
|
||||
val localCache = DesktopLocalCache()
|
||||
val coordinator = DesktopRelaySubscriptionsCoordinator(localCache, ...)
|
||||
|
||||
// Singleton ViewModels (created once, survive navigation)
|
||||
val globalFeedVM = DesktopFeedViewModel(DesktopGlobalFeedFilter(localCache), localCache)
|
||||
val followingFeedVM = DesktopFeedViewModel(DesktopFollowingFeedFilter(localCache, account), localCache)
|
||||
val readsFeedVM = DesktopFeedViewModel(DesktopReadsFeedFilter(localCache), localCache)
|
||||
val notificationsFeedVM = DesktopFeedViewModel(DesktopNotificationFeedFilter(localCache, account), localCache)
|
||||
```
|
||||
|
||||
Passed to screens as parameters (not CompositionLocal).
|
||||
|
||||
**Parameterized feeds** — use `remember(key)` in Compose. Data survives navigation via the cache, not the ViewModel. New VMs query cache on creation via `init { refreshSuspended() }` for instant results.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ThreadScreen(noteId: String, cache: DesktopLocalCache, ...) {
|
||||
val vm = remember(noteId) {
|
||||
DesktopFeedViewModel(DesktopThreadFilter(noteId, cache), cache)
|
||||
}
|
||||
DisposableEffect(vm) {
|
||||
onDispose { vm.clear() } // Cancel viewModelScope
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Per-screen subscriptions (unchanged)
|
||||
|
||||
Keep `rememberSubscription` in screens — it handles lifecycle automatically. Just change callbacks to route through cache.
|
||||
|
||||
```kotlin
|
||||
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> createGlobalFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
coordinator.consumeEvent(event, relay)
|
||||
},
|
||||
onEose = { _, _ -> eoseReceivedCount++ },
|
||||
)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 Migrate screens with per-screen consume methods
|
||||
|
||||
**Migration order and consume methods needed per screen:**
|
||||
|
||||
| Screen | VM Type | Consume Kinds to Add | Notes |
|
||||
|--------|---------|---------------------|-------|
|
||||
| FeedScreen | Singleton (global + following) | 3 (ContactList), 6 (Repost) | Includes FeedNoteCard rewrite |
|
||||
| ThreadScreen | Parameterized (noteId) | 5 (Deletion) | Deleted note indicators |
|
||||
| UserProfileScreen | Parameterized (pubkey) | — | Uses existing kinds |
|
||||
| SearchResultsList | Parameterized (query) | — | Uses DesktopSearchFeedFilter |
|
||||
| BookmarksScreen | Singleton | 30078 (BookmarkList) | Bookmark state from events |
|
||||
| ReadsScreen | Singleton | 30023 (LongTextNote) | Articles feed |
|
||||
| NotificationsScreen | Singleton | — | Uses DesktopNotificationFeedFilter |
|
||||
|
||||
**FeedNoteCard rewrite** — done alongside FeedScreen migration (first screen). All subsequent screen migrations benefit.
|
||||
|
||||
Current `FeedNoteCard` takes raw `Event` + per-screen counts:
|
||||
```kotlin
|
||||
// CURRENT: 6 per-screen state params
|
||||
FeedNoteCard(event, ..., zapReceipts, reactionCount, replyCount, repostCount, ...)
|
||||
```
|
||||
|
||||
New `FeedNoteCard` takes `Note` from cache — reads counts directly from model:
|
||||
```kotlin
|
||||
// NEW: Note replaces all per-screen count params
|
||||
FeedNoteCard(note, ...) // inside: note.zaps.size, note.countReactions(), note.replies.size, note.boosts.size
|
||||
```
|
||||
|
||||
**Field mapping (per-screen state → Note model):**
|
||||
|
||||
| Per-Screen State Map | Note Model Replacement |
|
||||
|---------------------|----------------------|
|
||||
| `zapsByEvent[id]` → `List<ZapReceipt>` | `note.zaps` → `Map<Note, Note?>` |
|
||||
| `zapReceipts.sumOf { it.amountSats }` | `note.zapsAmount` (BigDecimal) |
|
||||
| `reactionIdsByEvent[id]` → count | `note.countReactions()` |
|
||||
| `replyIdsByEvent[id]` → count | `note.replies.size` |
|
||||
| `repostIdsByEvent[id]` → count | `note.boosts.size` |
|
||||
|
||||
**FeedScreen subscriptions removed** (5 subscriptions, ~130 lines):
|
||||
- `createZapsSubscription` + `zapsByEvent` state map
|
||||
- `createReactionsSubscription` + `reactionIdsByEvent` state map
|
||||
- `createRepliesSubscription` + `replyIdsByEvent` state map
|
||||
- `createRepostsSubscription` + `repostIdsByEvent` state map
|
||||
- `createBatchMetadataSubscription` for zap senders
|
||||
|
||||
These are replaced by `cache.consume()` which populates Note model relationships automatically.
|
||||
|
||||
**Per-screen migration removes:**
|
||||
- `val eventState = remember { EventCollectionState<Event>(...) }`
|
||||
- Per-screen `zapsByEvent`, `reactionIdsByEvent`, `replyIdsByEvent`, `repostIdsByEvent` mutable state maps
|
||||
- 5 per-screen subscription handlers (zaps, reactions, replies, reposts, metadata)
|
||||
|
||||
**Per-screen migration adds:**
|
||||
- `val feedState by viewModel.feedState.feedContent.collectAsState()`
|
||||
- Route `onEvent` to `coordinator.consumeEvent()`
|
||||
- Always use `key` in `items()`: `items(notes.list, key = { it.idHex })`
|
||||
|
||||
```kotlin
|
||||
when (val state = feedState) {
|
||||
is FeedState.Loading -> LoadingState("Loading notes...")
|
||||
is FeedState.Empty -> EmptyState(...)
|
||||
is FeedState.Loaded -> {
|
||||
val notes by state.feed.collectAsState()
|
||||
LazyColumn {
|
||||
items(notes.list, key = { it.idHex }) { note ->
|
||||
FeedNoteCard(note = note, ...)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FeedState.FeedError -> ErrorState(state.errorMessage)
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 3 Acceptance Criteria
|
||||
|
||||
- [ ] `DesktopFeedViewModel` loads cache data on creation (initial `refreshSuspended()`)
|
||||
- [ ] Singleton ViewModels created in `Main.kt` for global/following/reads/notifications
|
||||
- [ ] `remember(key)` + `DisposableEffect` for parameterized feeds (thread, profile, search)
|
||||
- [ ] `FeedNoteCard` rewritten to read from `Note` model (done with FeedScreen migration)
|
||||
- [ ] Per-screen subscriptions route through `consumeEvent()`
|
||||
- [ ] Consume methods added for kinds 3, 5, 6, 30023, 30078
|
||||
- [ ] All 9 screens migrated: Feed, Thread, Profile, Search, Bookmarks, Reads, Notifications
|
||||
- [ ] Per-screen zap/reaction/reply state removed (stored in Note model)
|
||||
- [ ] Navigation back shows instant data (no loading spinner)
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Manual test: Feed → Thread → Back → data persists
|
||||
- [ ] Manual test: Search → Thread → Back → search results preserved
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
```
|
||||
User navigates to Feed
|
||||
→ FeedScreen reads globalFeedViewModel.feedState
|
||||
→ FeedContentState queries DesktopGlobalFeedFilter.feed()
|
||||
→ Filter calls cache.notes.filterIntoSet { kind==1 }
|
||||
→ Returns cached Note objects
|
||||
|
||||
Relay sends new event
|
||||
→ OkHttp callback → coordinator.consumeEvent(event, relay)
|
||||
→ scope.launch(IO) { localCache.consume(event) }
|
||||
→ Note created/updated in cache
|
||||
→ BasicBundledInsert(250ms) batches notes
|
||||
→ eventStream.emitNewNotes(batch)
|
||||
→ FeedViewModel.collect { feedState.updateFeedWith(notes) }
|
||||
→ Compose recomposes
|
||||
|
||||
User navigates away and back
|
||||
→ Singleton VM: feedState already Loaded → instant render
|
||||
→ Parameterized VM: new VM created, init { refreshSuspended() } loads from cache → instant render
|
||||
```
|
||||
|
||||
### Error Propagation
|
||||
|
||||
| Error | Source | Handling |
|
||||
|-------|--------|----------|
|
||||
| Relay disconnect | OkHttp | Coordinator reconnects, no cache impact |
|
||||
| consume() throws | Cache | Caught in consumer coroutine, logged, event skipped |
|
||||
| emitNewNotes() buffer full | SharedFlow | `DROP_OLDEST` — events already in cache |
|
||||
| Filter query on empty cache | FeedFilter | Returns empty list → `FeedState.Empty` |
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Stale data after logout | `coordinator.clear()` then `localCache.clear()` |
|
||||
| Mixed-account data | ViewModels cleared + cache cleared on account switch |
|
||||
| Subscription leak on app exit | Coordinator.clear() in shutdown hook |
|
||||
| Cache memory pressure | `-Xmx2g` + BoundedLargeCache caps (50k notes, 25k users) |
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
| Dependency | Status | Needed For |
|
||||
|------------|--------|------------|
|
||||
| `LargeCache` (quartz) | ✅ In quartz jvmAndroid — `ConcurrentSkipListMap`, lock-free | Phase 1 |
|
||||
| `BasicBundledInsert` (commons) | ✅ In commons `BundledUpdate.kt` | Phase 1 |
|
||||
| `ICacheProvider` / `ICacheEventStream` | ✅ In commons | Phase 1 |
|
||||
| `Note.loadEvent()`, `addReply()`, `addReaction()`, `addZap()` | ✅ In commons | Phase 1 |
|
||||
| `FeedFilter<T>` / `AdditiveFeedFilter<T>` | ✅ In commons | Phase 2 |
|
||||
| `FeedViewModel` / `FeedContentState` | ✅ In commons | Phase 3 |
|
||||
| `kotlinx-coroutines-swing` | ✅ In desktopApp deps | Dispatchers.Main |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Back navigation time | 2-5s (full reload) | <100ms (cache hit) |
|
||||
| Metadata re-fetch on navigate | Every screen | Never (cached) |
|
||||
| Zap/reaction counts on back | Lost | Preserved |
|
||||
|
||||
## Future Improvements (Deferred)
|
||||
|
||||
| Improvement | Trigger |
|
||||
|-------------|---------|
|
||||
| Secondary indexes (by kind, by author) | `feed()` scan >50ms |
|
||||
| Disk persistence (SQLite) | User requests cross-session persistence |
|
||||
| Centralized subscription coordinator | Multiple screens need same relay filter |
|
||||
|
||||
## Sources & References
|
||||
|
||||
- **Origin:** [docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md)
|
||||
- `commons/.../ui/feeds/FeedFilter.kt`, `AdditiveFeedFilter.kt`, `FeedContentState.kt`
|
||||
- `commons/.../viewmodels/FeedViewModel.kt`
|
||||
- `commons/.../model/cache/ICacheProvider.kt`, `ICacheEventStream.kt`
|
||||
- `commons/.../model/Note.kt` — `loadEvent()`, `addReply()`, reactions, zaps
|
||||
- `desktopApp/.../cache/DesktopLocalCache.kt`
|
||||
- `amethyst/.../model/LocalCache.kt` — Android reference
|
||||
- `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md` — subscription stability
|
||||
Reference in New Issue
Block a user