From 7f741260d1846893c3d0705c84c5ade2e659ba42 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 2 Dec 2025 16:59:16 -0500 Subject: [PATCH] Adds a cache system for WoT scores --- .../vitorpamplona/amethyst/model/Account.kt | 24 ---- .../amethyst/model/LocalCache.kt | 23 +++- .../com/vitorpamplona/amethyst/model/User.kt | 6 + .../model/trustedAssertions/UserCardsCache.kt | 110 ++++++++++++++++ .../user/UserFinderFilterAssembler.kt | 2 +- .../reqCommand/user/UserObservers.kt | 16 ++- .../user/watchers/FilterContactCardsToKey.kt | 39 +++--- .../user/watchers/UserCardsSubAssembler.kt | 120 +++++++++--------- .../amethyst/ui/note/UserProfilePicture.kt | 74 ++++++----- 9 files changed, 276 insertions(+), 138 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index aedf23b60..b1e5d6ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -121,7 +121,6 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType -import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -210,17 +209,13 @@ import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Locale @@ -1733,25 +1728,6 @@ class Account( ).toSet() } - @OptIn(ExperimentalCoroutinesApi::class) - fun loadUserCardFlow(target: HexKey): Flow = - trustProviderList.liveUserRankProvider - .transformLatest { provider -> - if (provider != null) { - emitAll( - cache - .getOrCreateAddressableNote( - ContactCardEvent.createAddress(provider.pubkey, target), - ).flow() - .metadata.stateFlow, - ) - } else { - emit(null) - } - }.map { - (it?.note?.event as? ContactCardEvent)?.rank() - }.flowOn(Dispatchers.IO) - suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) 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 5394316ad..5042c45e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1093,11 +1093,26 @@ object LocalCache : ILocalCache { return false } + fun Event.toNote() = getOrCreateNote(id) + + fun AddressableEvent.toAddressableNote() = getOrCreateAddressableNote(address()) + fun consume( event: ContactCardEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) + ): Boolean { + val note = event.toAddressableNote() + val new = consumeBaseReplaceable(event, relay, wasVerified) + + if (new) { + println("AABBCC New ContactCard about ${event.aboutUser()}") + val about = checkGetOrCreateUser(event.aboutUser()) ?: return new + about.cards().addCard(note) + } + + return new + } fun consume( event: OtsEvent, @@ -1335,6 +1350,9 @@ object LocalCache : ILocalCache { } } + if (deleteNote is AddressableNote && deletedEvent is ContactCardEvent) { + getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote) + } if (deletedEvent is TorrentCommentEvent) { deletedEvent.torrentIds()?.let { @@ -2460,6 +2478,9 @@ object LocalCache : ILocalCache { getAddressableNoteIfExists(it.address)?.removeReport(note) } } + if (note is AddressableNote && noteEvent is ContactCardEvent) { + getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note) + } note.clearFlow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index e05743468..c81a0b755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.nip56Reports.UserReportCache +import com.vitorpamplona.amethyst.model.trustedAssertions.UserCardsCache import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists @@ -53,6 +54,7 @@ class User( val dmRelayListNote: Note, ) { private var reports: UserReportCache? = null + private var cards: UserCardsCache? = null // private var deps = ScatterMap, UserDependencies>() @@ -227,6 +229,10 @@ class User( // fun reports(): UserReports = deps.getOrPut(UserReports::class) { UserReports() } as UserReports + fun cardsOrNull(): UserCardsCache? = cards + + fun cards(): UserCardsCache = cards ?: UserCardsCache().also { cards = it } + fun containsAny(hiddenWordsCase: List): Boolean { if (hiddenWordsCase.isEmpty()) return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt new file mode 100644 index 000000000..ea19dde3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt @@ -0,0 +1,110 @@ +/** + * 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.model.trustedAssertions + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserDependencies +import com.vitorpamplona.amethyst.service.relays.EOSERelayList +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class UserCardsCache : UserDependencies { + val receivedCards = MutableStateFlow(mapOf()) + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var latestEOSEs: EOSERelayList = EOSERelayList() + + fun addCard(note: AddressableNote) { + val author = note.author ?: return + + val cardBy = receivedCards.value[author] + + // if it's already there, quick exit + if (cardBy != null && cardBy == note) return + + receivedCards.update { + val author = note.author + if (author == null) { + it + } else { + it + (author to note) + } + } + } + + fun removeCard(note: AddressableNote) { + val author = note.author ?: return + val cardBy = receivedCards.value[author] + + // if it's not already there, quick exit + if (cardBy == null || cardBy != note) return + + receivedCards.update { + val author = note.author + if (author == null) { + it + } else { + val reportsByInner = it[author] + if (reportsByInner == null) { + it + } else { + it - author + } + } + } + } + + fun rankFlow(trustProviderList: TrustProviderListState) = + combineTransform(receivedCards, trustProviderList.liveUserRankProvider) { cards, provider -> + if (provider != null) { + val flow = + cards.firstNotNullOfOrNull { + if (it.key.pubkeyHex == provider.pubkey) { + it.value + .flow() + .metadata.stateFlow + } else { + null + } + } + + if (flow != null) { + emitAll(flow) + } else { + emit(null) + } + } else { + emit(null) + } + }.map { + (it?.note?.event as? ContactCardEvent)?.rank() + }.flowOn(Dispatchers.IO) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index c5b0de767..dc4240828 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -47,7 +47,7 @@ class UserFinderFilterAssembler( UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys), UserWatcherSubAssembler(client, cache, ::allKeys), UserReportsSubAssembler(client, cache, ::allKeys), - UserCardsSubAssembler(client, ::allKeys), + UserCardsSubAssembler(client, cache, ::allKeys), ) override fun invalidateFilters() = group.forEach { it.invalidateFilters() } 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 b55ceb383..317cb7dc9 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 @@ -44,7 +44,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach @@ -602,6 +601,21 @@ fun observeUserReportCount( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserContactCardsScore( + user: User, + accountViewModel: AccountViewModel, +): State { + // 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.cards().rankFlow(accountViewModel.account.trustProviderList) } + + return flow.collectAsStateWithLifecycle(null) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserStatuses( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt index 5ac88cc6f..6e72ef1a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -29,27 +28,21 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl val ContactCardKindList = listOf(ContactCardEvent.KIND) -fun filterContactCardsToKeysFromTrusted( +fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( targets: Set, - trustedAccounts: Map>, - since: SincePerRelayMap?, -): List { - if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() - val sortedTargets = mapOf("d" to targets.sorted()) - return trustedAccounts.mapNotNull { relayAuthors -> - if (relayAuthors.value.isNotEmpty()) { - RelayBasedFilter( - relay = relayAuthors.key, - filter = - Filter( - kinds = ContactCardKindList, - authors = relayAuthors.value.sorted(), - tags = sortedTargets, - since = since?.get(relayAuthors.key)?.time, - ), - ) - } else { - null - } - } + trustedAccounts: List, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter? { + if (targets.isEmpty() || trustedAccounts.isEmpty()) return null + return RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = ContactCardKindList, + authors = trustedAccounts, + tags = mapOf("d" to targets.sorted()), + since = since, + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index f82183c63..b8879fa77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.toHexSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -32,26 +33,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.component1 +import kotlin.collections.component2 class UserCardsSubAssembler( client: INostrClient, + val cache: LocalCache, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { - var lastUsersOnFilter: Set = emptySet() - - /** - * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc - * and reports, but only from trusted accounts (follows of all logged in users). - */ - var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) - override fun newEose( relay: NormalizedRelayUrl, time: Long, filters: List?, ) { - lastUsersOnFilter.forEach { - latestEOSEs.newEose(it, relay, time) + filters?.forEach { filter -> + filter.tags?.get("p")?.forEach { + val targetUser = cache.getUserIfExists(it) + targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time) + } } super.newEose(relay, time, filters) } @@ -62,7 +61,7 @@ class UserCardsSubAssembler( ): List? { if (keys.isEmpty()) return null - lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } + val lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } if (lastUsersOnFilter.isEmpty()) return null @@ -71,7 +70,7 @@ class UserCardsSubAssembler( val trustedAccounts: Map> = mapOfSet { accounts.forEach { account -> - account.outboxRelays.flow.value.map { + account.homeRelays.flow.value.map { add(it, account.userProfile().pubkeyHex) } } @@ -82,65 +81,70 @@ class UserCardsSubAssembler( } } - return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys) - .map { group -> - val groupIds = group.map { it.pubkeyHex }.toSet() - if (groupIds.isNotEmpty()) { - val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) - filterContactCardsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) - } else { - emptyList() - } - }.flatten() + return trustedAccounts + .flatMap { (relay, trustedUsersInThisRelay) -> + // this relay + accounts are where we could find cards. + // we might have already loaded them, so let's separate new targets that were checked before from the others + val groups = groupByRelayPresence(lastUsersOnFilter, relay) + val trustedAccounts = trustedUsersInThisRelay.sorted() + listOfNotNull( + filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + targets = groups.usersWithoutEose.toHexSet(), + trustedAccounts = trustedAccounts, + relay = relay, + since = null, + ), + filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + targets = groups.usersWithEose.toHexSet(), + trustedAccounts = trustedAccounts, + relay = relay, + since = findMinimumEOSEsForUsers(groups.usersWithEose, relay), + ), + ) + } } + class PresenceGroup( + val usersWithEose: List = emptyList(), + val usersWithoutEose: List = emptyList(), + ) + fun groupByRelayPresence( - users: Iterable, - eoseCache: EOSEAccountFast, - inRelays: Set, - ): Collection> { - if (users.none()) return emptyList() + targetUsers: Iterable, + relay: NormalizedRelayUrl, + ): PresenceGroup { + if (targetUsers.none()) return PresenceGroup() - val relaySnapshot = inRelays.toSet() - - return users - .groupBy { user -> - val relaysForUser = eoseCache.sinceRelaySet(user) - if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) { - null - } else { - val intersection = relaysForUser.filter { it in relaySnapshot }.sorted() - if (intersection.isEmpty()) { - null - } else { - intersection.hashCode() - } - } - }.values - .map { - // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again - it.sortedBy { it.pubkeyHex } + val groups = + targetUsers.groupBy { user -> + relay in + user + .cards() + .latestEOSEs.relayList.keys } + + return PresenceGroup( + groups[true]?.sortedBy { it.pubkeyHex } ?: emptyList(), + groups[false]?.sortedBy { it.pubkeyHex } ?: emptyList(), + ) } fun findMinimumEOSEsForUsers( users: List, - eoseCache: EOSEAccountFast, - ): SincePerRelayMap { - val minLatestEOSEs = mutableMapOf() + relay: NormalizedRelayUrl, + ): Long? { + var min: MutableTime? = null users.forEach { - eoseCache.since(it)?.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, it.value.copy()) - } else { - minEose.updateIfOlder(it.value.time) - } + val eose = it.cards().latestEOSEs.since()[relay] + if (min != null && eose != null) { + min.updateIfOlder(eose.time) + } else if (eose != null) { + min = MutableTime(eose.time) } } - return minLatestEOSEs + return min?.time } override fun distinct(key: UserFinderQueryState) = key.user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 4fe46fe61..1bc87a6fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -46,6 +46,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -368,11 +369,7 @@ fun BaseUserPicture( } } - WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score -> - if (score != null) { - ScoreTag(score, size, Modifier.align(Alignment.BottomCenter)) - } - } + ObserveAndRenderUserCards(baseUser, size, Modifier.align(Alignment.BottomCenter), accountViewModel) } } @@ -385,15 +382,21 @@ fun BaseUserPicture( outerModifier: Modifier = Modifier.size(size), ) { Box(outerModifier, contentAlignment = Alignment.TopEnd) { - LoadUserProfilePicture(baseUserHex, accountViewModel) { userProfilePicture, userName -> - InnerUserPicture( - userHex = baseUserHex, - userPicture = userProfilePicture, - userName = userName, - size = size, - modifier = innerModifier, - accountViewModel = accountViewModel, - ) + LoadUser(baseUserHex, accountViewModel) { + if (it != null) { + ObserveAndDrawInnerUserPicture(it, size, accountViewModel, innerModifier) + + ObserveAndRenderUserCards(it, size, Modifier.align(Alignment.BottomCenter), accountViewModel) + } else { + InnerUserPicture( + userHex = baseUserHex, + userPicture = null, + userName = null, + size = size, + modifier = innerModifier, + accountViewModel = accountViewModel, + ) + } } WatchUserFollows(baseUserHex, accountViewModel) { newFollowingState -> @@ -401,15 +404,28 @@ fun BaseUserPicture( FollowingIcon(Modifier.size(size.div(3.5f))) } } - - WatchUserCards(baseUserHex, accountViewModel) { score -> - if (score != null) { - ScoreTag(score, size, Modifier.align(Alignment.BottomCenter)) - } - } } } +@Composable +fun ObserveAndDrawInnerUserPicture( + user: User, + size: Dp, + accountViewModel: AccountViewModel, + innerModifier: Modifier = Modifier, +) { + val userProfile by observeUserInfo(user, accountViewModel) + + InnerUserPicture( + userHex = user.pubkeyHex, + userPicture = userProfile?.profilePicture(), + userName = userProfile?.bestName(), + size = size, + modifier = innerModifier, + accountViewModel = accountViewModel, + ) +} + @Preview @Composable fun ScoreTag55Preview() { @@ -579,17 +595,15 @@ fun WatchUserFollows( } @Composable -fun WatchUserCards( - userHex: String, +fun ObserveAndRenderUserCards( + user: User, + size: Dp, + modifier: Modifier, accountViewModel: AccountViewModel, - onScoreChanges: @Composable (Int?) -> Unit, ) { - val flow = - remember(userHex) { - accountViewModel.account.loadUserCardFlow(userHex) - } + val score by observeUserContactCardsScore(user, accountViewModel) - val score by flow.collectAsStateWithLifecycle(null) - - onScoreChanges(score) + score?.let { + ScoreTag(it, size, modifier) + } }