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) + } +}