Skip to content

Commit 8413b03

Browse files
committed
feat: show call member presence in pre-join lobby view
Display participant avatars with display names in the lobby when a call is already active. Shows up to 8 avatars with an overflow indicator and tooltip for additional participants. - Add useCallParticipants hook to derive participants from memberships - Add CallParticipantRow component using Compound Tooltip and Radix VisuallyHidden for accessibility - Integrate into LobbyView above VideoPreview - Include unit tests for hook and component - Add i18n strings for participant count and overflow
1 parent af54b39 commit 8413b03

File tree

9 files changed

+497
-0
lines changed

9 files changed

+497
-0
lines changed

locales/en/app.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@
156156
"join_as_guest": "Join as guest",
157157
"join_button": "Join call",
158158
"leave_button": "Back to recents",
159+
"participants_in_call_one": "{{count}} participant in call: {{names}}",
160+
"participants_in_call_other": "{{count}} participants in call: {{names}}",
161+
"participants_in_call_overflow_one": "{{count}} participant in call: {{names}}, and {{overflowCount}} other",
162+
"participants_in_call_overflow_other": "{{count}} participants in call: {{names}}, and {{overflowCount}} others",
163+
"participants_overflow_count": "+{{count}}",
164+
"participants_overflow_label_one": "{{count}} more participant",
165+
"participants_overflow_label_other": "{{count}} more participants",
159166
"waiting_for_invite": "Request sent! Waiting for permission to join…"
160167
},
161168
"log_in": "Log In",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
Copyright 2026 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
.participantRow {
9+
display: flex;
10+
flex-wrap: wrap;
11+
justify-content: center;
12+
align-items: flex-start;
13+
gap: var(--cpd-space-4x);
14+
padding: 0 var(--cpd-space-4x);
15+
max-width: 100%;
16+
}
17+
18+
.participantItem {
19+
display: flex;
20+
flex-direction: column;
21+
align-items: center;
22+
gap: var(--cpd-space-1x);
23+
width: 64px;
24+
}
25+
26+
.participantName {
27+
max-width: 100%;
28+
overflow: hidden;
29+
text-overflow: ellipsis;
30+
white-space: nowrap;
31+
text-align: center;
32+
font: var(--cpd-font-body-sm-regular);
33+
color: var(--cpd-color-text-secondary);
34+
}
35+
36+
.overflowItem {
37+
display: inline-flex;
38+
flex-direction: column;
39+
align-items: center;
40+
gap: var(--cpd-space-1x);
41+
width: 64px;
42+
cursor: default;
43+
}
44+
45+
.overflowCircle {
46+
width: 48px;
47+
height: 48px;
48+
border-radius: 50%;
49+
background-color: var(--cpd-color-bg-subtle-secondary);
50+
display: flex;
51+
justify-content: center;
52+
align-items: center;
53+
font: var(--cpd-font-body-md-semibold);
54+
color: var(--cpd-color-text-secondary);
55+
}
56+
57+
.overflowCount {
58+
font: var(--cpd-font-body-sm-regular);
59+
color: var(--cpd-color-text-secondary);
60+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
Copyright 2026 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { type JSX, type ReactNode } from "react";
9+
import { describe, expect, test, vi } from "vitest";
10+
import { render, screen } from "@testing-library/react";
11+
import { TooltipProvider } from "@vector-im/compound-web";
12+
13+
import { CallParticipantRow } from "./CallParticipantRow";
14+
import { type CallParticipant } from "./useCallParticipants";
15+
16+
// Mock the Avatar component to avoid MXC URL resolution side effects
17+
vi.mock("../Avatar", () => ({
18+
Avatar: ({
19+
id,
20+
name,
21+
size,
22+
}: {
23+
id: string;
24+
name: string;
25+
size: number;
26+
}): JSX.Element => (
27+
<div data-testid={`avatar-${id}`} data-name={name} data-size={size} />
28+
),
29+
Size: { SM: "sm", MD: "md", LG: "lg", XL: "xl" },
30+
}));
31+
32+
function renderWithProviders(children: ReactNode): ReturnType<typeof render> {
33+
return render(<TooltipProvider>{children}</TooltipProvider>);
34+
}
35+
36+
function makeParticipants(count: number): CallParticipant[] {
37+
return Array.from({ length: count }, (_, i) => ({
38+
userId: `@user${i}:example.org`,
39+
displayName: `User ${i}`,
40+
avatarUrl: null,
41+
}));
42+
}
43+
44+
describe("CallParticipantRow", () => {
45+
test("renders nothing when no participants", () => {
46+
const { container } = renderWithProviders(
47+
<CallParticipantRow participants={[]} />,
48+
);
49+
expect(container.textContent).toBe("");
50+
});
51+
52+
test("renders single participant", () => {
53+
const participants = makeParticipants(1);
54+
renderWithProviders(<CallParticipantRow participants={participants} />);
55+
56+
expect(screen.getByText("User 0")).toBeInTheDocument();
57+
expect(screen.getByTestId("avatar-@user0:example.org")).toBeInTheDocument();
58+
});
59+
60+
test("renders multiple participants up to limit", () => {
61+
const participants = makeParticipants(5);
62+
renderWithProviders(<CallParticipantRow participants={participants} />);
63+
64+
for (let i = 0; i < 5; i++) {
65+
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
66+
}
67+
// No overflow
68+
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument();
69+
});
70+
71+
test("renders overflow when exceeding default limit", () => {
72+
const participants = makeParticipants(10);
73+
renderWithProviders(<CallParticipantRow participants={participants} />);
74+
75+
// First 8 are visible
76+
for (let i = 0; i < 8; i++) {
77+
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
78+
}
79+
// Users 8 and 9 are in overflow (only visible in tooltip, not rendered in DOM)
80+
expect(screen.queryByText("User 8")).not.toBeInTheDocument();
81+
expect(screen.queryByText("User 9")).not.toBeInTheDocument();
82+
// Overflow count shown
83+
expect(screen.getByText("+2")).toBeInTheDocument();
84+
});
85+
86+
test("renders overflow with custom display limit", () => {
87+
const participants = makeParticipants(5);
88+
renderWithProviders(
89+
<CallParticipantRow participants={participants} displayLimit={3} />,
90+
);
91+
92+
// First 3 are visible
93+
for (let i = 0; i < 3; i++) {
94+
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
95+
}
96+
// 4th and 5th in overflow
97+
expect(screen.queryByText("User 3")).not.toBeInTheDocument();
98+
expect(screen.queryByText("User 4")).not.toBeInTheDocument();
99+
expect(screen.getByText("+2")).toBeInTheDocument();
100+
});
101+
102+
test("overflow element shows ellipsis", () => {
103+
const participants = makeParticipants(10);
104+
renderWithProviders(<CallParticipantRow participants={participants} />);
105+
106+
expect(screen.getByText("…")).toBeInTheDocument();
107+
});
108+
109+
test("has accessible list role", () => {
110+
const participants = makeParticipants(3);
111+
renderWithProviders(<CallParticipantRow participants={participants} />);
112+
113+
expect(screen.getByRole("list")).toBeInTheDocument();
114+
expect(screen.getAllByRole("listitem")).toHaveLength(3);
115+
});
116+
117+
test("overflow adds an additional listitem", () => {
118+
const participants = makeParticipants(10);
119+
renderWithProviders(<CallParticipantRow participants={participants} />);
120+
121+
// 8 visible + 1 overflow = 9 listitems
122+
expect(screen.getAllByRole("listitem")).toHaveLength(9);
123+
});
124+
125+
test("assigns correct screen reader label", () => {
126+
const participants: CallParticipant[] = [
127+
{ userId: "@alice:example.org", displayName: "Alice", avatarUrl: null },
128+
{ userId: "@bob:example.org", displayName: "Bob", avatarUrl: null },
129+
];
130+
renderWithProviders(<CallParticipantRow participants={participants} />);
131+
132+
const list = screen.getByRole("list");
133+
expect(list.getAttribute("aria-label")).toContain("Alice");
134+
expect(list.getAttribute("aria-label")).toContain("Bob");
135+
});
136+
});

src/room/CallParticipantRow.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
Copyright 2026 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { type FC, useMemo } from "react";
9+
import { useTranslation } from "react-i18next";
10+
import { Tooltip } from "@vector-im/compound-web";
11+
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
12+
13+
import { Avatar } from "../Avatar";
14+
import { type CallParticipant } from "./useCallParticipants";
15+
import styles from "./CallParticipantRow.module.css";
16+
17+
/** Maximum number of participant avatars to show before overflow. */
18+
const DEFAULT_DISPLAY_LIMIT = 8;
19+
20+
interface Props {
21+
participants: CallParticipant[];
22+
displayLimit?: number;
23+
}
24+
25+
/**
26+
* Renders a row of participant avatar circles with display names,
27+
* shown in the pre-join lobby when a call is active.
28+
* Overflowing participants are represented by a "..." item with a
29+
* +N count, with a tooltip showing the remaining names on hover.
30+
*/
31+
export const CallParticipantRow: FC<Props> = ({
32+
participants,
33+
displayLimit = DEFAULT_DISPLAY_LIMIT,
34+
}) => {
35+
const { t } = useTranslation();
36+
37+
const visibleParticipants = useMemo(
38+
() => participants.slice(0, displayLimit),
39+
[participants, displayLimit],
40+
);
41+
const overflowParticipants = useMemo(
42+
() => participants.slice(displayLimit),
43+
[participants, displayLimit],
44+
);
45+
const overflowCount = overflowParticipants.length;
46+
47+
if (participants.length === 0) return null;
48+
49+
const allNames = participants.map((p) => p.displayName);
50+
const screenReaderLabel =
51+
participants.length <= displayLimit
52+
? t("lobby.participants_in_call", {
53+
count: participants.length,
54+
names: allNames.join(", "),
55+
})
56+
: t("lobby.participants_in_call_overflow", {
57+
count: participants.length,
58+
names: visibleParticipants.map((p) => p.displayName).join(", "),
59+
overflowCount,
60+
});
61+
62+
return (
63+
<div
64+
className={styles.participantRow}
65+
role="list"
66+
aria-label={screenReaderLabel}
67+
>
68+
<VisuallyHidden>{screenReaderLabel}</VisuallyHidden>
69+
{visibleParticipants.map((participant) => (
70+
<div
71+
key={participant.userId}
72+
className={styles.participantItem}
73+
role="listitem"
74+
>
75+
<Avatar
76+
id={participant.userId}
77+
name={participant.displayName}
78+
src={participant.avatarUrl ?? undefined}
79+
size={48}
80+
/>
81+
<span className={styles.participantName}>
82+
{participant.displayName}
83+
</span>
84+
</div>
85+
))}
86+
{overflowCount > 0 && (
87+
<div role="listitem">
88+
<Tooltip
89+
label={overflowParticipants.map((p) => p.displayName).join(", ")}
90+
>
91+
<span
92+
className={styles.overflowItem}
93+
aria-label={t("lobby.participants_overflow_label", {
94+
count: overflowCount,
95+
})}
96+
>
97+
<span className={styles.overflowCircle}></span>
98+
<span className={styles.overflowCount}>
99+
{t("lobby.participants_overflow_count", {
100+
count: overflowCount,
101+
})}
102+
</span>
103+
</span>
104+
</Tooltip>
105+
</div>
106+
)}
107+
</div>
108+
);
109+
};

src/room/GroupCallView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import { useTypedEventEmitter } from "../useEvents";
7474
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
7575
import { useAppBarTitle } from "../AppBar.tsx";
7676
import { useBehavior } from "../useBehavior.ts";
77+
import { useCallParticipants } from "./useCallParticipants";
7778

7879
/**
7980
* If there already are this many participants in the call, we automatically mute
@@ -212,6 +213,8 @@ export const GroupCallView: FC<Props> = ({
212213
[memberships],
213214
);
214215

216+
const callParticipants = useCallParticipants(memberships, room);
217+
215218
const mediaDevices = useMediaDevices();
216219
const latestMuteStates = useLatest(muteStates);
217220

@@ -439,6 +442,7 @@ export const GroupCallView: FC<Props> = ({
439442
confineToRoom={confineToRoom}
440443
hideHeader={header === HeaderStyle.None}
441444
participantCount={participantCount}
445+
callParticipants={callParticipants}
442446
onShareClick={onShareClick}
443447
/>
444448
</>

src/room/LobbyView.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import {
5252
import { usePageTitle } from "../usePageTitle";
5353
import { getValue } from "../utils/observable";
5454
import { useBehavior } from "../useBehavior";
55+
import { CallParticipantRow } from "./CallParticipantRow";
56+
import { type CallParticipant } from "./useCallParticipants";
5557

5658
interface Props {
5759
client: MatrixClient;
@@ -62,6 +64,7 @@ interface Props {
6264
confineToRoom: boolean;
6365
hideHeader: boolean;
6466
participantCount: number | null;
67+
callParticipants: CallParticipant[];
6568
onShareClick: (() => void) | null;
6669
waitingForInvite?: boolean;
6770
}
@@ -75,6 +78,7 @@ export const LobbyView: FC<Props> = ({
7578
confineToRoom,
7679
hideHeader,
7780
participantCount,
81+
callParticipants,
7882
onShareClick,
7983
waitingForInvite,
8084
}) => {
@@ -205,6 +209,9 @@ export const LobbyView: FC<Props> = ({
205209
</Header>
206210
)}
207211
<div className={styles.content}>
212+
{callParticipants.length > 0 && (
213+
<CallParticipantRow participants={callParticipants} />
214+
)}
208215
<VideoPreview
209216
matrixInfo={matrixInfo}
210217
videoEnabled={videoEnabled}

src/room/RoomPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const RoomPage: FC = () => {
183183
confineToRoom={confineToRoom}
184184
hideHeader={header !== "standard"}
185185
participantCount={null}
186+
callParticipants={[]}
186187
muteStates={muteStates}
187188
onShareClick={null}
188189
/>

0 commit comments

Comments
 (0)