Files
buzz/desktop/src/features/channels/hooks.ts
Max Lampert b0466ac465 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>
2026-08-21 17:40:20 -06:00

971 lines
28 KiB
TypeScript

import * as React from "react";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import {
addChannelMembers,
archiveChannel,
createChannel,
deleteChannel,
getCanvas,
getChannelDetails,
getChannelMembers,
getChannels,
hideDm,
joinChannel,
leaveChannel,
openDm,
invokeTauri,
removeChannelMember,
setCanvas,
setChannelPurpose,
setChannelTopic,
unarchiveChannel,
updateChannel,
} from "@/shared/api/tauri";
import type {
AddChannelMembersInput,
Channel,
ChannelDetail,
CreateChannelInput,
SetChannelPurposeInput,
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import type {
GetChannelsPayload,
OpenDmInput,
} from "@/shared/api/tauriChannels";
import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
import { useCommunities } from "@/features/communities/useCommunities";
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. */
export const CHANNELS_REFETCH_INTERVAL_MS = 60_000;
/** Suppresses the expensive focus refetch until the channel list is old. */
export const CHANNELS_FOCUS_STALE_TIME_MS = 5 * 60_000;
/** Focus-refetch policy for the channels query; consumed by focusRefetchPolicy.test.mjs. */
export const channelsFocusRefetchPolicy = {
staleTime: CHANNELS_FOCUS_STALE_TIME_MS,
refetchOnWindowFocus: false,
} as const;
/**
* Authoritative server list/hash pair. Presentation mutations may patch
* `channelsQueryKey`, but may never change or be persisted with this hash.
*/
const channelsSnapshotPairKey = ["channels", "_snapshot-pair"] as const;
const channelDetailQueryKey = (channelId: string) =>
["channels", channelId, "detail"] as const;
const channelTypeOrder = {
stream: 0,
forum: 1,
dm: 2,
} as const;
export function sortChannels(channels: Channel[]) {
const uniqueChannels = new Map<string, Channel>();
for (const channel of channels) {
uniqueChannels.set(channel.id, channel);
}
return [...uniqueChannels.values()].sort((left, right) => {
const typeOrder =
channelTypeOrder[left.channelType] - channelTypeOrder[right.channelType];
if (typeOrder !== 0) {
return typeOrder;
}
return left.name.localeCompare(right.name);
});
}
export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK =
"buzz:sidebar:snapshot-diagnostic";
export const CHANNELS_FULL_SIDEBAR_PAINT_MARK =
"buzz:sidebar:full-list-painted";
export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE =
"buzz:sidebar:boot-to-full-list-painted";
const markedSnapshotKeys = new Set<string>();
const measuredSidebarKeys = new Set<string>();
const scheduledSidebarKeys = new Set<string>();
function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string {
return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`;
}
function markSnapshotDiagnostic(
relayUrl: string,
ownerPubkey: string,
diagnostics: ReturnType<typeof inspectChannelSnapshot>["diagnostics"],
): void {
if (typeof performance === "undefined") return;
const key = sidebarMeasurementKey(relayUrl, ownerPubkey);
if (markedSnapshotKeys.has(key)) return;
markedSnapshotKeys.add(key);
performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, {
detail: { ...diagnostics, relayUrl },
});
console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl });
}
function measureFullSidebarPaint(
relayUrl: string,
ownerPubkey: string,
channelCount: number,
): void {
if (typeof performance === "undefined") return;
const key = sidebarMeasurementKey(relayUrl, ownerPubkey);
if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return;
scheduledSidebarKeys.add(key);
// The channels have committed to the shared query cache; two animation frames
// put the mark after React's sidebar DOM commit and the browser's next paint.
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scheduledSidebarKeys.delete(key);
if (measuredSidebarKeys.has(key)) return;
measuredSidebarKeys.add(key);
performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, {
detail: { channelCount, relayUrl },
});
performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, {
detail: { channelCount, relayUrl },
duration: performance.now(),
start: 0,
});
const measure = performance
.getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE)
.at(-1);
console.info("[sidebar-perf] full list painted", {
channelCount,
durationMs: measure?.duration,
relayUrl,
});
});
});
}
export type CachedChannelMember = {
membershipAdded: boolean;
name: string;
pubkey: string;
};
/**
* Records a successful membership mutation in the shared channel list before
* its read-after-write refetch completes. DM participant sets are immutable,
* so adding a member there creates a separate conversation and must never
* decorate the source channel optimistically. Exported for focused cache race
* regression coverage.
*/
export function upsertCachedChannelMember(
current: Channel[] | undefined,
channelId: string,
member: CachedChannelMember,
): Channel[] | undefined {
if (!current) {
return current;
}
const normalizedPubkey = member.pubkey.toLowerCase();
return sortChannels(
current.map((channel) => {
if (channel.id !== channelId) {
return channel;
}
if (channel.channelType === "dm") {
return channel;
}
const hasMember = channel.memberPubkeys.some(
(pubkey) => pubkey.toLowerCase() === normalizedPubkey,
);
const memberPubkeys = hasMember
? channel.memberPubkeys
: [...channel.memberPubkeys, member.pubkey];
return {
...channel,
memberCount: Math.max(
memberPubkeys.length,
channel.memberCount + (member.membershipAdded && !hasMember ? 1 : 0),
),
memberPubkeys,
};
}),
);
}
/**
* Adds or replaces a relay-returned channel in a possibly stale channel list.
* Exported for focused cache race regression coverage.
*/
export function upsertCachedChannel(
current: Channel[] | undefined,
channel: Channel,
): Channel[] {
return sortChannels([
...(current ?? []).filter((candidate) => candidate.id !== channel.id),
channel,
]);
}
/**
* Reconciles a relay-returned channel after a list refresh. When the refresh
* already contains the immutable DM, its current metadata wins over the older
* snapshot used to open the route. Otherwise the opened channel repairs the
* route after a read-after-write-lagged list response.
*/
export function reconcileRefreshedCachedChannel(
refreshed: Channel[] | undefined,
channel: Channel,
): Channel[] {
const refreshedChannel = refreshed?.find(
(candidate) => candidate.id === channel.id,
);
return upsertCachedChannel(refreshed, refreshedChannel ?? channel);
}
export async function invalidateChannelState(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string | null | undefined,
) {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (!channelId) {
return;
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: channelDetailQueryKey(channelId),
}),
queryClient.invalidateQueries({
queryKey: channelMembersQueryKey(channelId),
}),
]);
}
function setChannelArchivedState(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string,
archivedAt: string | null,
) {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels(
current.map((channel) =>
channel.id === channelId ? { ...channel, archivedAt } : channel,
),
),
);
queryClient.setQueryData<ChannelDetail | undefined>(
channelDetailQueryKey(channelId),
(current) => (current ? { ...current, archivedAt } : current),
);
}
/**
* Overlays `last_messages` timestamps onto channels without creating new object
* references for channels whose `lastMessageAt` is unchanged. Preserving
* references lets React Query's structural sharing skip re-renders for
* channels that received no new messages. Exported for unit testing.
*/
export function applyLastMessages(
channels: Channel[],
lastMessages: Record<string, string>,
): Channel[] {
return channels.map((channel) => {
const newTs = lastMessages[channel.id] ?? null;
if (channel.lastMessageAt === newTs) {
return channel;
}
return { ...channel, lastMessageAt: newTs };
});
}
/**
* A failed identity read must disable persisted snapshots, not the live channel
* request. The backend still resolves its authoritative current identity.
*/
export function canFetchChannelsForIdentity(
ownerPubkey: string | null,
identityReadFailed: boolean,
): boolean {
return ownerPubkey !== null || identityReadFailed;
}
/** A hashless retry must return a full list before it can become authoritative. */
export function requireFullChannelList(channels: Channel[] | null): Channel[] {
if (channels === null) {
throw new Error("get_channels returned no list for a hashless request");
}
return channels;
}
export type RefreshChannelsQueryOptions = {
queryClient: QueryClient;
initialSnapshotPair: ChannelSnapshot | null;
relayUrl: string | null;
ownerPubkey: string | null;
fetchChannels?: (knownHash: string | null) => Promise<GetChannelsPayload>;
persistSnapshot?: typeof writeChannelSnapshot;
};
/**
* Revalidates the channel query while preserving live recency updates that land
* during the request. Exported so the production query/cache interleaving can
* be regression-tested without replacing it with a helper-only simulation.
*/
export async function refreshChannelsQuery({
queryClient,
initialSnapshotPair,
relayUrl,
ownerPubkey,
fetchChannels = getChannels,
persistSnapshot = writeChannelSnapshot,
}: RefreshChannelsQueryOptions): Promise<Channel[]> {
// Revalidation uses only an authoritative list/hash pair. The displayed
// channels cache is intentionally ignored because successful mutations
// patch it before the relay's list/hash has necessarily caught up.
const cachedPair =
queryClient.getQueryData<ChannelSnapshot>(channelsSnapshotPairKey) ??
initialSnapshotPair;
const knownHash = cachedPair?.hash ?? null;
const channelsAtRequestStart =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const payload = await fetchChannels(knownHash);
// A not-modified response is usable only when it echoes the exact hash
// that described the available list. Any other hash/list pairing fails
// slow-never-wrong by retrying without a hash.
const hasMatchingNotModifiedResponse =
payload.channels === null &&
knownHash !== null &&
payload.hash === knownHash;
const pairChannels =
payload.channels ??
(hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined);
if (!pairChannels) {
// Missing cache or a mismatched not-modified response: discard the hash
// and fetch a complete authoritative list before updating persistence.
const full = await fetchChannels(null);
const authoritativeChannels = sortChannels(
applyLastMessages(
requireFullChannelList(full.channels),
full.lastMessages,
),
);
const displayedAtSettlement =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const sorted = sortChannels(
mergeConcurrentChannelRecency(
authoritativeChannels,
displayedAtSettlement,
channelsAtRequestStart,
),
);
const pair = { channels: authoritativeChannels, hash: full.hash };
queryClient.setQueryData(channelsSnapshotPairKey, pair);
if (relayUrl && ownerPubkey) {
persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
}
const authoritativeChannels = sortChannels(
applyLastMessages(pairChannels, payload.lastMessages),
);
const pair = {
channels: authoritativeChannels,
hash: payload.hash,
};
queryClient.setQueryData(channelsSnapshotPairKey, pair);
// Merge against the displayed cache at settlement so a newer live
// timestamp cannot be rolled back by an older request result. This is
// required for both full-list and matching not-modified responses.
const displayedAtSettlement =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const refreshedForDisplay =
payload.channels === null
? sortChannels(
applyLastMessages(
displayedAtSettlement ?? authoritativeChannels,
payload.lastMessages,
),
)
: authoritativeChannels;
const sorted = sortChannels(
mergeConcurrentChannelRecency(
refreshedForDisplay,
displayedAtSettlement,
channelsAtRequestStart,
),
);
if (relayUrl && ownerPubkey) {
persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
}
export function useChannelsQuery(options?: { enabled?: boolean }) {
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? null;
// CommunityQueryProvider remounts its QueryClient for every community. Only
// the active identity may authorize a persisted snapshot: Community.pubkey
// is creation-time display metadata and can be stale after identity changes.
const identityQuery = useIdentityQuery();
const ownerPubkey = identityQuery.data?.pubkey ?? null;
const queryClient = useQueryClient();
const snapshotRead = React.useMemo(
() =>
relayUrl && ownerPubkey
? inspectChannelSnapshot(relayUrl, ownerPubkey)
: null,
[ownerPubkey, relayUrl],
);
const snapshot = snapshotRead?.snapshot ?? null;
const initialSnapshotPair = React.useMemo(
() =>
snapshot
? { channels: sortChannels(snapshot.channels), hash: snapshot.hash }
: null,
[snapshot],
);
React.useEffect(() => {
if (relayUrl && ownerPubkey && snapshotRead && options?.enabled !== false) {
markSnapshotDiagnostic(relayUrl, ownerPubkey, snapshotRead.diagnostics);
}
}, [options?.enabled, ownerPubkey, relayUrl, snapshotRead]);
const refetchInterval = useFocusedRefetchInterval(
CHANNELS_REFETCH_INTERVAL_MS,
);
const query = useQuery({
enabled:
(options?.enabled ?? true) &&
relayUrl !== null &&
canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError),
queryKey: channelsQueryKey,
queryFn: () =>
refreshChannelsQuery({
queryClient,
initialSnapshotPair,
relayUrl,
ownerPubkey,
}),
// Paint the complete persisted list immediately. `initialDataUpdatedAt: 0`
// deliberately keeps it stale so every boot still validates against the
// relay; queryFn reads the matching hash from the same atomic document.
initialData: initialSnapshotPair?.channels,
initialDataUpdatedAt: 0,
refetchInterval,
...channelsFocusRefetchPolicy,
});
React.useEffect(() => {
if (
relayUrl &&
ownerPubkey &&
query.isSuccess &&
query.fetchStatus === "idle" &&
query.dataUpdatedAt > 0
) {
measureFullSidebarPaint(relayUrl, ownerPubkey, query.data.length);
}
}, [
query.data,
query.dataUpdatedAt,
query.fetchStatus,
query.isSuccess,
ownerPubkey,
relayUrl,
]);
return query;
}
export function useCreateChannelMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateChannelInput) => createChannel(input),
onSuccess: (createdChannel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannel(current, createdChannel),
);
},
onSettled: () => {
// refetchType "none": onSuccess already cached the relay-returned channel;
// an immediate getChannels() refetch blocked the dialog and could clobber
// it with a read-after-write-lagged snapshot. Live updates reconcile later.
void queryClient.invalidateQueries({
queryKey: channelsQueryKey,
refetchType: "none",
});
},
});
}
export function useOpenDmMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: OpenDmInput) => openDm(input),
onSuccess: (openedChannel) => {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannel(current, openedChannel),
);
},
onSettled: () => {
// The relay-returned DM is already in the cache. Mark the list stale so
// the normal live/poll refresh can reconcile it later without putting a
// full get_channels round-trip on the critical path to the conversation.
void queryClient.invalidateQueries({
queryKey: channelsQueryKey,
refetchType: "none",
});
},
});
}
/**
* Reasserts a relay-returned channel in the shared cache before a caller
* depends on it for navigation. The open-DM mutation already made the relay
* write authoritative, so cancel any older list read and stay local rather
* than blocking on a read-after-write channel-list refresh.
*/
export function useUpsertCachedChannel() {
const queryClient = useQueryClient();
return React.useCallback(
async (channel: Channel) => {
await queryClient.cancelQueries({
queryKey: channelsQueryKey,
exact: true,
});
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
reconcileRefreshedCachedChannel(current, channel),
);
},
[queryClient],
);
}
export function useHideDmMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (channelId: string) => hideDm(channelId),
onMutate: async (channelId) => {
await queryClient.cancelQueries({ queryKey: channelsQueryKey });
const previous = queryClient.getQueryData<Channel[]>(channelsQueryKey);
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
current.filter((channel) => channel.id !== channelId),
);
return { previous };
},
onError: (_error, _channelId, context) => {
if (context?.previous) {
queryClient.setQueryData(channelsQueryKey, context.previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
});
}
export function useChannelDetailsQuery(
channelId: string | null,
enabled = true,
) {
return useQuery({
enabled: enabled && channelId !== null,
queryKey: ["channels", channelId ?? "none", "detail"],
queryFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
return getChannelDetails(channelId);
},
staleTime: 30_000,
});
}
export function useChannelMembersQuery(
channelId: string | null,
enabled = true,
) {
return useQuery({
enabled: enabled && channelId !== null,
queryKey: ["channels", channelId ?? "none", "members"],
queryFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
return getChannelMembers(channelId);
},
staleTime: CHANNEL_MEMBERS_STALE_TIME_MS,
});
}
export function useUpdateChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: Omit<UpdateChannelInput, "channelId">) => {
if (!channelId) {
throw new Error("No channel selected.");
}
return updateChannel({ ...input, channelId });
},
onMutate: () => ({ channelId }),
onSuccess: (updatedChannel) => {
queryClient.setQueryData<ChannelDetail>(
channelDetailQueryKey(updatedChannel.id),
updatedChannel,
);
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
sortChannels(
current.map((channel) =>
channel.id === updatedChannel.id ? updatedChannel : channel,
),
),
);
},
onSettled: (_data, _error, _variables, context) => {
// refetchType "none": onSuccess already cached the relay-returned detail;
// awaiting the full channel-list refetch kept the edit dialog stuck on
// "Saving..." (same failure #1360 fixed for create).
void queryClient.invalidateQueries({
queryKey: channelsQueryKey,
refetchType: "none",
});
if (context?.channelId) {
void queryClient.invalidateQueries({
queryKey: channelDetailQueryKey(context.channelId),
refetchType: "none",
});
}
},
});
}
export function useSetChannelTopicMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: Omit<SetChannelTopicInput, "channelId">) => {
if (!channelId) {
throw new Error("No channel selected.");
}
return setChannelTopic({ ...input, channelId });
},
onSettled: () => {
// fire-and-forget: awaiting the channels-list refetch blocks the dialog
void invalidateChannelState(queryClient, channelId);
},
});
}
export function useSetChannelPurposeMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: Omit<SetChannelPurposeInput, "channelId">) => {
if (!channelId) {
throw new Error("No channel selected.");
}
return setChannelPurpose({ ...input, channelId });
},
onSettled: () => {
// fire-and-forget: awaiting the channels-list refetch blocks the dialog
void invalidateChannelState(queryClient, channelId);
},
});
}
export function useArchiveChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
await archiveChannel(channelId);
},
onSuccess: () => {
if (!channelId) {
return;
}
setChannelArchivedState(queryClient, channelId, new Date().toISOString());
},
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
});
}
export function useUnarchiveChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
await unarchiveChannel(channelId);
},
onSuccess: () => {
if (!channelId) {
return;
}
setChannelArchivedState(queryClient, channelId, null);
},
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
});
}
export function useDeleteChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
await deleteChannel(channelId);
},
onSuccess: () => {
if (!channelId) {
return;
}
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
current.filter((channel) => channel.id !== channelId),
);
queryClient.removeQueries({
queryKey: channelDetailQueryKey(channelId),
});
queryClient.removeQueries({
queryKey: channelMembersQueryKey(channelId),
});
},
onSettled: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: channelsQueryKey }),
queryClient.invalidateQueries({ queryKey: ["managed-agents"] }),
queryClient.invalidateQueries({ queryKey: ["relay-agents"] }),
]);
},
});
}
export function useAddChannelMembersMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (
input: Omit<AddChannelMembersInput, "channelId"> & {
channelId?: string;
},
) => {
const { channelId: capturedChannelId, ...rest } = input;
const effectiveChannelId = capturedChannelId ?? channelId;
if (!effectiveChannelId) {
throw new Error("No channel selected.");
}
return addChannelMembers({ ...rest, channelId: effectiveChannelId });
},
onSuccess: (result, variables) => {
const effectiveChannelId = variables.channelId ?? channelId;
if (
effectiveChannelId &&
variables.role === "bot" &&
result.added.length > 0
) {
void invokeTauri("sync_agents_to_active_huddle", {
channelId: effectiveChannelId,
agentPubkeys: result.added,
}).catch((error) => {
console.warn("Could not sync added agents into Huddle:", error);
});
}
},
onSettled: async (_data, _err, variables) => {
// Invalidate the effective channel (the one actually mutated) not the
// live hook-closure channel, which may have changed mid-send.
const effectiveChannelId = variables?.channelId ?? channelId;
await invalidateChannelState(queryClient, effectiveChannelId);
},
});
}
export function useRemoveChannelMemberMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (pubkey: string) => {
if (!channelId) {
throw new Error("No channel selected.");
}
await removeChannelMember(channelId, pubkey);
},
onSettled: async () => {
await Promise.all([
invalidateChannelState(queryClient, channelId),
queryClient.invalidateQueries({ queryKey: ["managed-agents"] }),
queryClient.invalidateQueries({ queryKey: ["relay-agents"] }),
]);
},
});
}
export function useJoinChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
await joinChannel(channelId);
},
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
});
}
export function useLeaveChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!channelId) {
throw new Error("No channel selected.");
}
await leaveChannel(channelId);
},
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
});
}
export function useSelectedChannel(
channels: Channel[],
preferredChannelId: string | null,
) {
const [selectedChannelId, setSelectedChannelId] = React.useState<
string | null
>(preferredChannelId);
const selectedChannel = React.useMemo(
() =>
channels.find((channel) => channel.id === selectedChannelId) ??
channels.find((channel) => channel.channelType !== "forum") ??
channels[0] ??
null,
[channels, selectedChannelId],
);
React.useEffect(() => {
if (!selectedChannel && channels.length === 0) {
return;
}
if (!selectedChannelId && selectedChannel) {
setSelectedChannelId(selectedChannel.id);
return;
}
if (
selectedChannelId &&
!channels.some((channel) => channel.id === selectedChannelId) &&
selectedChannel
) {
setSelectedChannelId(selectedChannel.id);
}
}, [channels, selectedChannel, selectedChannelId]);
return {
selectedChannel,
selectedChannelId,
setSelectedChannelId,
};
}
// ── Canvas ────────────────────────────────────────────────────────────────────
export function useCanvasQuery(channelId: string | null, enabled = true) {
return useQuery({
queryKey: ["channel-canvas", channelId],
queryFn: () => {
if (!channelId) {
return Promise.reject(new Error("No channel selected"));
}
return getCanvas(channelId);
},
enabled: enabled && channelId !== null,
});
}
export function useSetCanvasMutation(channelId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (content: string) => {
if (!channelId) {
return Promise.reject(new Error("No channel selected"));
}
return setCanvas({ channelId, content });
},
onSuccess: () => {
if (channelId) {
void queryClient.invalidateQueries({
queryKey: ["channel-canvas", channelId],
});
}
},
});
}