From 824796a4b9e56076117091dccb046218e05cb24b Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Thu, 22 Oct 2026 16:06:49 -0400 Subject: [PATCH 1/3] Show member avatars in the channel toolbar Co-authored-by: OpenAI Codex Signed-off-by: Arjun Mahanti --- .../channels/ChannelMemberAvatars.test.tsx | 68 +++++++++++++++++++ src/bundled/channels/ChannelMemberAvatars.tsx | 68 +++++++++++++++++++ src/bundled/channels/ChannelsPage.tsx | 15 +++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/bundled/channels/ChannelMemberAvatars.test.tsx create mode 100644 src/bundled/channels/ChannelMemberAvatars.tsx diff --git a/src/bundled/channels/ChannelMemberAvatars.test.tsx b/src/bundled/channels/ChannelMemberAvatars.test.tsx new file mode 100644 index 0000000000..86be99a0e5 --- /dev/null +++ b/src/bundled/channels/ChannelMemberAvatars.test.tsx @@ -0,0 +1,68 @@ +// @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(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(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..a2b537a474 --- /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(":"), "foreground") + .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