Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default defineConfig({
"**/mention-spacing.spec.ts",
"**/mention-recipients.spec.ts",
"**/message-edit-focus.spec.ts",
"**/mention-picker.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",
Expand Down
31 changes: 9 additions & 22 deletions desktop/src/features/channels/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
deleteChannel,
getCanvas,
getChannelDetails,
getChannelMembers,
getChannels,
hideDm,
joinChannel,
Expand Down Expand Up @@ -49,11 +48,9 @@ import {
type ChannelSnapshot,
writeChannelSnapshot,
} from "@/features/channels/channelSnapshot";
import {
CHANNEL_MEMBERS_STALE_TIME_MS,
channelMembersQueryKey,
} from "@/features/channels/rosterFreshness";
import { channelMembersQueryKey } from "@/features/channels/rosterFreshness";
import { dmVisibilityQueryKeyFor } from "@/features/channels/useHiddenDmIds";
import { refreshDirectoryAfterMembershipChange } from "./membershipDirectorySync";

export const channelsQueryKey = ["channels"] as const;
/** Keeps focused polling at the established one-minute cadence. */
Expand Down Expand Up @@ -516,6 +513,7 @@ export function useCreateChannelMutation() {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannel(current, createdChannel),
);
refreshDirectoryAfterMembershipChange(queryClient);
},
onSettled: () => {
// refetchType "none": onSuccess already cached the relay-returned channel;
Expand Down Expand Up @@ -642,23 +640,7 @@ export function useChannelDetailsQuery(
});
}

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 { useChannelMembersQuery } from "./useChannelMembersQuery";

export function useUpdateChannelMutation(channelId: string | null) {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -841,6 +823,9 @@ export function useAddChannelMembersMutation(channelId: string | null) {
return addChannelMembers({ ...rest, channelId: effectiveChannelId });
},
onSuccess: (result, variables) => {
if (result.added.length > 0) {
refreshDirectoryAfterMembershipChange(queryClient);
}
const effectiveChannelId = variables.channelId ?? channelId;
if (
effectiveChannelId &&
Expand Down Expand Up @@ -896,6 +881,7 @@ export function useJoinChannelMutation(channelId: string | null) {

await joinChannel(channelId);
},
onSuccess: () => refreshDirectoryAfterMembershipChange(queryClient),
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
Expand All @@ -913,6 +899,7 @@ export function useLeaveChannelMutation(channelId: string | null) {

await leaveChannel(channelId);
},
onSuccess: () => refreshDirectoryAfterMembershipChange(queryClient),
onSettled: async () => {
await invalidateChannelState(queryClient, channelId);
},
Expand Down
29 changes: 29 additions & 0 deletions desktop/src/features/channels/membershipDirectorySync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, test } from "node:test";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import {
refreshDirectoryAfterMembershipChange as refresh,
refreshDirectoryForRosterChange as rosterChanged,
resetMembershipDirectorySync,
} from "./membershipDirectorySync.ts";

Expand Down Expand Up @@ -118,6 +119,34 @@ test("failed refresh ends in error and does not schedule a self-sustaining retry
assert.equal(calls, 1);
});

test("only changed membership or agent classification refreshes; names and ordering do not", async () => {
const value = client();
let calls = 0;
observe(value, async () => {
calls += 1;
return [];
});
const person = { pubkey: "a".repeat(64), role: "member", isAgent: false };
const agent = { pubkey: "b".repeat(64), role: "member", isAgent: true };
rosterChanged(value, undefined, [person]);
rosterChanged(
value,
[person, agent],
[{ ...agent, displayName: "Renamed" }, person],
);
await settle();
assert.equal(calls, 0);
rosterChanged(value, [person], [person, agent]);
await settle();
assert.equal(calls, 1);
rosterChanged(value, [person, agent], [person]);
await settle();
assert.equal(calls, 2);
rosterChanged(value, undefined, [agent]);
await settle();
assert.equal(calls, 3);
});

test("community reset cancels queued work and event deduplication is client-scoped", async () => {
const first = client();
const second = client();
Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/channels/membershipDirectorySync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { QueryClient } from "@tanstack/react-query";

import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";

const directoryQueryKey = ["relay-agents"] as const;
const COALESCE_MS = 200;
const MAX_EVENT_IDS = 256;
Expand Down Expand Up @@ -78,3 +81,34 @@ export function refreshDirectoryAfterMembershipChange(
});
}, COALESCE_MS);
}

function membershipFingerprint(members: readonly ChannelMember[]): string {
return members
.map(
(member) =>
`${normalizePubkey(member.pubkey)}:${member.role ?? ""}:${member.isAgent === true}`,
)
.sort()
.join("|");
}

/**
* A later roster can observe a write that the acceptance-time directory read
* could not yet see. Refresh once for that semantic change, not for names,
* ordering, repeated snapshots, or every local store notification. An initial
* agent-bearing roster also repairs discovery when no previous roster exists.
*/
export function refreshDirectoryForRosterChange(
queryClient: QueryClient,
previous: readonly ChannelMember[] | undefined,
current: readonly ChannelMember[],
): void {
if (
previous
? membershipFingerprint(previous) === membershipFingerprint(current)
: !current.some((member) => member.isAgent || member.role === "bot")
) {
return;
}
refreshDirectoryAfterMembershipChange(queryClient);
}
Loading
Loading