From a37cc944812546fa8759e0b6803c864fa6c6e80f Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:29:22 +0000 Subject: [PATCH 1/2] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 2 + .../src/main/res/values-de-rDE/strings.xml | 2 + .../src/main/res/values-pl-rPL/strings.xml | 51 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 2 + .../composeResources/values-cs/strings.xml | 15 ++++++ .../values-de-rDE/strings.xml | 9 ++++ .../values-pt-rBR/strings.xml | 12 +++++ .../values-sv-rSE/strings.xml | 9 ++++ 8 files changed, 102 insertions(+) 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/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 From 5166a5ac6978785e2ce4552aa5d67bc758ee0e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:40:46 +0000 Subject: [PATCH 2/2] fix(concord): load pinned communities from their own relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord community pinned to the bottom bar wouldn't load at all when its private kind-13302 joined-communities list wasn't already cached: the tab and its server screen stayed blank. The list often lives only on the community's own relays (Armada/Vector publish it there, never to the user's outbox), and the only fetch that looked beyond the outbox — importConcordCommunities — was triggered solely from the Concord hub and never queried the community's relays. Carry each pinned community's bootstrap relays on its BottomBarEntry.Concord tab (captured from the joined-list entry at pin time) and: - importConcordCommunities now takes extra relays and folds in the relays saved on every pinned Concord tab, so the list is found where it actually lives; - ConcordChannelPreload bootstraps app-wide: it fetches the list for any pinned community we don't yet know, so the tab and server screen fill in without the user ever opening the hub. Once the list folds into the cache, ConcordChannelListState.liveCommunities already surfaces it reactively (verified by a new late-arrival test) and the plane preload picks the community up — so a late-arriving list with no local backup now updates the tab and the Concord Channels screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RSiSXaHMEpuo3gcDuTZ24u --- .../vitorpamplona/amethyst/model/Account.kt | 9 +- .../navigation/bottombars/BottomBarEntry.kt | 13 +- .../ui/screen/loggedIn/AccountViewModel.kt | 16 ++- .../datasource/ConcordChannelSubscription.kt | 42 ++++++ .../settings/BottomBarSettingsScreen.kt | 2 +- .../BottomBarEntrySerializationTest.kt | 2 +- .../concord/ConcordListLateArrivalTest.kt | 129 ++++++++++++++++++ 7 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt 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 6841fe10a8..badc7c5969 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2873,10 +2873,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 794915bb44..a575be663b 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/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/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) + } +}