From 3750c7e26e857634d365dcf6e7eae72a6d4e2afd Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 1 Jan 2026 07:32:44 +0200 Subject: [PATCH] fixes --- .../LabeledBookmarkListsState.kt | 8 +- .../nip51Lists/peopleList/FollowListsState.kt | 8 +- .../nip51Lists/peopleList/PeopleListsState.kt | 8 +- .../user/watchers/UserCardsSubAssembler.kt | 2 +- .../user/watchers/UserReportsSubAssembler.kt | 2 +- .../amethyst/commons/model/User.kt | 355 ++++++++++++++++++ 6 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index a30a4e764..7859c198a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -24,11 +24,11 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 1c2078fa9..2bc5b7e87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 2ec777a93..b145b602e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update 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 991815b9d..9222d1bc8 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 @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.toHexSet +import com.vitorpamplona.amethyst.commons.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.MutableTime diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 0c5146654..e0c0da81f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.toHexSet +import com.vitorpamplona.amethyst.commons.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.MutableTime 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 new file mode 100644 index 000000000..08e1af1fd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -0,0 +1,355 @@ +/** + * 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 + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache +import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.containsAny +import kotlinx.coroutines.flow.MutableStateFlow +import java.math.BigDecimal + +interface UserDependencies + +@Stable +class User( + val pubkeyHex: String, + val nip65RelayListNote: Note, + val dmRelayListNote: Note, + private val cacheProvider: ICacheProvider? = null, +) { + private var reports: UserReportCache? = null + private var cards: UserCardsCache? = null + + // private var deps = ScatterMap, UserDependencies>() + + var info: UserMetadata? = null + + var latestMetadata: MetadataEvent? = null + var latestMetadataRelay: NormalizedRelayUrl? = null + var latestContactList: ContactListEvent? = null + + var zaps = mapOf() + private set + + var relaysBeingUsed = mapOf() + private set + + var flowSet: UserFlowSet? = null + + fun pubkey() = Hex.decode(pubkeyHex) + + fun pubkeyNpub() = pubkey().toNpub() + + fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() + + fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent + + fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent + + fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) + + fun outboxRelays() = authorRelayList()?.writeRelaysNorm() + + fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) + + fun inboxRelays() = authorRelayList()?.readRelaysNorm() + + fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() + + fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay + + fun toPTag() = PTag(pubkeyHex, bestRelayHint()) + + fun toNostrUri() = "nostr:${toNProfile()}" + + fun toBestShortFirstName(): String { + val fullName = toBestDisplayName() + + val names = fullName.split(' ') + + val firstName = + if (names[0].length <= 3) { + // too short. Remove Dr. + "${names[0]} ${names.getOrNull(1) ?: ""}" + } else { + names[0] + } + + return firstName + } + + fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex() + + fun nip05(): String? = info?.nip05 + + fun profilePicture(): String? = info?.picture + + fun updateContactList(event: ContactListEvent) { + if (event.id == latestContactList?.id) return + + val oldContactListEvent = latestContactList + latestContactList = event + + // Update following of the current user + flowSet?.follows?.invalidateData() + + // Update Followers of the past user list + // Update Followers of the new contact list + (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { + (cacheProvider?.getUserIfExists(it) as? User) + ?.flowSet + ?.followers + ?.invalidateData() + } + (latestContactList)?.unverifiedFollowKeySet()?.forEach { + (cacheProvider?.getUserIfExists(it) as? User) + ?.flowSet + ?.followers + ?.invalidateData() + } + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + flowSet?.zaps?.invalidateData() + } + } + + fun removeZap(zapRequestOrZapEvent: Note) { + if (zaps.containsKey(zapRequestOrZapEvent)) { + zaps = zaps.minus(zapRequestOrZapEvent) + flowSet?.zaps?.invalidateData() + } else if (zaps.containsValue(zapRequestOrZapEvent)) { + zaps = zaps.filter { it.value != zapRequestOrZapEvent } + flowSet?.zaps?.invalidateData() + } + } + + fun zappedAmount(): BigDecimal { + var amount = BigDecimal.ZERO + zaps.forEach { + val itemValue = (it.value?.event as? LnZapEvent)?.amount + if (itemValue != null) { + amount += itemValue + } + } + + return amount + } + + fun addRelayBeingUsed( + relay: NormalizedRelayUrl, + eventTime: Long, + ) { + val here = relaysBeingUsed[relay] + if (here == null) { + relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1)) + } else { + if (eventTime > here.lastEvent) { + here.lastEvent = eventTime + } + here.counter++ + } + + flowSet?.usedRelays?.invalidateData() + } + + fun updateUserInfo( + newUserInfo: UserMetadata, + latestMetadata: MetadataEvent, + ) { + info = newUserInfo + info?.tags = latestMetadata.tags.toImmutableListOfLists() + info?.cleanBlankNames() + + if (newUserInfo.lud16.isNullOrBlank()) { + info?.lud06?.let { + if (it.lowercase().startsWith("lnurl")) { + info?.lud16 = Lud06().toLud16(it) + } + } + } + + flowSet?.metadata?.invalidateData() + } + + fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false + + fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size + + fun transientFollowerCount(): Int = + cacheProvider?.countUsers { _, it -> + (it as? User)?.latestContactList?.isTaggedUser(pubkeyHex) ?: false + } ?: 0 + + fun reportsOrNull(): UserReportCache? = reports + + fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it } + + // fun reportsOrNull(): UserReports? = deps[UserReports::class] 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): Boolean { + if (hiddenWordsCase.isEmpty()) return false + + if (toBestDisplayName().containsAny(hiddenWordsCase)) { + return true + } + + if (profilePicture()?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.banner?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.about?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.lud06?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.lud16?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.nip05?.containsAny(hiddenWordsCase) == true) { + return true + } + + return false + } + + fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false + + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = UserFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } + } + } + + fun flow(): UserFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) + } + return flowSet!! + } + + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) + } + } +} + +@Stable +class UserFlowSet( + u: User, +) { + // Observers line up here. + val metadata = UserBundledRefresherFlow(u) + val follows = UserBundledRefresherFlow(u) + val followers = UserBundledRefresherFlow(u) + val usedRelays = UserBundledRefresherFlow(u) + val zaps = UserBundledRefresherFlow(u) + val statuses = UserBundledRefresherFlow(u) + + fun isInUse(): Boolean = + metadata.hasObservers() || + follows.hasObservers() || + followers.hasObservers() || + usedRelays.hasObservers() || + zaps.hasObservers() || + statuses.hasObservers() +} + +@Immutable +data class RelayInfo( + val url: NormalizedRelayUrl, + var lastEvent: Long, + var counter: Long, +) + +@Stable +class UserBundledRefresherFlow( + val user: User, +) { + val stateFlow = MutableStateFlow(UserState(user)) + + fun invalidateData() { + stateFlow.tryEmit(UserState(user)) + } + + fun hasObservers() = stateFlow.subscriptionCount.value > 0 +} + +@Immutable +class UserState( + val user: User, +) + +fun Set.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } + +fun Set.toSortedHexes() = map { it.pubkeyHex }.sorted() + +fun List.toHexes() = map { it.pubkeyHex } + +fun List.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } + +fun List.toSortedHexes() = map { it.pubkeyHex }.sorted()