Repository navigation
Show channel member avatars with a flexible overflow capsule #1566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mahanti
wants to merge
3
commits into
main
Choose a base branch
from
am-1022-channel-members
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| .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", | ||
| }))} | ||
| /> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.