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.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<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 savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
@@ -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()
@@ -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<KClass<out UserDependencies>, 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<DualCase>): Boolean {
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),
UserWatcherSubAssembler(client, cache, ::allKeys),
UserReportsSubAssembler(client, cache, ::allKeys),
UserCardsSubAssembler(client, ::allKeys),
UserCardsSubAssembler(client, cache, ::allKeys),
)
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
@@ -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<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)
@Composable
fun observeUserStatuses(
@@ -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<HexKey>,
trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<HexKey>,
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,
),
)
}
@@ -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<UserFinderQueryState>,
) : 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(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
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<RelayBasedFilter>? {
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<NormalizedRelayUrl, Set<HexKey>> =
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<User> = emptyList(),
val usersWithoutEose: List<User> = emptyList(),
)
fun groupByRelayPresence(
users: Iterable<User>,
eoseCache: EOSEAccountFast<User>,
inRelays: Set<NormalizedRelayUrl>,
): Collection<List<User>> {
if (users.none()) return emptyList()
targetUsers: Iterable<User>,
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<User>,
eoseCache: EOSEAccountFast<User>,
): SincePerRelayMap {
val minLatestEOSEs = mutableMapOf<NormalizedRelayUrl, MutableTime>()
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
@@ -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)
}
}