mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
45
desktop/src/features/channels/rosterFreshness.ts
Normal file
45
desktop/src/features/channels/rosterFreshness.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
44
desktop/src/features/channels/useCanAddChannelMembers.ts
Normal file
44
desktop/src/features/channels/useCanAddChannelMembers.ts
Normal 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]);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
66
desktop/src/shared/lib/rosterDerivations.test.mjs
Normal file
66
desktop/src/shared/lib/rosterDerivations.test.mjs
Normal 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()));
|
||||
});
|
||||
96
desktop/src/shared/lib/rosterDerivations.ts
Normal file
96
desktop/src/shared/lib/rosterDerivations.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user