Skip to content
Open
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
76 changes: 76 additions & 0 deletions src/bundled/channels/ChannelMemberAvatars.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { RelaySession } from "../../features/relay/session";
import { ChannelMemberAvatars } from "./ChannelMemberAvatars";

afterEach(cleanup);

it("keeps the people preview and overflow current as profiles and membership arrive", () => {
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
let members = ["alice", "bob", "carol", "dana", "agent", "remote-agent"];
let list = { channels: [{ id: "channel", members }] };
let profiles = new Map<
string,
{ name: string; picture?: string; isAgent?: boolean }
>([
["alice", { name: "Alice" }],
["bob", { name: "Bob" }],
["carol", { name: "Carol" }],
["dana", { name: "Dana" }],
["remote-agent", { name: "Remote agent", isAgent: true }],
]);
const choices = { identities: [{ pubkey: "agent" }] };
const session = {
channels: { subscribeList: subscribe, list: () => list },
profiles: {
subscribe,
snapshot: () => profiles,
ensure: vi.fn().mockResolvedValue(undefined),
},
agentChoices: { subscribe, snapshot: () => choices },
media: (url: string) => url,
} as unknown as RelaySession;
render(<ChannelMemberAvatars session={session} channelId="channel" />);
expect(session.profiles.ensure).toHaveBeenLastCalledWith(
members,
"background",
);
expect(screen.getByTitle("Alice")).toBeInTheDocument();
expect(screen.getByTitle("Bob")).toBeInTheDocument();
expect(screen.getByTitle("Carol")).toBeInTheDocument();
expect(screen.queryByTitle("Dana")).not.toBeInTheDocument();
expect(screen.getByText("+1")).toBeInTheDocument();

act(() => {
profiles = new Map(profiles).set("dana", {
name: "Dana",
picture: "https://example.test/dana.png",
});
for (const listener of listeners) listener();
});
expect(screen.getByTitle("Dana").querySelector("img")).toHaveAttribute(
"src",
"https://example.test/dana.png",
);
expect(screen.queryByTitle("Carol")).not.toBeInTheDocument();
expect(screen.getByText("+1")).toBeInTheDocument();

act(() => {
members = ["bob"];
list = { channels: [{ id: "channel", members }] };
for (const listener of listeners) listener();
});
expect(session.profiles.ensure).toHaveBeenLastCalledWith(
["bob"],
"background",
);
expect(screen.getByTitle("Bob")).toBeInTheDocument();
expect(screen.queryByTitle("Dana")).not.toBeInTheDocument();
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
});
68 changes: 68 additions & 0 deletions src/bundled/channels/ChannelMemberAvatars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useSyncExternalStore } from "react";
import type { RelaySession } from "../../features/relay/session";
import { useIdentityNames } from "../../features/identity-names/react";
import { AvatarStack } from "../../shared/design-system/ui/AvatarStack";
import { UsersIcon } from "../../shared/design-system/icons";
import { avatarMedia } from "../../shared/avatar-source";
import { formatPublicKey } from "../../shared/identity/public-key";

/** The members trigger previews people; the dialog still includes every member. */
export function ChannelMemberAvatars({
session,
channelId,
}: {
session: RelaySession;
channelId: string;
}) {
const list = useSyncExternalStore(
session.channels.subscribeList,
session.channels.list,
session.channels.list,
);
const profiles = useSyncExternalStore(
session.profiles.subscribe,
session.profiles.snapshot,
session.profiles.snapshot,
);
const choices = useSyncExternalStore(
session.agentChoices.subscribe,
session.agentChoices.snapshot,
session.agentChoices.snapshot,
);
const resolveName = useIdentityNames(session.names);
const channel =
list.channels.find((item) => item.id === channelId) ??
session.channels.get?.(channelId);
const members = channel?.members ?? [];
const memberKey = members.join(":");
useEffect(() => {
if (memberKey)
void session.profiles
.ensure(memberKey.split(":"), "background")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Promote pending profile batches when Members opens

Keep automatic toolbar enrichment in the background, but preserve the dialog’s foreground demand. ChannelMembersDialog.tsx:625–666 calls foreground ensure and waits for it before revealing the initial list; profile-directory.ts:99–102 only joins pending promises, so it never reaches the reader’s queued-job promotion. Even a small roster can remain queued behind another background read despite spare foreground capacity. Since the 10-second deadline starts when queued, opening Members can remain loading until the profile read times out and displays a name-loading error; Retry only recovers after the pending entry settles. The former foreground toolbar request avoided this serialization.

Have foreground ensure promote overlapping pending batches through the existing reader deduplication/promotion machinery, and cover this handoff with held work through the real directory/reader. The added mock-argument assertions do not exercise that interaction. This is source inference, not a measured latency result.

.catch(() => {});
}, [session, memberKey]);
const agents = new Set(choices.identities.map((agent) => agent.pubkey));
const people = members
.filter((id) => !agents.has(id) && !profiles.get(id)?.isAgent)
// Prefer recognizable photos over key-only placeholders.
.sort(
(a, b) =>
Number(!!profiles.get(b)?.picture) - Number(!!profiles.get(a)?.picture),
);
if (!people.length) return <UsersIcon size="1rem" aria-hidden="true" />;
return (
<AvatarStack
size="small"
max={3}
items={people.map((id) => ({
id,
name: resolveName(
id,
profiles.get(id)?.name ?? formatPublicKey(id) ?? "Member",
),
src: avatarMedia(profiles.get(id)?.picture, session.media),
shape: "circle",
}))}
/>
);
}
15 changes: 13 additions & 2 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ChannelBody } from "./ChannelBody";
import { tabPane, useChannelTabState } from "./useChannelTabState";
import { ChannelMembersDialog } from "./ChannelMembersDialog";
import { sharedSessionsTarget } from "./shared-sessions-panel";
import { UsersIcon } from "../../shared/design-system/icons";
import { ChannelMemberAvatars } from "./ChannelMemberAvatars";
import {
soloDraft,
soloDraftTarget,
Expand Down Expand Up @@ -1665,6 +1665,12 @@ function ChannelWorkspace({
{current && (
<IconButton
size="sm"
variant="avatar"
style={{
width: "auto",
minWidth: "var(--size-control-sm)",
paddingInline: "var(--space-1)",
}}
aria-label="Channel members"
title="Channel members"
aria-haspopup="dialog"
Expand All @@ -1673,7 +1679,12 @@ function ChannelWorkspace({
onClick={(event) =>
openMembers(event.currentTarget)
}
icon={<UsersIcon size="1rem" aria-hidden="true" />}
icon={
<ChannelMemberAvatars
session={queries}
channelId={current.id}
/>
}
/>
)}
{drawer.launchers}
Expand Down
5 changes: 3 additions & 2 deletions src/shared/design-system/ui/AvatarStack.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@
width: 1.5rem;
height: 1.5rem;
}
/* Thread summaries retain their existing circular overflow badge. */
/* Keep short counts round; wider counts grow into a capsule. */
.root[data-size="small"] .count {
display: inline-grid;
place-items: center;
flex-shrink: 0;
width: 1.5rem;
min-width: 1.5rem;
padding-inline: var(--space-1h);
height: 1.5rem;
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--space-1));
Expand Down
Loading