Merge pull request #1905 from nrobi144/feat/desktop-cache-v2

feat(desktop): cache-centric architecture for desktop feeds
This commit is contained in:
Vitor Pamplona
2026-03-27 15:45:34 -04:00
committed by GitHub
36 changed files with 4001 additions and 1253 deletions
@@ -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
@@ -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
@@ -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
@@ -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)
}
@@ -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
}
@@ -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)
}
@@ -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)
}
}
}
}
}
@@ -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
}
}
}
@@ -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>
}
@@ -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)
}
}
}
}
}
@@ -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>()
@@ -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()
}
@@ -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>) {
+2
View File
@@ -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),
)
}
@@ -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
@@ -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
}
@@ -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
@@ -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()
}
}
@@ -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 = { _, _ -> },
@@ -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,
@@ -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")
}
}
}
@@ -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)
}
@@ -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)
}
@@ -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,
@@ -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
@@ -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"
}
}
@@ -220,7 +220,7 @@ internal fun RootContent(
}
DeckColumnType.Notifications -> {
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
NotificationsScreen(relayManager, localCache, account, subscriptionsCoordinator)
}
DeckColumnType.Messages -> {
@@ -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,
@@ -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()
}
}
@@ -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()
}
}
@@ -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