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
7 changes: 7 additions & 0 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@
"join_as_guest": "Join as guest",
"join_button": "Join call",
"leave_button": "Back to recents",
"participants_in_call_one": "{{count}} participant in call: {{names}}",
"participants_in_call_other": "{{count}} participants in call: {{names}}",
"participants_in_call_overflow_one": "{{count}} participant in call: {{names}}, and {{overflowCount}} other",
"participants_in_call_overflow_other": "{{count}} participants in call: {{names}}, and {{overflowCount}} others",
"participants_overflow_count": "+{{count}}",
"participants_overflow_label_one": "{{count}} more participant",
"participants_overflow_label_other": "{{count}} more participants",
"waiting_for_invite": "Request sent! Waiting for permission to join…"
},
"log_in": "Log In",
Expand Down
60 changes: 60 additions & 0 deletions src/room/CallParticipantRow.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

.participantRow {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: var(--cpd-space-4x);
padding: 0 var(--cpd-space-4x);
max-width: 100%;
}

.participantItem {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-1x);
width: 64px;
}

.participantName {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}

.overflowItem {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-1x);
width: 64px;
cursor: default;
}

.overflowCircle {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--cpd-color-bg-subtle-secondary);
display: flex;
justify-content: center;
align-items: center;
font: var(--cpd-font-body-md-semibold);
color: var(--cpd-color-text-secondary);
}

.overflowCount {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}
136 changes: 136 additions & 0 deletions src/room/CallParticipantRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { type JSX, type ReactNode } from "react";
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";

import { CallParticipantRow } from "./CallParticipantRow";
import { type CallParticipant } from "./useCallParticipants";

// Mock the Avatar component to avoid MXC URL resolution side effects
vi.mock("../Avatar", () => ({
Avatar: ({
id,
name,
size,
}: {
id: string;
name: string;
size: number;
}): JSX.Element => (
<div data-testid={`avatar-${id}`} data-name={name} data-size={size} />
),
Size: { SM: "sm", MD: "md", LG: "lg", XL: "xl" },
}));

function renderWithProviders(children: ReactNode): ReturnType<typeof render> {
return render(<TooltipProvider>{children}</TooltipProvider>);
}

function makeParticipants(count: number): CallParticipant[] {
return Array.from({ length: count }, (_, i) => ({
userId: `@user${i}:example.org`,
displayName: `User ${i}`,
avatarUrl: null,
}));
}

describe("CallParticipantRow", () => {
test("renders nothing when no participants", () => {
const { container } = renderWithProviders(
<CallParticipantRow participants={[]} />,
);
expect(container.textContent).toBe("");
});

test("renders single participant", () => {
const participants = makeParticipants(1);
renderWithProviders(<CallParticipantRow participants={participants} />);

expect(screen.getByText("User 0")).toBeInTheDocument();
expect(screen.getByTestId("avatar-@user0:example.org")).toBeInTheDocument();
});

test("renders multiple participants up to limit", () => {
const participants = makeParticipants(5);
renderWithProviders(<CallParticipantRow participants={participants} />);

for (let i = 0; i < 5; i++) {
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
}
// No overflow
expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument();
});

test("renders overflow when exceeding default limit", () => {
const participants = makeParticipants(10);
renderWithProviders(<CallParticipantRow participants={participants} />);

// First 8 are visible
for (let i = 0; i < 8; i++) {
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
}
// Users 8 and 9 are in overflow (only visible in tooltip, not rendered in DOM)
expect(screen.queryByText("User 8")).not.toBeInTheDocument();
expect(screen.queryByText("User 9")).not.toBeInTheDocument();
// Overflow count shown
expect(screen.getByText("+2")).toBeInTheDocument();
});

test("renders overflow with custom display limit", () => {
const participants = makeParticipants(5);
renderWithProviders(
<CallParticipantRow participants={participants} displayLimit={3} />,
);

// First 3 are visible
for (let i = 0; i < 3; i++) {
expect(screen.getByText(`User ${i}`)).toBeInTheDocument();
}
// 4th and 5th in overflow
expect(screen.queryByText("User 3")).not.toBeInTheDocument();
expect(screen.queryByText("User 4")).not.toBeInTheDocument();
expect(screen.getByText("+2")).toBeInTheDocument();
});

test("overflow element shows ellipsis", () => {
const participants = makeParticipants(10);
renderWithProviders(<CallParticipantRow participants={participants} />);

expect(screen.getByText("…")).toBeInTheDocument();
});

test("has accessible list role", () => {
const participants = makeParticipants(3);
renderWithProviders(<CallParticipantRow participants={participants} />);

expect(screen.getByRole("list")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});

test("overflow adds an additional listitem", () => {
const participants = makeParticipants(10);
renderWithProviders(<CallParticipantRow participants={participants} />);

// 8 visible + 1 overflow = 9 listitems
expect(screen.getAllByRole("listitem")).toHaveLength(9);
});

test("assigns correct screen reader label", () => {
const participants: CallParticipant[] = [
{ userId: "@alice:example.org", displayName: "Alice", avatarUrl: null },
{ userId: "@bob:example.org", displayName: "Bob", avatarUrl: null },
];
renderWithProviders(<CallParticipantRow participants={participants} />);

const list = screen.getByRole("list");
expect(list.getAttribute("aria-label")).toContain("Alice");
expect(list.getAttribute("aria-label")).toContain("Bob");
});
});
109 changes: 109 additions & 0 deletions src/room/CallParticipantRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { type FC, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@vector-im/compound-web";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";

import { Avatar } from "../Avatar";
import { type CallParticipant } from "./useCallParticipants";
import styles from "./CallParticipantRow.module.css";

/** Maximum number of participant avatars to show before overflow. */
const DEFAULT_DISPLAY_LIMIT = 8;

interface Props {
participants: CallParticipant[];
displayLimit?: number;
}

/**
* Renders a row of participant avatar circles with display names,
* shown in the pre-join lobby when a call is active.
* Overflowing participants are represented by a "..." item with a
* +N count, with a tooltip showing the remaining names on hover.
*/
export const CallParticipantRow: FC<Props> = ({
participants,
displayLimit = DEFAULT_DISPLAY_LIMIT,
}) => {
const { t } = useTranslation();

const visibleParticipants = useMemo(
() => participants.slice(0, displayLimit),
[participants, displayLimit],
);
const overflowParticipants = useMemo(
() => participants.slice(displayLimit),
[participants, displayLimit],
);
const overflowCount = overflowParticipants.length;

if (participants.length === 0) return null;

const allNames = participants.map((p) => p.displayName);
const screenReaderLabel =
participants.length <= displayLimit
? t("lobby.participants_in_call", {
count: participants.length,
names: allNames.join(", "),
})
: t("lobby.participants_in_call_overflow", {
count: participants.length,
names: visibleParticipants.map((p) => p.displayName).join(", "),
overflowCount,
});

return (
<div
className={styles.participantRow}
role="list"
aria-label={screenReaderLabel}
>
<VisuallyHidden>{screenReaderLabel}</VisuallyHidden>
{visibleParticipants.map((participant) => (
<div
key={participant.userId}
className={styles.participantItem}
role="listitem"
>
<Avatar
id={participant.userId}
name={participant.displayName}
src={participant.avatarUrl ?? undefined}
size={48}
/>
<span className={styles.participantName}>
{participant.displayName}
</span>
</div>
))}
{overflowCount > 0 && (
<div role="listitem">
<Tooltip
label={overflowParticipants.map((p) => p.displayName).join(", ")}
>
<span
className={styles.overflowItem}
aria-label={t("lobby.participants_overflow_label", {
count: overflowCount,
})}
>
<span className={styles.overflowCircle}>…</span>
<span className={styles.overflowCount}>
{t("lobby.participants_overflow_count", {
count: overflowCount,
})}
</span>
</span>
</Tooltip>
</div>
)}
</div>
);
};
4 changes: 4 additions & 0 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { useCallParticipants } from "./useCallParticipants";

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

const callParticipants = useCallParticipants(memberships, room);

const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);

Expand Down Expand Up @@ -439,6 +442,7 @@ export const GroupCallView: FC<Props> = ({
confineToRoom={confineToRoom}
hideHeader={header === HeaderStyle.None}
participantCount={participantCount}
callParticipants={callParticipants}
onShareClick={onShareClick}
/>
</>
Expand Down
7 changes: 7 additions & 0 deletions src/room/LobbyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallParticipantRow } from "./CallParticipantRow";
import { type CallParticipant } from "./useCallParticipants";

interface Props {
client: MatrixClient;
Expand All @@ -62,6 +64,7 @@ interface Props {
confineToRoom: boolean;
hideHeader: boolean;
participantCount: number | null;
callParticipants: CallParticipant[];
onShareClick: (() => void) | null;
waitingForInvite?: boolean;
}
Expand All @@ -75,6 +78,7 @@ export const LobbyView: FC<Props> = ({
confineToRoom,
hideHeader,
participantCount,
callParticipants,
onShareClick,
waitingForInvite,
}) => {
Expand Down Expand Up @@ -205,6 +209,9 @@ export const LobbyView: FC<Props> = ({
</Header>
)}
<div className={styles.content}>
{callParticipants.length > 0 && (
<CallParticipantRow participants={callParticipants} />
)}
<VideoPreview
matrixInfo={matrixInfo}
videoEnabled={videoEnabled}
Expand Down
1 change: 1 addition & 0 deletions src/room/RoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const RoomPage: FC = () => {
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
participantCount={null}
callParticipants={[]}
muteStates={muteStates}
onShareClick={null}
/>
Expand Down
Loading
Loading