From 8413b0371dd051b436c403346c7bffb4035501ef Mon Sep 17 00:00:00 2001 From: alex-vg <22611767+alex-vg@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:26:04 +0100 Subject: [PATCH] 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 --- locales/en/app.json | 7 ++ src/room/CallParticipantRow.module.css | 60 +++++++++++ src/room/CallParticipantRow.test.tsx | 136 +++++++++++++++++++++++++ src/room/CallParticipantRow.tsx | 109 ++++++++++++++++++++ src/room/GroupCallView.tsx | 4 + src/room/LobbyView.tsx | 7 ++ src/room/RoomPage.tsx | 1 + src/room/useCallParticipants.test.ts | 123 ++++++++++++++++++++++ src/room/useCallParticipants.ts | 50 +++++++++ 9 files changed, 497 insertions(+) create mode 100644 src/room/CallParticipantRow.module.css create mode 100644 src/room/CallParticipantRow.test.tsx create mode 100644 src/room/CallParticipantRow.tsx create mode 100644 src/room/useCallParticipants.test.ts create mode 100644 src/room/useCallParticipants.ts diff --git a/locales/en/app.json b/locales/en/app.json index 9b1a567503..3455231f45 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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", diff --git a/src/room/CallParticipantRow.module.css b/src/room/CallParticipantRow.module.css new file mode 100644 index 0000000000..cccc8d8a62 --- /dev/null +++ b/src/room/CallParticipantRow.module.css @@ -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); +} diff --git a/src/room/CallParticipantRow.test.tsx b/src/room/CallParticipantRow.test.tsx new file mode 100644 index 0000000000..6d1ba069ee --- /dev/null +++ b/src/room/CallParticipantRow.test.tsx @@ -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 => ( +
+ ), + Size: { SM: "sm", MD: "md", LG: "lg", XL: "xl" }, +})); + +function renderWithProviders(children: ReactNode): ReturnType { + return render({children}); +} + +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( + , + ); + expect(container.textContent).toBe(""); + }); + + test("renders single participant", () => { + const participants = makeParticipants(1); + renderWithProviders(); + + 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(); + + 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(); + + // 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( + , + ); + + // 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(); + + expect(screen.getByText("…")).toBeInTheDocument(); + }); + + test("has accessible list role", () => { + const participants = makeParticipants(3); + renderWithProviders(); + + expect(screen.getByRole("list")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + + test("overflow adds an additional listitem", () => { + const participants = makeParticipants(10); + renderWithProviders(); + + // 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(); + + const list = screen.getByRole("list"); + expect(list.getAttribute("aria-label")).toContain("Alice"); + expect(list.getAttribute("aria-label")).toContain("Bob"); + }); +}); diff --git a/src/room/CallParticipantRow.tsx b/src/room/CallParticipantRow.tsx new file mode 100644 index 0000000000..5cf9a989c5 --- /dev/null +++ b/src/room/CallParticipantRow.tsx @@ -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 = ({ + 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 ( +
+ {screenReaderLabel} + {visibleParticipants.map((participant) => ( +
+ + + {participant.displayName} + +
+ ))} + {overflowCount > 0 && ( +
+ p.displayName).join(", ")} + > + + + + {t("lobby.participants_overflow_count", { + count: overflowCount, + })} + + + +
+ )} +
+ ); +}; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfd11ff32c..46f1f63ae4 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -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 @@ -212,6 +213,8 @@ export const GroupCallView: FC = ({ [memberships], ); + const callParticipants = useCallParticipants(memberships, room); + const mediaDevices = useMediaDevices(); const latestMuteStates = useLatest(muteStates); @@ -439,6 +442,7 @@ export const GroupCallView: FC = ({ confineToRoom={confineToRoom} hideHeader={header === HeaderStyle.None} participantCount={participantCount} + callParticipants={callParticipants} onShareClick={onShareClick} /> diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 10e098f1d0..ad5af92aa3 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -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; @@ -62,6 +64,7 @@ interface Props { confineToRoom: boolean; hideHeader: boolean; participantCount: number | null; + callParticipants: CallParticipant[]; onShareClick: (() => void) | null; waitingForInvite?: boolean; } @@ -75,6 +78,7 @@ export const LobbyView: FC = ({ confineToRoom, hideHeader, participantCount, + callParticipants, onShareClick, waitingForInvite, }) => { @@ -205,6 +209,9 @@ export const LobbyView: FC = ({ )}
+ {callParticipants.length > 0 && ( + + )} { confineToRoom={confineToRoom} hideHeader={header !== "standard"} participantCount={null} + callParticipants={[]} muteStates={muteStates} onShareClick={null} /> diff --git a/src/room/useCallParticipants.test.ts b/src/room/useCallParticipants.test.ts new file mode 100644 index 0000000000..dff0cb4298 --- /dev/null +++ b/src/room/useCallParticipants.test.ts @@ -0,0 +1,123 @@ +/* +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 { describe, expect, test } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { type Room } from "matrix-js-sdk"; + +import { useCallParticipants } from "./useCallParticipants"; +import { mockRtcMembership } from "../utils/test"; + +function mockRoom( + members: Record, +): Room { + return { + getMember: (userId: string) => { + const m = members[userId]; + if (!m) return null; + return { + userId, + rawDisplayName: m.displayName, + name: m.displayName, + getMxcAvatarUrl: () => m.avatarUrl ?? null, + }; + }, + } as unknown as Room; +} + +describe("useCallParticipants", () => { + test("returns empty array when no memberships", () => { + const room = mockRoom({}); + const { result } = renderHook(() => useCallParticipants([], room)); + expect(result.current).toEqual([]); + }); + + test("returns participants with resolved profile data", () => { + const room = mockRoom({ + "@alice:example.org": { + displayName: "Alice", + avatarUrl: "mxc://example.org/alice-avatar", + }, + "@bob:example.org": { + displayName: "Bob", + avatarUrl: "mxc://example.org/bob-avatar", + }, + }); + const memberships = [ + mockRtcMembership("@alice:example.org", "AAAA"), + mockRtcMembership("@bob:example.org", "BBBB"), + ]; + + const { result } = renderHook(() => + useCallParticipants(memberships, room), + ); + + expect(result.current).toEqual([ + { + userId: "@alice:example.org", + displayName: "Alice", + avatarUrl: "mxc://example.org/alice-avatar", + }, + { + userId: "@bob:example.org", + displayName: "Bob", + avatarUrl: "mxc://example.org/bob-avatar", + }, + ]); + }); + + test("deduplicates by userId when multiple devices", () => { + const room = mockRoom({ + "@alice:example.org": { displayName: "Alice" }, + }); + const memberships = [ + mockRtcMembership("@alice:example.org", "DEVICE1"), + mockRtcMembership("@alice:example.org", "DEVICE2"), + ]; + + const { result } = renderHook(() => + useCallParticipants(memberships, room), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].userId).toBe("@alice:example.org"); + }); + + test("falls back to userId when member not found in room", () => { + const room = mockRoom({}); + const memberships = [ + mockRtcMembership("@unknown:example.org", "XXXX"), + ]; + + const { result } = renderHook(() => + useCallParticipants(memberships, room), + ); + + expect(result.current).toEqual([ + { + userId: "@unknown:example.org", + displayName: "@unknown:example.org", + avatarUrl: null, + }, + ]); + }); + + test("handles null avatar URL", () => { + const room = mockRoom({ + "@alice:example.org": { displayName: "Alice" }, + }); + const memberships = [ + mockRtcMembership("@alice:example.org", "AAAA"), + ]; + + const { result } = renderHook(() => + useCallParticipants(memberships, room), + ); + + expect(result.current[0].avatarUrl).toBeNull(); + }); +}); diff --git a/src/room/useCallParticipants.ts b/src/room/useCallParticipants.ts new file mode 100644 index 0000000000..8d182b4497 --- /dev/null +++ b/src/room/useCallParticipants.ts @@ -0,0 +1,50 @@ +/* +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 { useMemo } from "react"; +import { type Room } from "matrix-js-sdk"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; + +export interface CallParticipant { + userId: string; + displayName: string; + avatarUrl: string | null; +} + +/** + * Derives a deduplicated list of active call participants with their + * profile information (display name + avatar MXC URL) from the room + * member data. + * + * @param memberships - The current call memberships from MatrixRTCSession + * @param room - The Matrix room, used to resolve member profiles + * @returns Array of unique participants with resolved profile data + */ +export function useCallParticipants( + memberships: CallMembership[], + room: Room, +): CallParticipant[] { + return useMemo(() => { + const seen = new Set(); + const participants: CallParticipant[] = []; + + for (const membership of memberships) { + const userId = membership.userId; + if (!userId || seen.has(userId)) continue; + seen.add(userId); + + const member = room.getMember(userId); + participants.push({ + userId, + displayName: member?.rawDisplayName ?? member?.name ?? userId, + avatarUrl: member?.getMxcAvatarUrl() ?? null, + }); + } + + return participants; + }, [memberships, room]); +}