From 52d5f748ee5b77691c793c38e1dabacc33847d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:57:55 +0000 Subject: [PATCH] fix: admin cache merge+cap, grouped a-pin backfill, petname index safety, NIP-43 members self gate - RelayGroupAdminCache.restore merges the disk map under in-memory entries (a remember() that beat the async read wins) and the cache is capped at 256 groups, dropping the least recently changed; the store persists any map that differs from disk instead of dropping the flow's first value. - filterRelayGroupState back-fills `a` pins with one filter per (kind, author) carrying all pinned d tags. - UserAssertionsState: petname index is an immutable snapshot behind @Volatile, rebuilt on IO by the flow; the composition-time accessor only reads it. KDoc now states the real order: NIP-85 nickname, NIP-02 petname, profile name, npub. - RelayMembersScreen resolves the relay's NIP-11 `self` first and fetches 13534/33534 once, by that author only; with no `self` it shows an explanatory state instead of lists anyone could have signed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../relays/nip43/RelayMembersScreen.kt | 47 +++++-- .../nip29RelayGroups/RelayGroupMigration.kt | 43 +++++- .../UserAssertionsState.kt | 24 +++- .../preferences/RelayGroupAdminCacheStore.kt | 32 +++-- .../channel/FilterRelayGroupState.kt | 15 +- .../RelayGroupMigrationDetectorTest.kt | 30 ++++ .../UserAssertionsDisplayNameTest.kt | 128 ++++++++++++++++++ .../channel/FilterRelayGroupStateTest.kt | 32 +++++ .../composeResources/values/strings.xml | 1 + 9 files changed, 312 insertions(+), 40 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index c33f421c56..4ed732db3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -55,9 +55,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips @@ -72,13 +74,14 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_loading import com.vitorpamplona.amethyst.commons.resources.relay_members_request_join import com.vitorpamplona.amethyst.commons.resources.relay_members_request_leave import com.vitorpamplona.amethyst.commons.resources.relay_members_title +import com.vitorpamplona.amethyst.commons.resources.relay_members_unverifiable import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -107,20 +110,37 @@ fun RelayMembersScreen( var members by remember { mutableStateOf>(emptyList()) } var roles by remember { mutableStateOf>(emptyMap()) } var isLoading by remember { mutableStateOf(true) } + // The relay publishes no NIP-11 `self`, so nothing it serves can be verified as relay-signed. + var isUnverifiable by remember { mutableStateOf(false) } var isMember by remember { mutableStateOf(false) } var joinRequestSent by remember { mutableStateOf(false) } var leaveRequestSent by remember { mutableStateOf(false) } var inviteCode by remember { mutableStateOf("") } val scope = rememberCoroutineScope() - // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. - // Filter by it once the doc resolves; until then, take whatever the relay serves. - val relayInfo by loadRelayInfo(normalizedRelayUrl) - val relaySelf = relayInfo.self - - LaunchedEffect(normalizedRelayUrl, relaySelf) { + // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. Resolve it + // first (loading state meanwhile) and fetch once, by that author only. A relay that publishes no + // `self` gets an explanatory state instead of lists anyone could have signed. + LaunchedEffect(normalizedRelayUrl) { launch(Dispatchers.IO) { - val authors = relaySelf?.let { listOf(it) } + var relaySelf: HexKey? = null + Amethyst.instance.nip11Cache.loadRelayInfo( + relay = normalizedRelayUrl, + onInfo = { relaySelf = it.self }, + onError = { _, _, _ -> }, + ) + + val self = relaySelf + if (self == null) { + members = emptyList() + roles = emptyMap() + isMember = false + isUnverifiable = true + isLoading = false + return@launch + } + + val authors = listOf(self) val filters = listOf( Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1), @@ -135,14 +155,13 @@ fun RelayMembersScreen( val membershipEvent = events ?.mapNotNull { it as? RelayMembershipListEvent } + ?.filter { it.pubKey == self } ?.maxByOrNull { it.createdAt } - // Only trust role definitions from whoever signed the member list. - val roleSigner = relaySelf ?: membershipEvent?.pubKey roles = events ?.mapNotNull { it as? RelayRoleEvent } - ?.filter { it.pubKey == roleSigner } + ?.filter { it.pubKey == self } ?.groupBy { it.roleId() } ?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() } ?: emptyMap() @@ -150,6 +169,7 @@ fun RelayMembersScreen( val memberList = membershipEvent?.membersWithRoles() ?: emptyList() members = memberList isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey } + isUnverifiable = false isLoading = false } } @@ -218,7 +238,7 @@ fun RelayMembersScreen( } } else if (members.isEmpty()) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -230,8 +250,9 @@ fun RelayMembersScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringRes(Res.string.relay_members_empty), + text = stringRes(if (isUnverifiable) Res.string.relay_members_unverifiable else Res.string.relay_members_empty), color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt index 523d87f95b..2638f54f53 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigration.kt @@ -115,6 +115,14 @@ object RelayGroupMigrationDetector { * [com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupAdminCacheStore]. */ object RelayGroupAdminCache { + /** + * Most groups kept. The cache is device-global (shared by every account on the device) and is + * filled for any group whose migration bar is shown, so it is bounded by recency of change instead + * of by one account's kind-10009: past the cap, the entry changed least recently is dropped. + */ + const val MAX_GROUPS = 256 + + // Insertion order = recency of change (oldest first), so the cap trims from the front. private val admins = MutableStateFlow>>(emptyMap()) val flow: StateFlow>> = admins @@ -131,13 +139,40 @@ object RelayGroupAdminCache { while (true) { val current = admins.value if (current[key] == pubkeys) return - if (admins.compareAndSet(current, current + (key to pubkeys))) return + val next = LinkedHashMap(current) + next.remove(key) + next[key] = pubkeys + if (admins.compareAndSet(current, next.trimToCap())) return } } - /** Replaces the whole map — used to restore from disk at startup. */ - fun restore(map: Map>) { - admins.value = map + /** + * Merges the map read from disk at startup UNDER the in-memory one: the disk read is async, so a + * [remember] that landed before it completed is newer and wins. Returns the merged map. + */ + fun restore(fromDisk: Map>): Map> { + while (true) { + val current = admins.value + val merged = LinkedHashMap>(fromDisk.size + current.size) + merged.putAll(fromDisk) + for ((key, value) in current) { + merged.remove(key) + merged[key] = value + } + val next = merged.trimToCap() + if (admins.compareAndSet(current, next)) return next + } + } + + private fun LinkedHashMap>.trimToCap(): LinkedHashMap> { + if (size > MAX_GROUPS) { + val oldestFirst = entries.iterator() + repeat(size - MAX_GROUPS) { + oldestFirst.next() + oldestFirst.remove() + } + } + return this } /** Test-only: clears the cache so unit tests don't leak state into each other. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index c422e772a2..c1d3189c25 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlin.concurrent.Volatile /** * The account's own kind:30382 contact cards — one card per target user, signed @@ -65,14 +66,19 @@ class UserAssertionsState( // The account's own kind-3 follow list, whose `p` tags may carry NIP-02 petnames. private val followListNote: AddressableNote by lazy { cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)) } - // pubkey -> petname for the latest follow list, rebuilt only when that list changes. + // pubkey -> petname for the latest follow list, rebuilt only when that list changes. An immutable + // snapshot published through a volatile field: the flows rebuild it on an IO thread while + // composition reads it through [cachedFollowListPetname], and a racing rebuild only costs a + // redundant (identical) index, never a torn one. private class PetnameIndex( val event: ContactListEvent, val petnames: Map, ) + @Volatile private var petnameIndex: PetnameIndex? = null + /** Looks [target] up in [followList]'s index, rebuilding it when stale. Parses tags: keep off the main thread. */ private fun followListPetname( followList: ContactListEvent?, target: HexKey, @@ -150,9 +156,14 @@ class UserAssertionsState( .stateFlow .map { followListPetname(it.note.event as? ContactListEvent, target.pubkeyHex) } .distinctUntilChanged() + .flowOn(Dispatchers.IO) - /** Synchronous counterpart of [followListPetnameFlow]. */ - fun cachedFollowListPetname(target: User): String? = followListPetname(followListNote.event as? ContactListEvent, target.pubkeyHex) + /** + * Synchronous counterpart of [followListPetnameFlow], for initial values in composition: it only + * reads the last index the flows built (possibly for the previous version of the follow list — + * the flow corrects it right after) and never parses the follow list on the calling thread. + */ + fun cachedFollowListPetname(target: User): String? = petnameIndex?.petnames?.get(target.pubkeyHex) /** * The name the account knows [target] by, for rendering: the NIP-85 nickname when it has a @@ -178,9 +189,10 @@ class UserAssertionsState( } /** - * The name to render for [target], per the NIP-81 policy: the nickname the - * account gave them wins over the profile's own display name, then the - * account's NIP-02 follow-list petname, falling back to the short npub. + * The name to render for [target]: the NIP-85 nickname the account gave them (NIP-81 policy), + * then the account's NIP-02 follow-list petname — the user's own local name for the contact, so + * it too wins over the profile's self-chosen name — then the profile's display name, falling + * back to the short npub. */ fun displayNameFlow(target: User): Flow = combine( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt index 2fcbf9eaf7..d9fda8fb27 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupAdminCacheStore.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupAdmin import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -47,19 +46,30 @@ class RelayGroupAdminCacheStore( ) { init { scope.launch { - restoreFromDisk() - RelayGroupAdminCache.flow.drop(1).collect { persist(it) } + // What the disk holds; only a map that differs from it is written back. Comparing instead + // of dropping the flow's first value also persists a remember() that raced the restore. + var onDisk = restoreFromDisk() + RelayGroupAdminCache.flow.collect { + if (it != onDisk) { + persist(it) + onDisk = it + } + } } } - private suspend fun restoreFromDisk() { - try { - val raw = store.data.first()[KEY] ?: return - if (raw.isNotEmpty()) RelayGroupAdminCache.restore(decode(raw)) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } - } + /** Merges the saved map into the cache (in-memory entries win) and returns what the disk held. */ + private suspend fun restoreFromDisk(): Map> { + val fromDisk = + try { + store.data.first()[KEY]?.let(::decode) ?: emptyMap() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupAdminCache") { "Error reading cached group admins: ${e.message}" } + emptyMap() + } + RelayGroupAdminCache.restore(fromDisk) + return fromDisk } private suspend fun persist(map: Map>) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt index 92c1253805..92f702038b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupState.kt @@ -66,23 +66,26 @@ fun filterRelayGroupState( relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } - // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author+#d filter per - // pinned address. No `since` either — the pin stays valid however old the latest version is. + // Same back-fill for `a` pins (addressable events, NIP-29 #2416): one kind+author filter per + // (kind, author) pair carrying every pinned `d` of that pair — the union of the per-address + // filters, in as few filters as the pins allow. No `since` either — the pin stays valid however + // old the latest version is. val pinnedAddresses = channel.pinnedAddresses val addressPins = if (pinnedAddresses.isEmpty()) { emptyList() } else { + val byKindAndAuthor = pinnedAddresses.groupBy({ it.kind to it.pubKeyHex }, { it.dTag }) relays.flatMap { relay -> - pinnedAddresses.map { address -> + byKindAndAuthor.map { (kindAndAuthor, dTags) -> RelayBasedFilter( relay = relay, filter = ExplainedFilter( purpose = SubPurpose.RELAY_GROUPS, - kinds = listOf(address.kind), - authors = listOf(address.pubKeyHex), - tags = mapOf("d" to listOf(address.dTag)), + kinds = listOf(kindAndAuthor.first), + authors = listOf(kindAndAuthor.second), + tags = mapOf("d" to dTags.distinct()), ), ) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt index edf8158329..58c80731d8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMigrationDetectorTest.kt @@ -136,4 +136,34 @@ class RelayGroupMigrationDetectorTest { assertEquals(mapOf(current.toKey() to setOf(admin, friend)), restored) assertEquals(setOf(admin, friend), RelayGroupAdminCache.adminsOf(current)) } + + @Test + fun restoringFromDiskKeepsRememberCallsThatLandedFirst() { + val other = GroupId("other", newRelay) + // The async disk read completes after the UI already recorded a fresher roster. + RelayGroupAdminCache.remember(current, setOf(friend)) + + val merged = RelayGroupAdminCache.restore(mapOf(current.toKey() to setOf(admin), other.toKey() to setOf(stranger))) + + assertEquals(setOf(friend), RelayGroupAdminCache.adminsOf(current), "the in-memory entry is newer and wins") + assertEquals(setOf(stranger), RelayGroupAdminCache.adminsOf(other), "disk-only entries are restored") + assertEquals(RelayGroupAdminCache.flow.value, merged) + } + + @Test + fun adminCacheIsCappedDroppingTheLeastRecentlyChanged() { + val first = GroupId("g0", newRelay) + RelayGroupAdminCache.remember(first, setOf(admin)) + repeat(RelayGroupAdminCache.MAX_GROUPS) { i -> RelayGroupAdminCache.remember(GroupId("g${i + 1}", newRelay), setOf(admin)) } + + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertTrue(RelayGroupAdminCache.adminsOf(first).isEmpty(), "the oldest entry is evicted") + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + + // A restore past the cap trims too, keeping the in-memory (newer) entries. + val fromDisk = (0 until RelayGroupAdminCache.MAX_GROUPS).associate { GroupId("disk$it", newRelay).toKey() to setOf(friend) } + RelayGroupAdminCache.restore(fromDisk) + assertEquals(RelayGroupAdminCache.MAX_GROUPS, RelayGroupAdminCache.flow.value.size) + assertEquals(setOf(admin), RelayGroupAdminCache.adminsOf(GroupId("g${RelayGroupAdminCache.MAX_GROUPS}", newRelay))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt new file mode 100644 index 0000000000..138fa1a50a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsDisplayNameTest.kt @@ -0,0 +1,128 @@ +/* + * 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.nip85TrustedAssertions + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The display-name order: NIP-85 nickname, then the account's NIP-02 follow-list petname (the + * user's own local name for the contact, so it beats the profile's self-chosen name), then the + * profile's name, then the short npub. + */ +class UserAssertionsDisplayNameTest { + private val signer = NostrSignerInternal(KeyPair()) + private val target = "aa".repeat(32) + + private class MapCache : ICacheProvider { + private val context = UserContext { addr -> AddressableNote(addr) } + val users = HashMap() + val addressables = HashMap() + + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(address: Address): AddressableNote = addressables.getOrPut(address) { AddressableNote(address) } + + override fun getEventStream(): ICacheEventStream = error("unused") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User = users.getOrPut(pubkey) { User(pubkey, context) } + + override fun consumeEmbedded(event: Event) = Unit + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + private fun setProfileName( + user: User, + name: String, + ) { + user.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, EmptyTagList, emptyList(), 1) + } + + private fun loadFollowList( + cache: MapCache, + vararg tags: Array, + ) { + val event = ContactListEvent("11".repeat(32), signer.pubKey, 10, arrayOf(*tags), "", "22".repeat(64)) + cache.getOrCreateAddressableNote(ContactListEvent.createAddress(signer.pubKey)).loadEvent(event, cache.getOrCreateUser(signer.pubKey), emptyList()) + } + + @Test + fun followListPetnameBeatsTheProfileName() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + + loadFollowList(cache, arrayOf("p", target, "", "bestie")) + assertEquals("bestie", state.displayNameFlow(user).first()) + // The synchronous initial value reads the index the flow just built. + assertEquals("bestie", state.cachedDisplayName(user)) + } + + @Test + fun withoutAPetnameTheProfileNameShows() = + runTest { + val cache = MapCache() + val state = UserAssertionsState(signer, cache, UserAssertionDecryptionCache(signer), EmojiPackState(signer, cache, backgroundScope)) + val user = cache.getOrCreateUser(target) + setProfileName(user, "Profile Name") + loadFollowList(cache, arrayOf("p", target)) + + assertEquals("Profile Name", state.displayNameFlow(user).first()) + assertEquals("Profile Name", state.cachedDisplayName(user)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt index 813b8d82ae..f45d56aa1d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt @@ -152,4 +152,36 @@ class FilterRelayGroupStateTest { assertEquals(listOf("article"), addressFilter.filter.tags!!["d"]) assertNull(addressFilter.filter.since) } + + @Test + fun `address pins sharing a kind and author collapse into one filter`() { + val channel = RelayGroupChannel(groupId) + val author = "b".repeat(64) + val other = "e".repeat(64) + channel.updatePinned( + GroupPinnedEvent( + id = "d".repeat(64), + pubKey = relaySignKey, + createdAt = 100L, + tags = + arrayOf( + arrayOf("d", "g1"), + arrayOf("a", "30023:$author:one"), + arrayOf("a", "30023:$author:two"), + arrayOf("a", "30023:$other:three"), + arrayOf("a", "30311:$author:live"), + ), + content = "", + sig = sig, + ), + ) + + val addressFilters = filterRelayGroupState(channel, since = null).filter { it.filter.authors != null } + assertEquals(3, addressFilters.size, "one filter per (kind, author), not per address") + + val byKey = addressFilters.associateBy { it.filter.kinds!!.single() to it.filter.authors!!.single() } + assertEquals(listOf("one", "two"), byKey[30023 to author]!!.filter.tags!!["d"]) + assertEquals(listOf("three"), byKey[30023 to other]!!.filter.tags!!["d"]) + assertEquals(listOf("live"), byKey[30311 to author]!!.filter.tags!!["d"]) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 84674960c8..9843e6cbf1 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2856,6 +2856,7 @@ You are a member Invite code This relay admits members with an invite code from its operator + This relay does not publish its identity key (NIP-11 self), so its member list cannot be verified Relay membership list Member added to relay %1$d members added to relay