diff --git a/locales/en/app.json b/locales/en/app.json index 0d785f68c..2116d4bdc 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -152,10 +152,13 @@ "layout_switch_label": "Layout", "lobby": { "ask_to_join": "Request to join call", + "cancel_request": "Cancel request", + "invite_only_body": "You need an invite to join this call.", "join_as_guest": "Join as guest", "join_button": "Join call", "leave_button": "Back to recents", - "waiting_for_invite": "Request sent! Waiting for permission to join…" + "request_sent": "Request to join sent", + "request_sent_body": "You will receive an invite to join the call if your request is accepted." }, "log_in": "Log In", "logging_in": "Logging in…", diff --git a/src/room/CallView.tsx b/src/room/CallView.tsx index 3c34dc845..c6b5c2806 100644 --- a/src/room/CallView.tsx +++ b/src/room/CallView.tsx @@ -507,7 +507,7 @@ const LoadedCallView: FC = ({ client={client} matrixInfo={matrixInfo} muteStates={muteStates} - onEnter={() => setJoined(true)} + joinState={{ kind: "can-join", join: () => setJoined(true) }} confineToRoom={confineToRoom} hideHeader={header !== HeaderStyle.Standard} participantCount={participantCount} diff --git a/src/room/KnockLobbyView.test.tsx b/src/room/KnockLobbyView.test.tsx index a513952b7..ff7d1e6e6 100644 --- a/src/room/KnockLobbyView.test.tsx +++ b/src/room/KnockLobbyView.test.tsx @@ -9,13 +9,16 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; -import { type MatrixClient, type RoomSummary } from "matrix-js-sdk"; +import { type MatrixClient } from "matrix-js-sdk"; import { KnockLobbyView } from "./KnockLobbyView"; +import { type LobbyJoinState } from "./LobbyJoinState"; import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { mockMediaDevices } from "../utils/test"; +import { E2eeType } from "../e2ee/e2eeType"; +import { type PreJoinRoomInfo } from "./preJoinRoomInfo"; vi.mock("@livekit/components-react", () => ({ usePreviewTracks: (): unknown[] => [], @@ -44,22 +47,24 @@ const client = { } as Partial as MatrixClient; // What peeking at a room we are not in tells us about it -const roomSummary = { - room_id: "!room:example.org", - name: "Knock Room", - "im.nheko.summary.encryption": "m.megolm.v1.aes-sha2", -} as Partial as RoomSummary; +const room: PreJoinRoomInfo = { + roomId: "!room:example.org", + roomName: "Knock Room", + roomAlias: null, + roomAvatar: null, + e2eeSystem: { kind: E2eeType.PER_PARTICIPANT }, +}; -function renderKnockLobby(knock: (() => void) | null): void { +function renderKnockLobby(joinState: LobbyJoinState): void { render( @@ -71,8 +76,8 @@ function renderKnockLobby(knock: (() => void) | null): void { describe("KnockLobbyView", () => { it("offers to ask to join, with what it knows of the room", async () => { - const knock = vi.fn(); - renderKnockLobby(knock); + const askToJoin = vi.fn(); + renderKnockLobby({ kind: "can-ask-to-join", askToJoin }); // The mute state arrives asynchronously, and the lobby with it const button = await screen.findByTestId("lobby_joinCall"); @@ -81,14 +86,14 @@ describe("KnockLobbyView", () => { expect(screen.getByText("Knock Room")).toBeInTheDocument(); await userEvent.setup().click(button); - expect(knock).toHaveBeenCalledOnce(); + expect(askToJoin).toHaveBeenCalledOnce(); }); it("waits once it has asked", async () => { - renderKnockLobby(null); + renderKnockLobby({ kind: "waiting-for-approval" }); const button = await screen.findByTestId("lobby_joinCall"); - expect(button).toHaveTextContent("Request sent!"); + expect(button).toHaveTextContent("Request to join sent"); // Compound's button keeps focusable, saying so through ARIA instead expect(button).toHaveAttribute("aria-disabled", "true"); }); diff --git a/src/room/KnockLobbyView.tsx b/src/room/KnockLobbyView.tsx index 39643b20c..0565fec9c 100644 --- a/src/room/KnockLobbyView.tsx +++ b/src/room/KnockLobbyView.tsx @@ -5,26 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, type ReactNode } from "react"; -import { type MatrixClient, type RoomSummary } from "matrix-js-sdk"; -import { useTranslation } from "react-i18next"; -import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { type FC, type ReactNode } from "react"; +import { type MatrixClient } from "matrix-js-sdk"; import { LobbyView } from "./LobbyView"; -import { E2eeType } from "../e2ee/e2eeType"; import { useMuteStates } from "../state/useMuteStates"; +import { type LobbyJoinState } from "./LobbyJoinState"; +import { type PreJoinRoomInfo } from "./preJoinRoomInfo"; interface Props { client: MatrixClient; - /** What we know about the room from peeking at it. */ - roomSummary: RoomSummary; + /** What we know about the room without being in it. */ + room: PreJoinRoomInfo; /** The user's own name and avatar, to show in their own tile. */ profile: { displayName: string; avatarUrl: string }; - /** - * Asks to be let in, if the room allows it. Absent once we have asked and - * are waiting for an answer. - */ - knock: (() => void) | null; + /** How the user may get into the call, and what they may do about it. */ + joinState: LobbyJoinState; confineToRoom: boolean; hideHeader: boolean; } @@ -41,27 +37,16 @@ interface Props { */ export const KnockLobbyView: FC = ({ client, - roomSummary, + room, profile, - knock, + joinState, confineToRoom, hideHeader, }): ReactNode => { - const { t } = useTranslation(); const muteStates = useMuteStates(); if (muteStates === null) return null; - const waitingForInvite = knock === null; - const enterLabel: string | JSX.Element = waitingForInvite ? ( - <> - {t("lobby.waiting_for_invite")} - - - ) : ( - t("lobby.ask_to_join") - ); - return ( = ({ userId: client.getUserId() ?? "", displayName: profile.displayName, avatarUrl: profile.avatarUrl, - roomAlias: null, - roomId: roomSummary.room_id, - roomName: roomSummary.name ?? "", - roomAvatar: roomSummary.avatar_url ?? null, - e2eeSystem: { - kind: roomSummary["im.nheko.summary.encryption"] - ? E2eeType.PER_PARTICIPANT - : E2eeType.NONE, - }, + ...room, }} - onEnter={(): void => knock?.()} - enterLabel={enterLabel} - waitingForInvite={waitingForInvite} + joinState={joinState} confineToRoom={confineToRoom} hideHeader={hideHeader} participantCount={null} diff --git a/src/room/LobbyJoinState.ts b/src/room/LobbyJoinState.ts new file mode 100644 index 000000000..802e8a6b9 --- /dev/null +++ b/src/room/LobbyJoinState.ts @@ -0,0 +1,35 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * How the local user may enter the call they are looking at, and therefore what + * the lobby offers them. Everything the lobby needs to know about membership. + */ +export type LobbyJoinState = + /** The user may enter the call right away. */ + | { kind: "can-join"; join: () => void } + /** + * The room only takes knocks. `error` is set when a previous request failed + * to send. + */ + | { + kind: "can-ask-to-join"; + askToJoin: (reason?: string) => void; + error?: "request_failed"; + } + /** The request is on its way to the server. */ + | { kind: "sending-request" } + /** + * The request is with the room's moderators. `cancelRequest` is absent while + * a withdrawal is on its way, and where withdrawing is not supported. + */ + | { kind: "waiting-for-approval"; cancelRequest?: () => void } + /** The request was declined. There is no way to ask again. */ + | { kind: "denied" } + | { kind: "banned"; reason?: string } + /** The room takes neither joins nor knocks from this user. */ + | { kind: "not-allowed" }; diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css index b112cdf20..266a7552a 100644 --- a/src/room/LobbyView.module.css +++ b/src/room/LobbyView.module.css @@ -39,3 +39,19 @@ Please see LICENSE in the repository root for full details. gap: var(--cpd-space-10x); } } + +.joinMessage { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--cpd-space-2x); + max-inline-size: 40ch; + text-align: center; + color: var(--cpd-color-text-secondary); +} + +.waiting { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); +} diff --git a/src/room/LobbyView.test.tsx b/src/room/LobbyView.test.tsx index 7f03f2d29..695239936 100644 --- a/src/room/LobbyView.test.tsx +++ b/src/room/LobbyView.test.tsx @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it, vi } from "vitest"; import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; @@ -17,6 +18,7 @@ import { } from "@vector-im/compound-design-tokens/assets/web/icons"; import { LobbyView } from "./LobbyView"; +import { type LobbyJoinState } from "./LobbyJoinState"; import { E2eeType } from "../e2ee/e2eeType"; import { mockMediaDevices, mockMuteStates } from "../utils/test"; import { MediaDevicesContext } from "../MediaDevicesContext"; @@ -87,7 +89,7 @@ function renderLobbyView( client={mockClient} matrixInfo={matrixInfo} muteStates={muteStates} - onEnter={() => {}} + joinState={{ kind: "can-join", join: () => {} }} confineToRoom={false} hideHeader={hideHeader} participantCount={3} @@ -125,18 +127,9 @@ describe("LobbyView", () => { } }); - it("renders with waiting for invite state", () => { - const { getByTestId } = renderLobbyView({ - waitingForInvite: true, - }); - expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait); - }); - it("renders with AppBar android", async () => { const { container, getByRole } = renderLobbyView( - { - waitingForInvite: true, - }, + { joinState: { kind: "waiting-for-approval" } }, true, "android", ); @@ -157,9 +150,7 @@ describe("LobbyView", () => { it("renders with AppBar ios", async () => { const { container, getByRole } = renderLobbyView( - { - waitingForInvite: true, - }, + { joinState: { kind: "waiting-for-approval" } }, true, "ios", ); @@ -177,4 +168,159 @@ describe("LobbyView", () => { expect(container).toMatchSnapshot(); expect(await axe(container)).toHaveNoViolations(); }); + describe("join states", () => { + const cases: { + joinState: LobbyJoinState; + button: string | null; + disabled: boolean; + message: string | null; + }[] = [ + { + joinState: { kind: "can-join", join: () => {} }, + button: "Join call", + disabled: false, + message: null, + }, + { + joinState: { kind: "can-ask-to-join", askToJoin: () => {} }, + button: "Request to join call", + disabled: false, + message: null, + }, + { + joinState: { + kind: "can-ask-to-join", + askToJoin: () => {}, + error: "request_failed", + }, + button: "Request to join call", + disabled: false, + message: "Something went wrong", + }, + { + joinState: { kind: "sending-request" }, + button: "Request to join call", + disabled: true, + message: null, + }, + { + joinState: { kind: "waiting-for-approval" }, + button: "Request to join sent", + disabled: true, + message: "You will receive an invite", + }, + { + joinState: { kind: "denied" }, + button: null, + disabled: false, + message: "Your request to join was declined.", + }, + { + joinState: { kind: "banned" }, + button: null, + disabled: false, + message: "You have been banned from the room.", + }, + { + joinState: { kind: "not-allowed" }, + button: null, + disabled: false, + message: "You need an invite to join this call.", + }, + ]; + + it.each(cases)( + "renders $joinState.kind", + async ({ joinState, button, disabled, message }) => { + const { container, queryByTestId } = renderLobbyView({ joinState }); + const joinButton = queryByTestId("lobby_joinCall"); + if (button === null) { + expect(joinButton).toBeNull(); + } else { + expect(joinButton).toHaveTextContent(button); + // Compound buttons are soft-disabled: they keep focus and expose + // `aria-disabled` rather than the DOM `disabled` attribute. + if (disabled) { + expect(joinButton).toHaveAttribute("aria-disabled", "true"); + } else { + expect(joinButton).not.toHaveAttribute("aria-disabled", "true"); + } + } + const messageBlock = queryByTestId("lobby_joinMessage"); + if (message === null) { + expect(messageBlock).toBeNull(); + } else { + expect(messageBlock).toHaveTextContent(message); + } + expect(await axe(container)).toHaveNoViolations(); + }, + ); + + it("only marks the waiting button as waiting", () => { + const waiting = renderLobbyView({ + joinState: { kind: "waiting-for-approval" }, + }); + expect(waiting.getByTestId("lobby_joinCall")).toHaveClass( + lobbyStyles.wait, + ); + waiting.unmount(); + const canJoin = renderLobbyView(); + expect(canJoin.getByTestId("lobby_joinCall")).not.toHaveClass( + lobbyStyles.wait, + ); + }); + + it("joins when the join button is pressed", async () => { + const join = vi.fn(); + const { getByTestId } = renderLobbyView({ + joinState: { kind: "can-join", join }, + }); + await userEvent.click(getByTestId("lobby_joinCall")); + expect(join).toHaveBeenCalled(); + }); + + it("asks to join when the request button is pressed", async () => { + const askToJoin = vi.fn(); + const { getByTestId } = renderLobbyView({ + joinState: { kind: "can-ask-to-join", askToJoin }, + }); + await userEvent.click(getByTestId("lobby_joinCall")); + expect(askToJoin).toHaveBeenCalled(); + }); + + it("does nothing while the request is being sent", async () => { + const { getByTestId } = renderLobbyView({ + joinState: { kind: "sending-request" }, + }); + const button = getByTestId("lobby_joinCall"); + expect(button).toHaveAttribute("aria-busy", "true"); + await userEvent.click(button); + expect(button).toHaveAttribute("aria-disabled", "true"); + }); + + it("withdraws the request when cancel is pressed", async () => { + const cancelRequest = vi.fn(); + const { getByTestId } = renderLobbyView({ + joinState: { kind: "waiting-for-approval", cancelRequest }, + }); + await userEvent.click(getByTestId("lobby_cancelRequest")); + expect(cancelRequest).toHaveBeenCalled(); + }); + + it("offers no cancel link when withdrawing is unsupported", () => { + const { queryByTestId } = renderLobbyView({ + joinState: { kind: "waiting-for-approval" }, + }); + expect(queryByTestId("lobby_cancelRequest")).toBeNull(); + }); + + it("shows the ban reason", () => { + const { getByTestId } = renderLobbyView({ + joinState: { kind: "banned", reason: "Not today" }, + }); + expect(getByTestId("lobby_joinMessage")).toHaveTextContent( + "Reason: Not today", + ); + }); + }); }); diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 9e6e0ed99..0c71911ca 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -7,16 +7,20 @@ Please see LICENSE in the repository root for full details. import { type FC, + type ReactNode, useCallback, useMemo, useState, - type JSX, useEffect, } from "react"; import { useTranslation } from "react-i18next"; import { type MatrixClient } from "matrix-js-sdk"; -import { Button } from "@vector-im/compound-web"; +import { Button, Heading, InlineSpinner, Text } from "@vector-im/compound-web"; import classNames from "classnames"; +import { + CheckIcon, + SpinnerIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { logger } from "matrix-js-sdk/lib/logger"; import { usePreviewTracks } from "@livekit/components-react"; import { @@ -28,6 +32,7 @@ import { useObservableEagerState } from "observable-hooks"; import inCallStyles from "./InCallView.module.css"; import styles from "./LobbyView.module.css"; +import buttonStyles from "../button/Button.module.css"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MuteStates } from "../state/MuteStates"; @@ -51,31 +56,28 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { createLobbyFooterViewModel } from "../components/CallFooterViewModel"; import { type ViewModel } from "../state/ViewModel"; import { useAppBarPrimaryButtonIconKind } from "../AppBar"; +import { type LobbyJoinState } from "./LobbyJoinState"; interface Props { client: MatrixClient; matrixInfo: MatrixInfo; muteStates: MuteStates; - onEnter: () => void; - enterLabel?: JSX.Element | string; + joinState: LobbyJoinState; confineToRoom: boolean; hideHeader: boolean; participantCount: number | null; onShareClick: (() => void) | null; - waitingForInvite?: boolean; } export const LobbyView: FC = ({ client, matrixInfo, muteStates, - onEnter, - enterLabel, + joinState, confineToRoom, hideHeader, participantCount, onShareClick, - waitingForInvite, }) => { useEffect(() => { logger.info("[Lifecycle] LobbyView Component mounted"); @@ -209,6 +211,118 @@ export const LobbyView: FC = ({ }; }, [devices, hangup, hideHeader, muteStates, openSettings]); + const joinButton = ((): ReactNode => { + switch (joinState.kind) { + case "can-join": + return ( + + ); + case "can-ask-to-join": + return ( + + ); + case "sending-request": + return ( + + ); + case "waiting-for-approval": + return ( + + ); + case "denied": + case "banned": + case "not-allowed": + return null; + } + })(); + + const joinMessage = ((): ReactNode => { + switch (joinState.kind) { + case "can-ask-to-join": + return joinState.error === undefined ? null : ( + {t("error.generic")} + ); + case "waiting-for-approval": + return ( + <> +
+ + {t("lobby.request_sent_body")} +
+ {joinState.cancelRequest !== undefined && ( + + )} + + ); + case "denied": + return ( + <> + + {t("group_call_loader.knock_reject_heading")} + + {t("group_call_loader.knock_reject_body")} + + ); + case "banned": + return ( + <> + + {t("group_call_loader.banned_heading")} + + {t("group_call_loader.banned_body")} + {joinState.reason !== undefined && ( + + {t("group_call_loader.reason", { reason: joinState.reason })} + + )} + + ); + case "not-allowed": + return {t("lobby.invite_only_body")}; + case "can-join": + case "sending-request": + return null; + } + })(); + // TODO: Unify this component with InCallView, so we can get slick joining // animations and don't have to feel bad about reusing its CSS return ( @@ -236,20 +350,13 @@ export const LobbyView: FC = ({ videoEnabled={videoEnabled} videoTrack={videoTrack} > - + {joinButton} + {joinMessage !== null && ( +
+ {joinMessage} +
+ )} {!recentsButtonInFooter && recentsButton} {footerVm !== null && ( diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 71bceea76..b648a9630 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -56,9 +56,8 @@ export const RoomPage: FC = (): ReactNode => { ); usePageTitle( roomName ?? - (groupCallState.kind === "canKnock" || - groupCallState.kind === "waitForInvite" - ? groupCallState.roomSummary.name + (groupCallState.kind === "lobby" + ? groupCallState.room.roomName : undefined), ); @@ -90,10 +89,10 @@ export const RoomPage: FC = (): ReactNode => { if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true); }, [optInAnalytics, setOptInAnalytics]); - const wasInWaitForInviteState = useRef(false); + const wasWaitingForApproval = useRef(false); useEffect(() => { - if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) { + if (groupCallState.kind === "loaded" && wasWaitingForApproval.current) { logger.log("Play join sound 'Not yet implemented'"); } }, [groupCallState.kind]); @@ -108,25 +107,22 @@ export const RoomPage: FC = (): ReactNode => { isPasswordlessUser={passwordlessUser} confineToRoom={confineToRoom} preload={preload} - skipLobby={skipLobby || wasInWaitForInviteState.current} + skipLobby={skipLobby || wasWaitingForApproval.current} /> ); - case "waitForInvite": - case "canKnock": { - wasInWaitForInviteState.current = - wasInWaitForInviteState.current || - groupCallState.kind === "waitForInvite"; + case "lobby": { + wasWaitingForApproval.current = + wasWaitingForApproval.current || + groupCallState.joinState.kind === "waiting-for-approval"; return ( @@ -139,7 +135,7 @@ export const RoomPage: FC = (): ReactNode => { ); case "failed": - wasInWaitForInviteState.current = false; + wasWaitingForApproval.current = false; if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") { return ( diff --git a/src/room/__snapshots__/LobbyView.test.tsx.snap b/src/room/__snapshots__/LobbyView.test.tsx.snap index d66d4a8fe..60ddeac62 100644 --- a/src/room/__snapshots__/LobbyView.test.tsx.snap +++ b/src/room/__snapshots__/LobbyView.test.tsx.snap @@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = ` >
+
+
+ + + +

+ You will receive an invite to join the call if your request is accepted. +

+
+
renders with AppBar android 1`] = ` class="_settingsLogoContainer_20b7b4" > +
+
+ + + +

+ You will receive an invite to join the call if your request is accepted. +

+
+
renders with AppBar ios 1`] = ` class="_settingsLogoContainer_20b7b4" >