Moves status to a user property to avoid searching on scrolling

This commit is contained in:
Vitor Pamplona
2026-02-06 18:15:12 -05:00
parent ec629ff081
commit bab881e75d
6 changed files with 90 additions and 85 deletions
@@ -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<AddressableNote> = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex)
fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
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)
@@ -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)
@@ -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)
}
}
}
@@ -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<AddressableNote>) -> Unit,
) {
val userStatuses by observeUserStatuses(user, accountViewModel)
content(userStatuses)
}
@Composable
fun LoadOts(
note: Note,
@@ -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<Note, Note?>()
private set
@@ -218,6 +220,10 @@ class User(
}
}
fun statusStateOrNull(): UserStatusCache? = status
fun statusState(): UserStatusCache = status ?: UserStatusCache().also { status = it }
fun containsAny(hiddenWordsCase: List<DualCase>): 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
@@ -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<ImmutableList<AddressableNote>>(persistentListOf())
val sortModel: Comparator<Note> =
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()
}
}
}