diff --git a/src/bundled/channels/ChannelMemberAvatars.test.tsx b/src/bundled/channels/ChannelMemberAvatars.test.tsx
new file mode 100644
index 0000000000..7b70b71b95
--- /dev/null
+++ b/src/bundled/channels/ChannelMemberAvatars.test.tsx
@@ -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();
+ 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();
+});
diff --git a/src/bundled/channels/ChannelMemberAvatars.tsx b/src/bundled/channels/ChannelMemberAvatars.tsx
new file mode 100644
index 0000000000..e15d0c6e00
--- /dev/null
+++ b/src/bundled/channels/ChannelMemberAvatars.tsx
@@ -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")
+ .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 ;
+ return (
+ ({
+ id,
+ name: resolveName(
+ id,
+ profiles.get(id)?.name ?? formatPublicKey(id) ?? "Member",
+ ),
+ src: avatarMedia(profiles.get(id)?.picture, session.media),
+ shape: "circle",
+ }))}
+ />
+ );
+}
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 0d493cc812..f152d16a47 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -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,
@@ -1665,6 +1665,12 @@ function ChannelWorkspace({
{current && (
openMembers(event.currentTarget)
}
- icon={}
+ icon={
+
+ }
/>
)}
{drawer.launchers}
diff --git a/src/shared/design-system/ui/AvatarStack.module.css b/src/shared/design-system/ui/AvatarStack.module.css
index c449a5fc10..1f9cdb9a8b 100644
--- a/src/shared/design-system/ui/AvatarStack.module.css
+++ b/src/shared/design-system/ui/AvatarStack.module.css
@@ -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));