diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index 7bcb6f7254..25543b1888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -26,11 +26,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** One screen's request to keep the user's joined Concord Channels live. */ class ConcordChannelQueryState( @@ -76,7 +73,9 @@ class ConcordChannelSubAssembler( if (entries.isEmpty()) return null // Control planes for every joined community, plus channel planes for the - // ones whose Control Plane has already folded. + // ones whose Control Plane has already folded. Deriving the channel planes + // is the only account-dependent step; collapsing planes into per-relay + // kind-1059 filters lives in the shared planner. val subs = ArrayList() subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) for (entry in entries) { @@ -88,24 +87,7 @@ class ConcordChannelSubAssembler( subs += ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) } - // One kind-1059 filter per host relay, carrying every plane address on it. - val authorsByRelay = HashMap>() - for (sub in subs) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) - } - if (authorsByRelay.isEmpty()) return null - - return authorsByRelay.map { (relay, authors) -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = listOf(ConcordKinds.WRAP), - authors = authors.toList(), - since = since?.get(relay)?.time, - ), - ) - } + return ConcordSubscriptionPlanner.relayBasedFilters(subs, since) } override fun id(key: ConcordChannelQueryState) = key.account diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 05c9442037..d8dc2db7ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -89,5 +92,37 @@ object ConcordSubscriptionPlanner { return authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors)) } } + /** + * Collapses [subs] into one [RelayBasedFilter] per host relay for a live + * subscription: each relay gets a single `{kinds:[1059], authors:[…all plane + * pks on it…], since}` filter, with [since] applied per relay from the EOSE + * map. Returns null when no plane resolves to a relay (nothing to subscribe). + * + * This is the assembler-facing shape (what a `PerUniqueIdEoseManager` returns); + * [filtersByRelay] is the one-shot drain shape (no `since`). + */ + fun relayBasedFilters( + subs: List, + since: SincePerRelayMap?, + ): List? { + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + if (authorsByRelay.isEmpty()) return null + + return authorsByRelay.map { (relay, authors) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordKinds.WRAP), + authors = authors.toList(), + since = since?.get(relay)?.time, + ), + ) + } + } + private fun normalize(urls: List): Set = urls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index ca70488ff5..82598e6b17 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -20,13 +20,16 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.relays.MutableTime import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class ConcordSubscriptionPlannerTest { @@ -71,4 +74,33 @@ class ConcordSubscriptionPlannerTest { assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex)) assertTrue(filter.authors!!.contains(general.pubKeyHex)) } + + @Test + fun relayBasedFiltersCollapsePerRelayAndApplySince() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val subs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) + val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!! + + // One kind-1059 filter for the single relay, carrying the derived since. + val filters = ConcordSubscriptionPlanner.relayBasedFilters(subs, mutableMapOf(relay to MutableTime(1234L)))!! + assertEquals(1, filters.size) + assertEquals(relay, filters[0].relay) + assertEquals(listOf(1059), filters[0].filter.kinds) + assertEquals(1234L, filters[0].filter.since) + assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex)) + + // No planes resolve to a relay -> nothing to subscribe. + assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null)) + } }