fix: narrow relay-group follows/authors REQ instead of broad directory pull

A follows/authors filter no longer pulls every group on the relay and filters
client-side. It now emits, per relay, the three narrowed REQs a relay-signed
group is discoverable by:
  1. {kinds:[39000], authors:<follows>}          — the relay signing-key is a follow
  2. {kinds:[39001,39002], #p:<follows>}          — a follow is an admin/member
  3. {kinds:[39000], #d:<roster group-ids>}       — metadata backfill for (2)

(3) reads the group-ids of cached 39001/39002 rosters that already mention a
follow (empty on the first pass, filled once (2)'s events land and the
sub-assembler re-invalidates), since a #p roster hit doesn't carry the 39000.

ByFollows routes through the shared per-relay author builder (mirrors Git);
muted-authors likewise. Global / communities keep the broad directory pull,
since they carry no author dimension to narrow on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 22:41:26 +00:00
parent dc5bf6499a
commit 7f10ff6fae
2 changed files with 85 additions and 12 deletions
@@ -20,14 +20,85 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
// Authors can't be pushed into the REQ (a group's 39000 is signed by its relay, never a follow),
// so an author filter contributes only its RELAY set; the directory is pulled per relay and the
// dal keeps the groups whose relay-key/admins/members intersect the selected authors.
/**
* A group's kind-39000 is relay-signed, so a follow shows up in three distinct ways, and each is a
* real relay-side REQ (not a broad "pull everything" pass):
* 1. the follow IS the relay signing key → `{kinds:[39000], authors:<follows>}` (metadata included);
* 2. a follow is an admin/member → `{kinds:[39001,39002], #p:<follows>}` (standard p-tag filter);
* 3. metadata backfill for the groups (2) surfaced → `{kinds:[39000], #d:<their group-ids>}`, read
* from the rosters already in cache (empty on the first pass, filled once (2)'s events land and
* the sub-assembler re-invalidates).
*/
fun filterRelayGroupsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
if (authors.isEmpty()) return emptyList()
val authorList = authors.sorted()
val filters =
mutableListOf(
// (1) groups whose relay signing-key is a follow
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(GroupMetadataEvent.KIND),
limit = 200,
since = since,
),
),
// (2) groups where a follow is an admin (39001) or member (39002)
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
tags = mapOf(PTag.TAG_NAME to authorList),
limit = 200,
since = since,
),
),
)
// (3) backfill 39000 metadata for the roster-discovered groups already known on this relay.
val rosterGroupIds =
LocalCache
.getRelayGroupChannelsOnRelay(relay)
.filter { channel -> channel.admins.any { it.pubKey in authors } || channel.members.any { it in authors } }
.map { it.groupId.id }
if (rosterGroupIds.isNotEmpty()) {
filters +=
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(DTag.TAG_NAME to rosterGroupIds),
limit = 200,
since = since,
),
)
}
return filters
}
fun filterRelayGroupsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
@@ -37,8 +108,9 @@ fun filterRelayGroupsByAuthors(
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterRelayGroupsDirectory(
filterRelayGroupsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
@@ -52,8 +124,9 @@ fun filterRelayGroupsByMutedAuthors(
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterRelayGroupsDirectory(
filterRelayGroupsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
@@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavP
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// A follows filter pulls the whole directory (metadata + rosters) from each relay the follow set
// routes to; the dal keeps groups where a follow is the relay key, an admin, or a member, and any
// topic/geo groups the AllFollows big-OR also selects (all present in the directory pull).
// A follows filter routes to each relay the follow set uses and asks, per relay, for the three
// ways a follow surfaces in a relay-signed group: the relay signing-key is a follow, or a follow
// is an admin (39001) or a member (39002). See [filterRelayGroupsByAuthors].
fun filterRelayGroupsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
@@ -35,9 +35,9 @@ fun filterRelayGroupsByFollows(
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
val relaySince = since?.get(it.key)?.time ?: defaultSince
it.value.authors?.let { authors ->
filterRelayGroupsByAuthors(it.key, authors, relaySince)
} ?: emptyList()
}
}