From bab881e75dc61ab8e1098eaf95d58409eb2c1c88 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 6 Feb 2026 18:15:12 -0500 Subject: [PATCH] Moves status to a user property to avoid searching on scrolling --- .../amethyst/model/LocalCache.kt | 57 +++++------------- .../reqCommand/user/UserObservers.kt | 17 +----- .../ui/navigation/drawer/DrawerContent.kt | 18 +++--- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 14 ----- .../amethyst/commons/model/User.kt | 10 +++- .../nip38UserStatuses/UserStatusCache.kt | 59 +++++++++++++++++++ 6 files changed, 90 insertions(+), 85 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 00bcf4513..30a138141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -133,7 +133,6 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent -import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent @@ -198,8 +197,6 @@ import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -1224,33 +1221,15 @@ object LocalCache : ILocalCache, ICacheProvider { relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { - val version = getOrCreateNote(event.id) - val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) + val note = event.toAddressableNote() + val new = consumeBaseReplaceable(event, relay, wasVerified) - val isVerified = - if (version.event == null && (wasVerified || justVerify(event))) { - version.loadEvent(event, author, emptyList()) - version.moveAllReferencesTo(note) - true - } else { - wasVerified - } - - // Already processed this event. - if (note.event?.id == event.id) return false - - if (event.createdAt > (note.createdAt() ?: 0L) && (isVerified || justVerify(event))) { - note.loadEvent(event, author, emptyList()) - - author.flowSet?.statuses?.invalidateData() - - refreshNewNoteObservers(note) - - return true + if (new) { + author.statusState().addStatus(note) } - return false + return new } fun Event.toNote() = getOrCreateNote(id) @@ -1513,6 +1492,10 @@ object LocalCache : ILocalCache, ICacheProvider { getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote) } + if (deleteNote is AddressableNote && deletedEvent is StatusEvent) { + deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote) + } + if (deletedEvent is TorrentCommentEvent) { deletedEvent.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) @@ -2308,23 +2291,6 @@ object LocalCache : ILocalCache, ICacheProvider { fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) - fun findStatusesForUser(user: User): ImmutableList { - checkNotInMainThread() - - return addressables - .filter { _, it -> - val noteEvent = it.event - ( - noteEvent is StatusEvent && - noteEvent.pubKey == user.pubkeyHex && - !noteEvent.isExpired() && - noteEvent.content.isNotBlank() - ) - }.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt }, { it.idHex })) - .reversed() - .toImmutableList() - } - suspend fun findEarliestOtsForNote( note: Note, otsVerifCache: VerificationStateCache, @@ -2628,10 +2594,15 @@ object LocalCache : ILocalCache, ICacheProvider { getAddressableNoteIfExists(it.address)?.removeReport(note) } } + if (note is AddressableNote && noteEvent is ContactCardEvent) { getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note) } + if (note is AddressableNote && noteEvent is StatusEvent) { + note.author?.statusStateOrNull()?.removeStatus(note) + } + note.clearFlow() notes.remove(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 033f24266..251c228fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState @@ -555,21 +554,7 @@ fun observeUserStatuses( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) - // Subscribe in the LocalCache for changes that arrive in the device - val flow = - remember(user) { - user - .flow() - .statuses - .stateFlow - .sample(1000) - .mapLatest { userState -> - LocalCache.findStatusesForUser(userState.user) - }.distinctUntilChanged() - .flowOn(Dispatchers.IO) - } - - return flow.collectAsStateWithLifecycle(persistentListOf()) + return user.statusState().statuses.collectAsStateWithLifecycle(persistentListOf()) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 8d0addbf1..3592e827a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -97,12 +97,12 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog @@ -274,15 +274,15 @@ private fun EditStatusBoxes( accountViewModel: AccountViewModel, nav: INav, ) { - LoadStatuses(user = baseAccountUser, accountViewModel) { statuses -> - if (statuses.isEmpty()) { - StatusEditBar(accountViewModel = accountViewModel, nav = nav) - } else { - statuses.forEach { - val noteStatus by observeNote(it, accountViewModel) + val statuses by observeUserStatuses(baseAccountUser, accountViewModel) - StatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) - } + if (statuses.isEmpty()) { + StatusEditBar(accountViewModel = accountViewModel, nav = nav) + } else { + statuses.forEach { + val noteStatus by observeNote(it, accountViewModel) + + StatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 8e3dd0add..602fdde39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -38,14 +38,11 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.Address -import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -110,17 +107,6 @@ fun LoadAddressableNote( content(note) } -@Composable -fun LoadStatuses( - user: User, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, -) { - val userStatuses by observeUserStatuses(user, accountViewModel) - - content(userStatuses) -} - @Composable fun LoadOts( note: Note, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 33b8733a4..d61dae31f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.nip01Core.UserMetadataCache import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.UserNip05Cache +import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache import com.vitorpamplona.amethyst.commons.util.toShortDisplay @@ -54,6 +55,7 @@ class User( private var reports: UserReportCache? = null private var cards: UserCardsCache? = null private var nip05: UserNip05Cache? = null + private var status: UserStatusCache? = null var zaps = mapOf() private set @@ -218,6 +220,10 @@ class User( } } + fun statusStateOrNull(): UserStatusCache? = status + + fun statusState(): UserStatusCache = status ?: UserStatusCache().also { status = it } + fun containsAny(hiddenWordsCase: List): Boolean { if (hiddenWordsCase.isEmpty()) return false @@ -301,12 +307,10 @@ class UserFlowSet( // Observers line up here. val usedRelays = UserBundledRefresherFlow(u) val zaps = UserBundledRefresherFlow(u) - val statuses = UserBundledRefresherFlow(u) fun isInUse(): Boolean = usedRelays.hasObservers() || - zaps.hasObservers() || - statuses.hasObservers() + zaps.hasObservers() } @Immutable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt new file mode 100644 index 000000000..0f1e98c52 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -0,0 +1,59 @@ +/** + * 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.nip38UserStatuses + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.quartz.nip40Expiration.expiration +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class UserStatusCache : UserDependencies { + val statuses = MutableStateFlow>(persistentListOf()) + + val sortModel: Comparator = + compareBy( + { it.event?.expiration() ?: it.event?.createdAt }, + { it.idHex }, + ) + + fun addStatus(note: AddressableNote) { + // if it's already there, quick exit + if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return + + statuses.update { + (it + note).sortedWith(sortModel).toImmutableList() + } + } + + fun removeStatus(deleteNote: AddressableNote) { + // if it's not already there, quick exit + if (!statuses.value.contains(deleteNote)) return + + statuses.update { + (it - deleteNote).toImmutableList() + } + } +}