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 f6f18b6a1f..07cbdbfc68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2880,10 +2880,15 @@ class Account( * Read-only import: kind 13302 is replaceable, so folding an older copy is a * no-op and this is safe to call on every hub open. Merging our own edits with * a foreign writer's is a separate concern (newest-wins replaceable). + * + * [extraRelays] are additional relays to query — the bootstrap relays saved on the + * bottom-bar tabs of pinned communities. A community's private list frequently lives + * only on the community's own relays (never the user's outbox), so a community pinned + * to the bottom bar would otherwise never surface when opened cold. */ - suspend fun importConcordCommunities() { + suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet() + val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet() if (relays.isEmpty()) return val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt index 9a8615bb5f..0d2e74ce07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -70,11 +70,22 @@ sealed interface BottomBarEntry { val relayUrl: String, ) : BottomBarEntry - /** A pinned Concord community, keyed by its community id; opens the community's channel list. */ + /** + * A pinned Concord community, keyed by its community id; opens the community's channel list. + * + * [relays] are the community's bootstrap relays, captured from the joined-list entry at pin + * time. A Concord community's private kind-13302 list often lives only on these relays (Armada/ + * Vector publish it there, never to the user's outbox), so without them a pinned community whose + * list we haven't cached can never be found — the tab and its server screen would stay blank. + * Carrying the relays on the tab lets the bootstrap re-fetch the list from the right place even + * when nothing about the community is known yet. Optional (defaults empty) so older persisted + * bottom-bar configs still decode. + */ @Serializable @SerialName("concord") data class Concord( val communityId: String, + val relays: List = emptyList(), ) : BottomBarEntry /** A pinned Bitchat geohash location channel, keyed by its geohash cell; opens the location chat. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4842416ecc..b4fc77f1af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus @@ -127,6 +128,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -671,10 +673,20 @@ class AccountViewModel( account.refoundConcordCommunity(communityId, setOf(member)) } - /** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */ + /** + * Pull the account's Concord community list (kind 13302) from the stock + own relays and, in + * addition, from the bootstrap relays of every Concord community pinned to the bottom bar. The + * private list frequently lives only on a community's own relays, so a pinned community opened + * cold — never through the hub — is only reachable via the relays saved on its tab. + */ fun importConcordCommunities() = viewModelScope.launch(Dispatchers.IO) { - account.importConcordCommunities() + val pinnedRelays = + settings.uiSettingsFlow.bottomBarItems.value + .filterIsInstance() + .flatMap { it.relays } + .mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.importConcordCommunities(pinnedRelays) } /** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 04657724b2..1f66f9a9dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** @@ -76,6 +77,15 @@ fun ConcordChannelSubscription( * [KeyDataSourceSubscription] so the planes stay requested app-wide, exactly like DMs, and keeps the * same [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] watch so a * fresh fold subscribes its newly-revealed channel planes. + * + * Preloading a community's planes requires its keys, which come from the private kind-13302 list — + * so a community pinned to the bottom bar whose list never reached the cache preloads nothing and + * shows a blank tab + server screen ("doesn't load at all"). That list often lives only on the + * community's own relays (Armada/Vector publish it there, never to the user's outbox), so before we + * can preload we may first have to fetch it: [bootstrapPinnedCommunities] imports the list for any + * pinned community we don't yet know, from the relays saved on its tab. Once it folds into the cache, + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState.liveCommunities] surfaces + * the entry, the tab/screen fill in, and the plane preload above picks it up. */ @Composable fun ConcordChannelPreload(accountViewModel: AccountViewModel) { @@ -88,5 +98,37 @@ fun ConcordChannelPreload(accountViewModel: AccountViewModel) { dataSource.invalidateFilters() } + bootstrapPinnedCommunities(accountViewModel) + KeyDataSourceSubscription(state, dataSource) } + +/** + * Fetch the private kind-13302 list of any Concord community pinned to the bottom bar whose list we + * don't already have, from the relays saved on its tab (see + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel.importConcordCommunities]). Runs + * app-wide as part of [ConcordChannelPreload], so a pinned community loads without the user ever + * opening the Concord hub (which has its own import). Keyed on the exact set of missing communities, + * so the (slow, stock-relay) fetch runs when a new gap appears — a freshly pinned community we can't + * yet resolve — and not on every recomposition; it stops once every pinned community is known. + */ +@Composable +private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) { + val account = accountViewModel.account + val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() + + val missingPinned = + remember(items, communities) { + val known = communities.mapTo(HashSet()) { it.id } + items + .filterIsInstance() + .map { it.communityId } + .filterTo(sortedSetOf()) { it !in known } + } + + LaunchedEffect(missingPinned) { + if (missingPinned.isNotEmpty()) accountViewModel.importConcordCommunities() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index 05d91be6d6..512079e8fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -585,7 +585,7 @@ private fun PickerChildren( NavBarItem.CONCORD -> { val communities by accountViewModel.account.concordChannelList.liveCommunities .collectAsStateWithLifecycle() - val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } } + val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id, it.relays) } } GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) } diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index e8966aebbb..a205299a5f 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -1677,7 +1677,9 @@ Hlavní Přidáno Změnit pořadí serveru + Online Pomalé + Offline Kontrola… Platební cíle Zveřejněte své platební adresy, aby vám ostatní mohli přímo posílat prostředky. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 8023b21305..50e31e3dfb 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1613,7 +1613,9 @@ Primär Hinzugefügt Server neu anordnen + Online Langsam + Offline Wird geprüft… Zahlungsziele Veröffentliche deine Zahlungsadressen, damit andere dir direkt Geld senden können. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 3d71fd0436..ad92657261 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -886,11 +886,62 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przestaje obserwować %1$d kont Przestaje obserwować %1$d konta + + dodaje %1$d transmiter + dodaje %1$d transmiterów + dodaje %1$d transmiterów + dodaje %1$d transmitery + + + USUWA %1$d transmiter + USUWA %1$d transmiterów + USUWA %1$d transmiterów + USUWA %1$d transmitery + + + wycisza jeszcze %1$d osobę + wycisza jeszcze %1$d osób + wycisza jeszcze %1$d osób + wycisza jeszcze %1$d osoby + + + ODBLOKOWUJE %1$d osobę + ODBLOKOWUJE %1$d osób + ODBLOKOWUJE %1$d osób + ODBLOKOWUJE %1$d osoby + + ⚠️ To obserwuje %1$s. + ⚠️ To zaprzestaje obserwacji %1$s. + ⚠️ To ucisza %1$s. + ⚠️ To odblokowuje %1$s. + ⚠️ To spowoduje przepisanie Twojej listy obserwowanych: %1$s. + ⚠️ To przepisuje twoją listę transmiterów: %1$s. Twoje posty i przeczytane wpisy zmieniają się wraz z tym. + ⚠️ Spowoduje to aktualizację listy wyciszonych: %1$s. Wyciszone słowa i hashtagi nie są tutaj wyświetlane — naciśnij „Pokaż wydarzenie”, aby wyświetlić pełną listę. + %1$s i %2$s + Powoduje to ponowne opublikowanie istniejącej listy bez zmian. + + Spowoduje to utworzenie listy zawierającej %1$d pozycję. Amethyst nie posiada kopii w pamięci podręcznej, z którą mógłby dokonać porównania. + Spowoduje to zapisanie listy zawierającej %1$d pozycji. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać. + Spowoduje to zapisanie listy zawierającej %1$d pozycji. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać. + Spowoduje to zapisanie listy zawierającej %1$d pozycje. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać. + + + To żądanie dotyczy usunięcia %1$d wydarzenia. + To żądanie dotyczy usunięcia %1$d wydarzeń. + To żądanie dotyczy usunięcia %1$d wydarzeń. + To żądanie dotyczy usunięcia %1$d wydarzeń. + + + Zawiera %1$d tag. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane. + Zawiera %1$d tagów. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane. + Zawiera %1$d tagów. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane. + Zawiera %1$d tagi. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane. + Połącz z Nostr chce połączyć się z Twoim kontem Nostr diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d84ec93401..3d9f675406 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1611,7 +1611,9 @@ Primär Tillagd Ändra ordning på server + Online Långsam + Offline Kontrollerar… Betalningsmottagare Publicera dina betalningsadresser så att andra kan skicka pengar direkt till dig. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt index e6714281a7..83fe3592e1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt @@ -40,7 +40,7 @@ class BottomBarEntrySerializationTest { BottomBarEntry.Favorite("url:https://example.com"), BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"), BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"), - BottomBarEntry.Concord("f".repeat(64)), + BottomBarEntry.Concord("f".repeat(64), listOf("wss://relay.ditto.pub", "wss://community.example.com")), ) @Test diff --git a/commons/src/commonMain/composeResources/values-cs/strings.xml b/commons/src/commonMain/composeResources/values-cs/strings.xml index 2397cb87fd..f3a44deb5b 100644 --- a/commons/src/commonMain/composeResources/values-cs/strings.xml +++ b/commons/src/commonMain/composeResources/values-cs/strings.xml @@ -12,6 +12,7 @@ Vygenerovat nový Zadejte svůj soukromý klíč (nsec) nebo veřejný klíč (npub) nsec, bunker:// nebo npub + nsec1… / bunker://… / npub1… Zobrazit klíč Skrýt klíč @@ -24,6 +25,7 @@ Kopírovat Vložit Zrušit + OK Uložit Smazat Sdílet @@ -56,16 +58,26 @@ Vše načteno Dosáhli jste začátku zpráv %1$s %1$s · %2$s · načteno od %3$s + %1$s · %2$s čeká se na %1$s Některé relaye neodpověděly %1$s nedostupných · klepnutím zobrazíte které %1$s · historie podle relaye od %1$s Zavřít + + %1$d relay + %1$d relaye + %1$d relaye + %1$d relayů + odpovídá na Statický web: %1$s + nApplet: %1$s + nApplet + nSite K čemu má přístup Kořenový web Zdroj: @@ -127,6 +139,8 @@ Dopravní zácpa Silniční událost Zapy na toto se rozdělují mezi: + %1$d%% + Value-for-Value Otevření Čtení Zápis @@ -134,6 +148,7 @@ Typ Požadavky Podporované NIP + %1$d ms Přijímané druhy Poloha Účastním se diff --git a/commons/src/commonMain/composeResources/values-de-rDE/strings.xml b/commons/src/commonMain/composeResources/values-de-rDE/strings.xml index db0f682c33..0dadb7fa9b 100644 --- a/commons/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commons/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -12,6 +12,7 @@ Neu erstellen Gib deinen privaten Schlüssel (nsec) oder öffentlichen Schlüssel (npub) ein nsec, bunker:// oder npub + nsec1… / bunker://… / npub1… Schlüssel anzeigen Schlüssel verbergen @@ -24,6 +25,7 @@ Kopieren Einfügen Abbrechen + OK Speichern Löschen Teilen @@ -56,6 +58,7 @@ Alles gelesen Anfang deiner %1$s Nachrichten erreicht %1$s · %2$s · geladen seit %3$s + %1$s · %2$s warte auf %1$s Einige Relays haben nicht geantwortet %1$s nicht erreichbar · tippen, um zu sehen welche @@ -70,6 +73,9 @@ Antworten auf Statische Website: %1$s + nApplet: %1$s + nApplet + nSite Worauf sie zugreifen kann Stammseite Quelle: @@ -125,6 +131,8 @@ Stau Straßenereignis Zaps hierauf werden aufgeteilt zwischen: + %1$d%% + Value-for-Value Öffnen Lesen Schreiben @@ -132,6 +140,7 @@ Typ Anforderungen Unterstützte NIPs + %1$d ms Akzeptierte Arten Standort Komme diff --git a/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml index 6b65c8acfe..db76aafe20 100644 --- a/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commons/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -12,6 +12,7 @@ Gerar nova Digite sua chave privada (nsec) ou chave pública (npub) nsec, bunker:// ou npub + nsec1… / bunker://… / npub1… Mostrar chave Ocultar chave @@ -24,6 +25,7 @@ Copiar Colar Cancelar + OK Salvar Excluir Compartilhar @@ -56,16 +58,24 @@ Tudo em dia Início das suas mensagens %1$s alcançado %1$s · %2$s · carregado desde %3$s + %1$s · %2$s aguardando %1$s Alguns relays não responderam %1$s inacessível · toque para ver quais %1$s · histórico por relay desde %1$s Dispensar + + %1$d Relé + %1$d Relés + respondendo para Site Estático: %1$s + nApplet: %1$s + nApplet + nSite O que ele pode acessar Site raiz Origem: @@ -121,6 +131,7 @@ Congestionamento Evento na via Os zaps para isto são divididos entre: + %1$d%% Valor por Valor Abrir Leitura @@ -129,6 +140,7 @@ Tipo Requisitos NIPs Suportados + %1$d ms Tipos Aceitos Localização Vou diff --git a/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml index a90c7d5954..369cd58fd2 100644 --- a/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commons/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -12,6 +12,7 @@ Skapa ny Ange din privata nyckel (nsec) eller publika nyckel (npub) nsec, bunker:// eller npub + nsec1… / bunker://… / npub1… Visa nyckel Dölj nyckel @@ -24,6 +25,7 @@ Kopiera Klistra in Avbryt + Ok Spara Radera Dela @@ -56,6 +58,7 @@ Allt ikapp Nådde början av dina %1$s-meddelanden %1$s · %2$s · laddat sedan %3$s + %1$s · %2$s väntar på %1$s Vissa reläer svarade inte %1$s ej nåbara · tryck för att se vilka @@ -70,6 +73,9 @@ Svarar till Statisk webbplats: %1$s + nApplet: %1$s + nApplet + nSite Vad det har åtkomst till Rotplats Källa: @@ -103,6 +109,7 @@ %1$s +%2$d till PS1-minneskortsparning + block %1$d Tom plats Spela höjdpunkt Olycka @@ -124,6 +131,7 @@ Trafikstockning Väghändelse Zaps till detta fördelas mellan: + %1$d%% Värde -för-värde Öppna Läs @@ -132,6 +140,7 @@ Typ Krav NIP som stöds + %1$d ms Accepterade typer Plats Kommer diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt new file mode 100644 index 0000000000..366693b062 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt @@ -0,0 +1,129 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +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.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The account joined a community on another client but has NO offline backup here yet; the + * kind-13302 list only shows up later, delivered by a relay into the addressable cache note. + * `liveCommunities` must then decrypt it and surface the entry so the bottom-bar tab and the + * Concord Channels screen fill in. + */ +class ConcordListLateArrivalTest { + private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray())) + + private val alpha = "a".repeat(64) + + private fun entry( + id: String, + name: String, + ) = ConcordCommunityListEntry( + id = id, + owner = signer.pubKey, + ownerSalt = "1".repeat(64), + root = "2".repeat(64), + rootEpoch = 3, + relays = listOf("ws://127.0.0.1:7777"), + name = name, + addedAt = 1000, + ) + + /** No offline backup at all — this account never saved a list locally. */ + private class NoBackupRepository : ConcordListRepository { + override fun concordList(): ConcordCommunityListEvent? = null + + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {} + } + + /** Hands back one stable addressable note per address so the state and the "relay" share it. */ + private class StubCache : ICacheProvider { + private val notes = HashMap() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = null + + 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(key: Address): AddressableNote = notes.getOrPut(key.toValue()) { AddressableNote(key) } + + override fun getEventStream(): ICacheEventStream = error("not used") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } + + @Test + fun lateArrivingListDecryptsAndSurfaces() = + runTest { + val state = + ConcordChannelListState( + signer = signer, + cache = StubCache(), + scope = CoroutineScope(Dispatchers.Unconfined), + settings = NoBackupRepository(), + ) + + // Nothing is known yet: no backup, empty cache note. + assertEquals(emptyList(), state.liveCommunities.value) + + // A relay delivers the kind-13302 into the SAME addressable note the state observes. + val event = ConcordCommunityListEvent.create(signer, listOf(entry(alpha, "Alpha"))) + val author = User(signer.pubKey) { addr -> Note(addr.toValue()) } + state.concordListNote.loadEvent(event, author, emptyList()) + + // liveCommunities decrypts on Dispatchers.IO, so give it a beat to settle. + withTimeout(5000) { + while (state.liveCommunities.value.isEmpty()) yield() + } + + assertEquals(1, state.liveCommunities.value.size) + assertEquals(alpha, state.liveCommunities.value[0].id) + } +}