Adds a cache system for WoT scores

This commit is contained in:
Vitor Pamplona
2025-12-02 16:59:16 -05:00
parent 9e1f863a67
commit 7f741260d1
9 changed files with 276 additions and 138 deletions
@@ -121,7 +121,6 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension
import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType 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.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -210,17 +209,13 @@ import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@@ -1733,25 +1728,6 @@ class Account(
).toSet() ).toSet()
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun loadUserCardFlow(target: HexKey): Flow<Int?> =
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<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) suspend fun saveDMRelayList(dmRelays: List<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays))
suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
@@ -1093,11 +1093,26 @@ object LocalCache : ILocalCache {
return false return false
} }
fun Event.toNote() = getOrCreateNote(id)
fun AddressableEvent.toAddressableNote() = getOrCreateAddressableNote(address())
fun consume( fun consume(
event: ContactCardEvent, event: ContactCardEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
wasVerified: Boolean, 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( fun consume(
event: OtsEvent, 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) { if (deletedEvent is TorrentCommentEvent) {
deletedEvent.torrentIds()?.let { deletedEvent.torrentIds()?.let {
@@ -2460,6 +2478,9 @@ object LocalCache : ILocalCache {
getAddressableNoteIfExists(it.address)?.removeReport(note) getAddressableNoteIfExists(it.address)?.removeReport(note)
} }
} }
if (note is AddressableNote && noteEvent is ContactCardEvent) {
getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
note.clearFlow() note.clearFlow()
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.nip56Reports.UserReportCache import com.vitorpamplona.amethyst.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
@@ -53,6 +54,7 @@ class User(
val dmRelayListNote: Note, val dmRelayListNote: Note,
) { ) {
private var reports: UserReportCache? = null private var reports: UserReportCache? = null
private var cards: UserCardsCache? = null
// private var deps = ScatterMap<KClass<out UserDependencies>, UserDependencies>() // private var deps = ScatterMap<KClass<out UserDependencies>, UserDependencies>()
@@ -227,6 +229,10 @@ class User(
// fun reports(): UserReports = deps.getOrPut(UserReports::class) { UserReports() } as UserReports // 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<DualCase>): Boolean { fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false if (hiddenWordsCase.isEmpty()) return false
@@ -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<User, AddressableNote>())
/**
* 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)
}
@@ -47,7 +47,7 @@ class UserFinderFilterAssembler(
UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys), UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys), UserWatcherSubAssembler(client, cache, ::allKeys),
UserReportsSubAssembler(client, cache, ::allKeys), UserReportsSubAssembler(client, cache, ::allKeys),
UserCardsSubAssembler(client, ::allKeys), UserCardsSubAssembler(client, cache, ::allKeys),
) )
override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
@@ -44,7 +44,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -602,6 +601,21 @@ fun observeUserReportCount(
return flow.collectAsStateWithLifecycle(0) return flow.collectAsStateWithLifecycle(0)
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserContactCardsScore(
user: User,
accountViewModel: AccountViewModel,
): State<Int?> {
// 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) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserStatuses( fun observeUserStatuses(
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers 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.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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) val ContactCardKindList = listOf(ContactCardEvent.KIND)
fun filterContactCardsToKeysFromTrusted( fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
targets: Set<HexKey>, targets: Set<HexKey>,
trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>>, trustedAccounts: List<HexKey>,
since: SincePerRelayMap?, relay: NormalizedRelayUrl,
): List<RelayBasedFilter> { since: Long?,
if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() ): RelayBasedFilter? {
val sortedTargets = mapOf("d" to targets.sorted()) if (targets.isEmpty() || trustedAccounts.isEmpty()) return null
return trustedAccounts.mapNotNull { relayAuthors -> return RelayBasedFilter(
if (relayAuthors.value.isNotEmpty()) { relay = relay,
RelayBasedFilter( filter =
relay = relayAuthors.key, Filter(
filter = kinds = ContactCardKindList,
Filter( authors = trustedAccounts,
kinds = ContactCardKindList, tags = mapOf("d" to targets.sorted()),
authors = relayAuthors.value.sorted(), since = since,
tags = sortedTargets, ),
since = since?.get(relayAuthors.key)?.time, )
),
)
} else {
null
}
}
} }
@@ -20,10 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers 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.User
import com.vitorpamplona.amethyst.model.toHexSet
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState 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.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.ammolite.relays.filters.MutableTime
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.component1
import kotlin.collections.component2
class UserCardsSubAssembler( class UserCardsSubAssembler(
client: INostrClient, client: INostrClient,
val cache: LocalCache,
allKeys: () -> Set<UserFinderQueryState>, allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) { ) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
var lastUsersOnFilter: Set<User> = 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<User> = EOSEAccountFast<User>(2000)
override fun newEose( override fun newEose(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
time: Long, time: Long,
filters: List<Filter>?, filters: List<Filter>?,
) { ) {
lastUsersOnFilter.forEach { filters?.forEach { filter ->
latestEOSEs.newEose(it, relay, time) filter.tags?.get("p")?.forEach {
val targetUser = cache.getUserIfExists(it)
targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time)
}
} }
super.newEose(relay, time, filters) super.newEose(relay, time, filters)
} }
@@ -62,7 +61,7 @@ class UserCardsSubAssembler(
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null if (keys.isEmpty()) return null
lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user } val lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user }
if (lastUsersOnFilter.isEmpty()) return null if (lastUsersOnFilter.isEmpty()) return null
@@ -71,7 +70,7 @@ class UserCardsSubAssembler(
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> = val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet { mapOfSet {
accounts.forEach { account -> accounts.forEach { account ->
account.outboxRelays.flow.value.map { account.homeRelays.flow.value.map {
add(it, account.userProfile().pubkeyHex) add(it, account.userProfile().pubkeyHex)
} }
} }
@@ -82,65 +81,70 @@ class UserCardsSubAssembler(
} }
} }
return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys) return trustedAccounts
.map { group -> .flatMap { (relay, trustedUsersInThisRelay) ->
val groupIds = group.map { it.pubkeyHex }.toSet() // this relay + accounts are where we could find cards.
if (groupIds.isNotEmpty()) { // we might have already loaded them, so let's separate new targets that were checked before from the others
val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) val groups = groupByRelayPresence(lastUsersOnFilter, relay)
filterContactCardsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) val trustedAccounts = trustedUsersInThisRelay.sorted()
} else { listOfNotNull(
emptyList() filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
} targets = groups.usersWithoutEose.toHexSet(),
}.flatten() 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<User> = emptyList(),
val usersWithoutEose: List<User> = emptyList(),
)
fun groupByRelayPresence( fun groupByRelayPresence(
users: Iterable<User>, targetUsers: Iterable<User>,
eoseCache: EOSEAccountFast<User>, relay: NormalizedRelayUrl,
inRelays: Set<NormalizedRelayUrl>, ): PresenceGroup {
): Collection<List<User>> { if (targetUsers.none()) return PresenceGroup()
if (users.none()) return emptyList()
val relaySnapshot = inRelays.toSet() val groups =
targetUsers.groupBy { user ->
return users relay in
.groupBy { user -> user
val relaysForUser = eoseCache.sinceRelaySet(user) .cards()
if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) { .latestEOSEs.relayList.keys
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 }
} }
return PresenceGroup(
groups[true]?.sortedBy { it.pubkeyHex } ?: emptyList(),
groups[false]?.sortedBy { it.pubkeyHex } ?: emptyList(),
)
} }
fun findMinimumEOSEsForUsers( fun findMinimumEOSEsForUsers(
users: List<User>, users: List<User>,
eoseCache: EOSEAccountFast<User>, relay: NormalizedRelayUrl,
): SincePerRelayMap { ): Long? {
val minLatestEOSEs = mutableMapOf<NormalizedRelayUrl, MutableTime>() var min: MutableTime? = null
users.forEach { users.forEach {
eoseCache.since(it)?.forEach { val eose = it.cards().latestEOSEs.since()[relay]
val minEose = minLatestEOSEs[it.key] if (min != null && eose != null) {
if (minEose == null) { min.updateIfOlder(eose.time)
minLatestEOSEs.put(it.key, it.value.copy()) } else if (eose != null) {
} else { min = MutableTime(eose.time)
minEose.updateIfOlder(it.value.time)
}
} }
} }
return minLatestEOSEs return min?.time
} }
override fun distinct(key: UserFinderQueryState) = key.user override fun distinct(key: UserFinderQueryState) = key.user
@@ -46,6 +46,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -368,11 +369,7 @@ fun BaseUserPicture(
} }
} }
WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score -> ObserveAndRenderUserCards(baseUser, size, Modifier.align(Alignment.BottomCenter), accountViewModel)
if (score != null) {
ScoreTag(score, size, Modifier.align(Alignment.BottomCenter))
}
}
} }
} }
@@ -385,15 +382,21 @@ fun BaseUserPicture(
outerModifier: Modifier = Modifier.size(size), outerModifier: Modifier = Modifier.size(size),
) { ) {
Box(outerModifier, contentAlignment = Alignment.TopEnd) { Box(outerModifier, contentAlignment = Alignment.TopEnd) {
LoadUserProfilePicture(baseUserHex, accountViewModel) { userProfilePicture, userName -> LoadUser(baseUserHex, accountViewModel) {
InnerUserPicture( if (it != null) {
userHex = baseUserHex, ObserveAndDrawInnerUserPicture(it, size, accountViewModel, innerModifier)
userPicture = userProfilePicture,
userName = userName, ObserveAndRenderUserCards(it, size, Modifier.align(Alignment.BottomCenter), accountViewModel)
size = size, } else {
modifier = innerModifier, InnerUserPicture(
accountViewModel = accountViewModel, userHex = baseUserHex,
) userPicture = null,
userName = null,
size = size,
modifier = innerModifier,
accountViewModel = accountViewModel,
)
}
} }
WatchUserFollows(baseUserHex, accountViewModel) { newFollowingState -> WatchUserFollows(baseUserHex, accountViewModel) { newFollowingState ->
@@ -401,15 +404,28 @@ fun BaseUserPicture(
FollowingIcon(Modifier.size(size.div(3.5f))) 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 @Preview
@Composable @Composable
fun ScoreTag55Preview() { fun ScoreTag55Preview() {
@@ -579,17 +595,15 @@ fun WatchUserFollows(
} }
@Composable @Composable
fun WatchUserCards( fun ObserveAndRenderUserCards(
userHex: String, user: User,
size: Dp,
modifier: Modifier,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onScoreChanges: @Composable (Int?) -> Unit,
) { ) {
val flow = val score by observeUserContactCardsScore(user, accountViewModel)
remember(userHex) {
accountViewModel.account.loadUserCardFlow(userHex)
}
val score by flow.collectAsStateWithLifecycle(null) score?.let {
ScoreTag(it, size, modifier)
onScoreChanges(score) }
} }