perf(desktop): keep the member roster off the channel-switch path (#6456)

Switching channels triggered a full-roster fetch (kind:39002 plus a
kind:0 profile batch with every member pubkey as an author) in the
common case, and several render paths walked the full roster per render.
None of this scales past a few hundred members; the product target is
10k+.

- **Members query staleTime 30s → 5min.** Every membership change the
client can observe already invalidates the key explicitly: live
join/leave system messages for the active channel, member-added/removed
notifications for the current identity, and all membership mutations —
including previously-uncovered direct write paths (moderation kick,
agent-deletion cleanup), which now invalidate through a shared helper.
The 30s window bought correctness we already had and charged a roster
fetch per switch.
- **ChannelMembersBar no longer mounts the roster query for non-DM
channels** — the count renders from the channel summary, and the
private-channel huddle gate accepts `channel.isMember` (derived from the
same kind:39002 event as the roster's self entry).
- **Roster-derived lookups are cached on roster identity**
(`rosterDerivations.ts`): role map, agent-member subset, member/bot
pubkey sets. These were rebuilt O(members) on every live message /
profile re-key. React Query's structural sharing keeps the roster
identity stable, so each derivation computes once per distinct roster.
- **Backend: the kind:0 profile join in `get_channel_members` is capped
at the first 500 members** (roster order). Members past the cap keep
`display_name: None` (UI falls back to pubkey labels and profile
caches); `role=="bot"` agent flags are roster-derived and unaffected.
Full roster pagination is the structural follow-up.
- **Composer keystroke path**: `useCanAddChannelMembers` re-scanned
channels + roster per keystroke; now memoized on data identities,
sharing the cached pubkey set.

### Measured / estimated impact

| metric | before | after |
|---|---|---|
| roster fetches while switching (live trace) | nearly every switch | ≤1
per channel per 5 min |
| roster fetch cost on the wire (live, 51-member channel) | 273ms per
fetch | amortized away |
| kind:0 `authors` filter size at 10k members | ~670KB per request (~67
B/pubkey) | capped at 500 authors (~34KB) |
| warm-switch longtask at 10k members (mock harness, 4× throttle) |
364ms | 318ms |
| per-render roster walks (role map, agent sets) at 10k members |
O(members) per live message | once per distinct roster |

Deferred deliberately: protocol-level roster pagination and removing
`memberPubkeys` from channel summaries (needs relay support).

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-21 16:40:20 -07:00
committed by GitHub
parent 47526784d5
commit b0466ac465
20 changed files with 406 additions and 64 deletions

View File

@@ -121,6 +121,24 @@ pub async fn get_channel_details(
.ok_or_else(|| "channel not found".to_string())
}
/// Cap for the kind:0 profile join in `get_channel_members`. Enriching a
/// huge roster required an `authors` filter carrying every member pubkey — a
/// query whose size and relay cost grow linearly with membership and which
/// dominated channel-open latency on large channels. Members past the cap
/// keep `display_name: None` (the UI falls back to pubkey-derived labels and
/// resolves visible names through its profile caches); `role == "bot"` agent
/// flags are roster-derived and unaffected by the cap.
const MEMBER_PROFILE_JOIN_LIMIT: usize = 500;
/// The pubkeys eligible for the kind:0 profile join: roster order, capped.
fn profile_join_pubkeys(members: &[crate::models::ChannelMemberInfo], limit: usize) -> Vec<String> {
members
.iter()
.take(limit)
.map(|member| member.pubkey.clone())
.collect()
}
#[tauri::command]
pub async fn get_channel_members(
channel_id: String,
@@ -142,8 +160,9 @@ pub async fn get_channel_members(
.transpose()?
.ok_or_else(|| "channel members not found".to_string())?;
// Batch-fetch kind:0 profiles to populate display names.
let pubkeys: Vec<String> = response.members.iter().map(|m| m.pubkey.clone()).collect();
// Batch-fetch kind:0 profiles to populate display names, capped so the
// query cost is bounded on large rosters (see MEMBER_PROFILE_JOIN_LIMIT).
let pubkeys = profile_join_pubkeys(&response.members, MEMBER_PROFILE_JOIN_LIMIT);
if !pubkeys.is_empty() {
let profile_events = query_relay(
&state,

View File

@@ -490,3 +490,26 @@ fn last_message_filters_stay_within_relay_channel_cap() {
);
assert_eq!(batches.concat(), filters);
}
fn member(pubkey: &str) -> crate::models::ChannelMemberInfo {
crate::models::ChannelMemberInfo {
pubkey: pubkey.to_string(),
role: "member".to_string(),
is_agent: false,
joined_at: None,
display_name: None,
}
}
#[test]
fn profile_join_pubkeys_caps_in_roster_order() {
let members = vec![member(PK_A), member(PK_B), member(PK_C)];
assert_eq!(
profile_join_pubkeys(&members, 2),
vec![PK_A.to_string(), PK_B.to_string()]
);
assert_eq!(profile_join_pubkeys(&members, 3).len(), 3);
assert_eq!(profile_join_pubkeys(&members, 10).len(), 3);
assert!(profile_join_pubkeys(&[], 10).is_empty());
}

View File

@@ -1,4 +1,5 @@
import { normalizePubkey } from "@/shared/lib/pubkey";
import { channelAgentMembers } from "@/shared/lib/rosterDerivations";
/**
* Pure merge behind `useKnownAgentPubkeys`: managed agents ∪ relay agents,
@@ -62,10 +63,11 @@ export function mergeChannelKnownAgentPubkeys(
relayAgents: readonly { pubkey: string }[] | undefined,
): ReadonlySet<string> {
const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents));
for (const member of channelMembers ?? []) {
if (member.role === "bot" || member.isAgent) {
pubkeys.add(normalizePubkey(member.pubkey));
}
// Identity-cached agent subset: avoids walking the full roster per call.
for (const member of channelMembers
? channelAgentMembers(channelMembers)
: []) {
pubkeys.add(normalizePubkey(member.pubkey));
}
return pubkeys;
}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -15,6 +16,7 @@ import {
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { useChannelsQuery } from "@/features/channels/hooks";
import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness";
import { usePresenceQuery } from "@/features/presence/hooks";
import type { AgentPersona, Channel, ManagedAgent } from "@/shared/api/types";
import { removeChannelMember } from "@/shared/api/tauri";
@@ -34,6 +36,7 @@ import {
} from "../lib/instanceInputForDefinition";
export function useManagedAgentActions() {
const queryClient = useQueryClient();
const { globalConfig } = useGlobalAgentConfig();
const relayAgentsQuery = useRelayAgentsQuery();
const managedAgentsQuery = useManagedAgentsQuery();
@@ -296,6 +299,9 @@ export function useManagedAgentActions() {
await Promise.allSettled(
channelIds.map((channelId) => removeChannelMember(channelId, pubkey)),
);
// Direct writes bypass the member mutations' invalidation; without this,
// the deleted agent stays in cached rosters for the freshness window.
await invalidateChannelMembersRosters(queryClient, channelIds);
}
async function handleDelete(pubkey: string) {

View File

@@ -353,3 +353,22 @@ test("reconcileRefreshedCachedChannel_preservesRefreshedDmRecency", () => {
ownerPubkey,
]);
});
test("invalidateChannelMembersRosters dedupes and targets member keys", async () => {
const { invalidateChannelMembersRosters } = await import(
"./rosterFreshness.ts"
);
const invalidated = [];
const queryClient = {
invalidateQueries: async ({ queryKey }) => {
invalidated.push(queryKey);
},
};
await invalidateChannelMembersRosters(queryClient, ["ch-a", "ch-b", "ch-a"]);
assert.deepEqual(invalidated, [
["channels", "ch-a", "members"],
["channels", "ch-b", "members"],
]);
});

View File

@@ -44,12 +44,15 @@ import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRe
import { useIdentityQuery } from "@/shared/api/hooks";
import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
import { useCommunities } from "@/features/communities/useCommunities";
import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission";
import {
inspectChannelSnapshot,
type ChannelSnapshot,
writeChannelSnapshot,
} from "@/features/channels/channelSnapshot";
import {
CHANNEL_MEMBERS_STALE_TIME_MS,
channelMembersQueryKey,
} from "@/features/channels/rosterFreshness";
export const channelsQueryKey = ["channels"] as const;
/** Keeps focused polling at the established one-minute cadence. */
@@ -69,8 +72,6 @@ export const channelsFocusRefetchPolicy = {
const channelsSnapshotPairKey = ["channels", "_snapshot-pair"] as const;
const channelDetailQueryKey = (channelId: string) =>
["channels", channelId, "detail"] as const;
const channelMembersQueryKey = (channelId: string) =>
["channels", channelId, "members"] as const;
const channelTypeOrder = {
stream: 0,
forum: 1,
@@ -628,7 +629,7 @@ export function useChannelMembersQuery(
return getChannelMembers(channelId);
},
staleTime: 30_000,
staleTime: CHANNEL_MEMBERS_STALE_TIME_MS,
});
}
@@ -795,32 +796,6 @@ export function useDeleteChannelMutation(channelId: string | null) {
});
}
/**
* Whether the signed-in identity may add *another* identity to this channel,
* per {@link canAddChannelMembers}. Both queries are the ones the channel UI
* already holds, so this shares their cache rather than fetching again.
*/
export function useCanAddChannelMembers(channelId: string | null) {
const channelsQuery = useChannelsQuery();
const membersQuery = useChannelMembersQuery(channelId);
const identityQuery = useIdentityQuery();
const channel =
channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null;
const selfPubkey = identityQuery.data?.pubkey ?? null;
const selfRole = selfPubkey
? (membersQuery.data?.find(
(member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(),
)?.role ?? null)
: null;
return canAddChannelMembers({
channelType: channel?.channelType,
visibility: channel?.visibility,
selfRole,
});
}
export function useAddChannelMembersMutation(channelId: string | null) {
const queryClient = useQueryClient();

View File

@@ -87,11 +87,9 @@ test("canStartHuddleInChannel blocks non-participant DMs", () => {
});
test("canStartHuddleInChannel keeps private channels member-gated", () => {
const privateChannel = channel({ visibility: "private" });
assert.equal(
canStartHuddleInChannel({
channel: privateChannel,
channel: channel({ visibility: "private", isMember: false }),
currentPubkey: SELF,
selfMember: null,
}),
@@ -100,7 +98,7 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => {
assert.equal(
canStartHuddleInChannel({
channel: privateChannel,
channel: channel({ visibility: "private", isMember: false }),
currentPubkey: SELF,
selfMember: member(),
}),
@@ -108,6 +106,19 @@ test("canStartHuddleInChannel keeps private channels member-gated", () => {
);
});
test("canStartHuddleInChannel accepts channel-level membership without a roster", () => {
// The roster is fetched lazily; `channel.isMember` derives from the same
// kind:39002 event, so it must satisfy the private-channel gate on its own.
assert.equal(
canStartHuddleInChannel({
channel: channel({ visibility: "private", isMember: true }),
currentPubkey: SELF,
selfMember: null,
}),
true,
);
});
test("canStartHuddleInChannel blocks archived channels and DMs", () => {
assert.equal(
canStartHuddleInChannel({

View File

@@ -31,5 +31,10 @@ export function canStartHuddleInChannel({
);
}
return channel.visibility === "open" || selfMember !== null;
// `channel.isMember` and the roster's self entry derive from the same
// kind:39002 event; either satisfies the private-channel gate, so callers
// that no longer fetch the roster eagerly keep huddle access.
return (
channel.visibility === "open" || channel.isMember || selfMember !== null
);
}

View File

@@ -0,0 +1,45 @@
/**
* Member-roster freshness policy and the invalidation helper for write paths
* that bypass the member mutations. Split from hooks.ts to keep that file
* under the per-file line cap; behavior unchanged.
*/
import type { useQueryClient } from "@tanstack/react-query";
/** Single source for the members cache key; hooks.ts imports it from here. */
export const channelMembersQueryKey = (channelId: string) =>
["channels", channelId, "members"] as const;
/**
* Freshness window for the full member roster. Kept long because every
* membership change the client can observe invalidates this key explicitly:
* live join/leave/removed system messages for the active channel
* (useChannelSubscription), member-added/removed notifications targeting the
* current identity (useMembershipNotifications), and every membership
* mutation (add/remove/join/leave, template apply). The residual staleness is
* a third party joining a channel the viewer is not currently subscribed to,
* which corrects within this window. The previous 30s window put a full
* roster fetch (kind:39002 + a kind:0 batch over every member) on nearly
* every channel switch.
*/
export const CHANNEL_MEMBERS_STALE_TIME_MS = 5 * 60_000;
/**
* Invalidates cached rosters for channels whose membership was written
* through direct `removeChannelMember` calls that bypass the member
* mutations (moderation kick, agent-deletion cleanup). The roster's long
* freshness window (CHANNEL_MEMBERS_STALE_TIME_MS) means any direct write
* path that skips this leaves the removed identity visible until the window
* lapses. Accepts a minimal client shape so node unit tests can stub it.
*/
export async function invalidateChannelMembersRosters(
queryClient: Pick<ReturnType<typeof useQueryClient>, "invalidateQueries">,
channelIds: Iterable<string>,
) {
const uniqueChannelIds = [...new Set(channelIds)];
for (const channelId of uniqueChannelIds) {
await queryClient.invalidateQueries({
queryKey: channelMembersQueryKey(channelId),
});
}
}

View File

@@ -65,7 +65,14 @@ export function ChannelMembersBar({
);
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channel.id);
// The roster is only needed for DM huddle composition (agent detection and
// participant naming). Streams/forums render the count from the channel
// summary and gate huddle access on `channel.isMember`, so mounting this
// bar must not put a full-roster fetch on the channel-switch path.
const membersQuery = useChannelMembersQuery(
channel.id,
channel.channelType === "dm",
);
const providersQuery = useAvailableAcpRuntimes();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();

View File

@@ -10,6 +10,7 @@ import type {
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { channelAgentMembers } from "@/shared/lib/rosterDerivations";
import {
buildChannelAgentSessionCandidates,
getChannelAgentSessionAgents,
@@ -178,9 +179,12 @@ export function mergeMemberAgentFlagsIntoProfiles(
| readonly Pick<ChannelMember, "pubkey" | "role" | "isAgent">[]
| undefined,
): UserProfileLookup {
const agentMembers = (channelMembers ?? []).filter(
(member) => member.role === "bot" || member.isAgent,
);
if (!channelMembers) {
return profiles;
}
// Identity-cached: this merge re-runs whenever the profile lookup re-keys,
// and the filter walked the full roster (10k+ members) each time.
const agentMembers = channelAgentMembers(channelMembers);
if (agentMembers.length === 0) {
return profiles;
}

View File

@@ -9,6 +9,10 @@ import type {
} from "@/shared/api/types";
import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import {
channelBotMemberPubkeySet,
channelMemberPubkeySet,
} from "@/shared/lib/rosterDerivations";
import {
type AgentSessionReturnTarget,
resolveAgentSessionReturnTarget,
@@ -121,15 +125,14 @@ export function getChannelAgentSessionAgents({
return [];
}
// Identity-cached: the memo recomputes whenever the active channel object
// churns (e.g. lastMessageAt updates), and these Sets walked the full
// roster each time.
const memberPubkeys = channelMembers
? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey)))
? channelMemberPubkeySet(channelMembers)
: null;
const botMemberPubkeys = channelMembers
? new Set(
channelMembers
.filter((member) => member.role === "bot")
.map((member) => normalizePubkey(member.pubkey)),
)
? channelBotMemberPubkeySet(channelMembers)
: null;
return agents.filter((agent) => {

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission";
import {
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
// Split from hooks.ts to keep that file under the per-file line cap.
/**
* Whether the signed-in identity may add *another* identity to this channel,
* per {@link canAddChannelMembers}. Both queries are the ones the channel UI
* already holds, so this shares their cache rather than fetching again.
*/
export function useCanAddChannelMembers(channelId: string | null) {
const channelsQuery = useChannelsQuery();
const membersQuery = useChannelMembersQuery(channelId);
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data;
const members = membersQuery.data;
const selfPubkey = identityQuery.data?.pubkey ?? null;
// Memoized: this hook sits in the composer (useMentionSendFlow), which
// re-renders per keystroke, and these scans are O(channels) + O(members)
// — rosters can be 10k+. Structural sharing keeps the inputs' identities
// stable, so the scans run once per data change instead of per keystroke.
return React.useMemo(() => {
const channel =
channels?.find((candidate) => candidate.id === channelId) ?? null;
const selfRole = selfPubkey
? (members?.find(
(member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(),
)?.role ?? null)
: null;
return canAddChannelMembers({
channelType: channel?.channelType,
visibility: channel?.visibility,
selfRole,
});
}, [channelId, channels, members, selfPubkey]);
}

View File

@@ -38,6 +38,9 @@ import {
} from "@/shared/constants/kinds";
import { resolveEventAuthorPubkey } from "@/shared/lib/authors";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { channelRoleMap } from "@/shared/lib/rosterDerivations";
const EMPTY_ROLE_MAP: ReadonlyMap<string, string> = new Map();
import { formatTime } from "@/features/messages/lib/dateFormatters";
// Pure overlay helper lives in a sibling .mjs so node:test (no TS loader)
// can exercise the exact same source the renderer uses.
@@ -228,12 +231,9 @@ export function formatTimelineMessages(
ownerProfiles?: UserProfileLookup,
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
if (members) {
for (const member of members) {
roleByPubkey.set(member.pubkey.toLowerCase(), member.role);
}
}
// Identity-cached: rosters can be 10k+ members and this formatter re-runs
// on every live message; the map is computed once per distinct roster.
const roleByPubkey = members ? channelRoleMap(members) : EMPTY_ROLE_MAP;
const deletedEventIds = new Set<string>();
for (const event of events) {
// Both kind:5 and kind:9005 are deletion markers; mirror the relay.

View File

@@ -39,6 +39,7 @@ import type {
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { flushMentionDebounce } from "./flushMentionDebounce";
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
@@ -225,9 +226,10 @@ export function useMentions(
() => new Set(activePersonas.map((persona) => persona.id)),
[activePersonas],
);
// Identity-cached (shared with the timeline's roster derivations) — the
// Set is built once per distinct roster instead of per consumer.
const memberPubkeys = React.useMemo(
() =>
new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))),
() => (members ? channelMemberPubkeySet(members) : new Set<string>()),
[members],
);
const agentIdentityPubkeys = React.useMemo(

View File

@@ -10,10 +10,8 @@ import {
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import {
useAddChannelMembersMutation,
useCanAddChannelMembers,
} from "@/features/channels/hooks";
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission";
import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError";
import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys";

View File

@@ -1,9 +1,11 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
deleteManagedAgentWithRules,
type ManagedAgentActionResult,
} from "@/features/agents/lib/managedAgentControlActions";
import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness";
import { removeChannelMember } from "@/shared/api/tauri";
import type {
AgentPersona,
@@ -46,6 +48,7 @@ export function useProfileAgentDeletion({
presenceLookup,
relayAgents,
}: UseProfileAgentDeletionInput) {
const queryClient = useQueryClient();
const removeAgentFromAllChannels = React.useCallback(
async (agentPubkey: string) => {
const normalizedPubkey = agentPubkey.toLowerCase();
@@ -67,8 +70,12 @@ export function useProfileAgentDeletion({
removeChannelMember(channelId, agentPubkey),
),
);
// Direct writes bypass the member mutations' invalidation; without
// this, the deleted agent stays in cached rosters for the freshness
// window.
await invalidateChannelMembersRosters(queryClient, channelIds);
},
[channels, relayAgents],
[channels, queryClient, relayAgents],
);
const deleteManagedAgentRecord = React.useCallback(

View File

@@ -1,7 +1,10 @@
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, ShieldAlert } from "lucide-react";
import { useMemo } from "react";
import { toast } from "sonner";
import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness";
import {
useModerationAuditQuery,
useModerationReportsQuery,
@@ -365,6 +368,7 @@ function QueueGroupCard({
}
function QueueTab() {
const queryClient = useQueryClient();
const reportsQuery = useModerationReportsQuery({ status: "open" });
const auditQuery = useModerationAuditQuery();
const resolveMutation = useResolveReportMutation();
@@ -408,6 +412,12 @@ function QueueTab() {
// report open (retryable, no orphan decision row). Only after the paired
// 9040/9005/9001 lands do we resolve every open report about this target.
await enforceResolution(group, action, banMutation.mutateAsync);
if (action === "kick" && group.channelId != null) {
// The kick writes the roster directly (no member mutation); without
// this, the kicked identity stays in the cached roster for the
// freshness window.
await invalidateChannelMembersRosters(queryClient, [group.channelId]);
}
await Promise.all(
openReports.map((report) =>
resolveMutation.mutateAsync({

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
channelAgentMembers,
channelBotMemberPubkeySet,
channelMemberPubkeySet,
channelRoleMap,
} from "./rosterDerivations.ts";
const HUMAN = "A".repeat(64);
const BOT_ROLE = "b".repeat(64);
const FLAGGED_AGENT = "c".repeat(64);
function roster() {
return [
{ pubkey: HUMAN, role: "owner", isAgent: false },
{ pubkey: BOT_ROLE, role: "bot", isAgent: false },
{ pubkey: FLAGGED_AGENT, role: "member", isAgent: true },
];
}
test("channelRoleMap lowercases pubkeys and maps roles", () => {
const map = channelRoleMap(roster());
assert.equal(map.get(HUMAN.toLowerCase()), "owner");
assert.equal(map.get(BOT_ROLE), "bot");
assert.equal(map.size, 3);
});
test("channelAgentMembers keeps bot-role and agent-flagged members", () => {
const agents = channelAgentMembers(roster());
assert.deepEqual(
agents.map((member) => member.pubkey),
[BOT_ROLE, FLAGGED_AGENT],
);
});
test("channelMemberPubkeySet normalizes; bot set is role-gated only", () => {
const members = roster();
assert.deepEqual(
[...channelMemberPubkeySet(members)],
[HUMAN.toLowerCase(), BOT_ROLE, FLAGGED_AGENT],
);
// role === "bot" only — an isAgent flag without the role stays out, matching
// the agent-session filter this set feeds.
assert.deepEqual([...channelBotMemberPubkeySet(members)], [BOT_ROLE]);
});
test("derivations are cached on roster identity", () => {
const members = roster();
assert.equal(channelRoleMap(members), channelRoleMap(members));
assert.equal(channelAgentMembers(members), channelAgentMembers(members));
assert.equal(
channelMemberPubkeySet(members),
channelMemberPubkeySet(members),
);
assert.equal(
channelBotMemberPubkeySet(members),
channelBotMemberPubkeySet(members),
);
// A new array — even with equal content — recomputes: the cache is keyed on
// identity, which React Query's structural sharing keeps stable for
// unchanged rosters.
assert.notEqual(channelRoleMap(members), channelRoleMap(roster()));
});

View File

@@ -0,0 +1,96 @@
/**
* Roster-derived lookups cached on the roster array's identity.
*
* Channel rosters can be 10k+ members, and several render-path consumers
* (timeline role badges, agent-flag merges, agent-session filters) each
* derived their own Map/Set from the full roster on every recompute — every
* live message re-ran the O(members) passes. React Query's structural sharing
* keeps the roster array's identity stable across refetches that return
* identical data, so a WeakMap keyed on that identity computes each
* derivation once per distinct roster.
*/
import { normalizePubkey } from "@/shared/lib/pubkey";
type RosterMember = {
pubkey: string;
role: string;
isAgent: boolean;
};
const roleMaps = new WeakMap<
readonly RosterMember[],
ReadonlyMap<string, string>
>();
/** Lowercased pubkey → channel role for the given roster. */
export function channelRoleMap<T extends RosterMember>(
members: readonly T[],
): ReadonlyMap<string, string> {
const cached = roleMaps.get(members);
if (cached) return cached;
const map = new Map<string, string>();
for (const member of members) {
map.set(member.pubkey.toLowerCase(), member.role);
}
roleMaps.set(members, map);
return map;
}
const agentMemberLists = new WeakMap<
readonly RosterMember[],
readonly RosterMember[]
>();
/** Members that are agents: role `bot` or an explicit `isAgent` flag. */
export function channelAgentMembers<T extends RosterMember>(
members: readonly T[],
): readonly T[] {
const cached = agentMemberLists.get(members);
if (cached) return cached as readonly T[];
const agents = members.filter(
(member) => member.role === "bot" || member.isAgent,
);
agentMemberLists.set(members, agents);
return agents;
}
const memberPubkeySets = new WeakMap<
readonly RosterMember[],
ReadonlySet<string>
>();
/** Normalized pubkeys of every roster member. */
export function channelMemberPubkeySet(
members: readonly RosterMember[],
): ReadonlySet<string> {
const cached = memberPubkeySets.get(members);
if (cached) return cached;
const set = new Set(members.map((member) => normalizePubkey(member.pubkey)));
memberPubkeySets.set(members, set);
return set;
}
const botMemberPubkeySets = new WeakMap<
readonly RosterMember[],
ReadonlySet<string>
>();
/**
* Normalized pubkeys of role-`bot` members only. Deliberately narrower than
* {@link channelAgentMembers}: the agent-session filter treats the membership
* role as authoritative and ignores profile-derived agent flags.
*/
export function channelBotMemberPubkeySet(
members: readonly RosterMember[],
): ReadonlySet<string> {
const cached = botMemberPubkeySets.get(members);
if (cached) return cached;
const set = new Set(
members
.filter((member) => member.role === "bot")
.map((member) => normalizePubkey(member.pubkey)),
);
botMemberPubkeySets.set(members, set);
return set;
}