Merge branch 'livekit' into toger5/track-processor-blur

This commit is contained in:
Timo
2025-04-05 00:00:00 +02:00
375 changed files with 23054 additions and 10991 deletions

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -15,7 +15,7 @@ import {
import { useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Modal } from "../Modal";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";

View File

@@ -1,7 +1,7 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,21 +1,16 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
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 FormEventHandler,
type ReactNode,
useCallback,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";
@@ -24,7 +19,6 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
import { RageshakeButton } from "../settings/RageshakeButton";
import { Link } from "../button/Link";
import { LinkButton } from "../button";
@@ -33,8 +27,6 @@ interface Props {
isPasswordlessUser: boolean;
confineToRoom: boolean;
endedCallId: string;
leaveError?: Error;
reconnect: () => void;
}
export const CallEndedView: FC<Props> = ({
@@ -42,8 +34,6 @@ export const CallEndedView: FC<Props> = ({
isPasswordlessUser,
confineToRoom,
endedCallId,
leaveError,
reconnect,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -76,7 +66,9 @@ export const CallEndedView: FC<Props> = ({
setSurveySubmitted(true);
} else if (!confineToRoom) {
// if the user already has an account immediately go back to the home screen
navigate("/");
navigate("/")?.catch((error) => {
logger.error("Failed to navigate to /", error);
});
}
}, 1000);
}, 1000);
@@ -139,69 +131,32 @@ export const CallEndedView: FC<Props> = ({
</div>
);
const renderBody = (): ReactNode => {
if (leaveError) {
return (
<>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
<Trans i18nKey="call_ended_view.body">
You were disconnected from the call
</Trans>
</Heading>
<div className={styles.disconnectedButtons}>
<Button onClick={reconnect}>
{t("call_ended_view.reconnect_button")}
</Button>
<div className={styles.rageshakeButton}>
<RageshakeButton description="***Call disconnected***" />
</div>
</div>
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("return_home_button")} </Link>
</Text>
)}
</>
);
} else {
return (
<>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
{surveySubmitted
? t("call_ended_view.headline", {
displayName,
})
: t("call_ended_view.headline", {
displayName,
}) +
"\n" +
t("call_ended_view.survey_prompt")}
</Heading>
{(!surveySubmitted || confineToRoom) &&
PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
</Text>
)}
</>
);
}
};
return (
<>
<Header>
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>{renderBody()}</div>
<div className={styles.container}>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>
{surveySubmitted
? t("call_ended_view.headline", { displayName })
: t("call_ended_view.headline", { displayName }) +
"\n" +
t("call_ended_view.survey_prompt")}
</Heading>
{(!surveySubmitted || confineToRoom) &&
PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
{!confineToRoom && (
<Text className={styles.footer}>
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
</Text>
)}
</div>
</>
);
};

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -16,7 +16,7 @@ import {
afterEach,
} from "vitest";
import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { mockRtcMembership } from "../utils/test";
import {

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -0,0 +1,253 @@
/*
Copyright 2025 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, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useState,
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import {
type CallErrorRecoveryAction,
GroupCallErrorBoundary,
} from "./GroupCallErrorBoundary.tsx";
import {
ConnectionLostError,
E2EENotSupportedError,
type ElementCallError,
InsufficientCapacityError,
MatrixRTCFocusMissingError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
error: new MatrixRTCFocusMissingError("example.com"),
expectedTitle: "Call is not supported",
},
{
error: new ConnectionLostError(),
expectedTitle: "Connection lost",
expectedDescription: "You were disconnected from the call.",
},
{
error: new E2EENotSupportedError(),
expectedTitle: "Incompatible browser",
expectedDescription:
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
},
{
error: new InsufficientCapacityError(),
expectedTitle: "Insufficient capacity",
expectedDescription:
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
},
])(
"should report correct error for $expectedTitle",
async ({ error, expectedTitle, expectedDescription }) => {
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText(expectedTitle);
if (expectedDescription) {
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
}
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
},
);
test("should render the error page with link back to home", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
).toBeInTheDocument();
await screen.findByRole("button", { name: "Return to home screen" });
expect(onErrorMock).toHaveBeenCalledOnce();
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(asFragment()).toMatchSnapshot();
});
test("ConnectionLostError: Action handling should reset error state", async () => {
const user = userEvent.setup();
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
if (fail) {
throw new ConnectionLostError();
}
return <div>HELLO</div>;
};
const reconnectCallbackSpy = vi.fn();
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
},
[setFailState],
);
return (
<BrowserRouter>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
);
};
const { asFragment } = render(<WrapComponent />);
// Should fail first
await screen.findByText("Connection lost");
await screen.findByRole("button", { name: "Reconnect" });
await screen.findByRole("button", { name: "Return to home screen" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Reconnect" }));
// reconnect should have reset the error, thus rendering should be ok
await screen.findByText("HELLO");
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
});
describe("Rageshake button", () => {
function setupTest(testError: ElementCallError): void {
mockConfig({
rageshake: {
submit_url: "https://rageshake.example.com.localhost",
},
});
const TestComponent = (): ReactElement => {
throw testError;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
}
test("should show send rageshake button for unknown errors", () => {
setupTest(new UnknownCallError(new Error("FOO")));
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).toBeInTheDocument();
});
test("should not show send rageshake button for call errors", () => {
setupTest(new E2EENotSupportedError());
expect(
screen.queryByRole("button", { name: "Send debug logs" }),
).not.toBeInTheDocument();
});
});
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const user = userEvent.setup();
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Call is not supported");
await screen.findByRole("button", { name: "Close" });
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});

View File

@@ -0,0 +1,146 @@
/*
Copyright 2025 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 { ErrorBoundary, type FallbackRender } from "@sentry/react";
import {
type ComponentType,
type FC,
type ReactElement,
type ReactNode,
type SVGAttributes,
useCallback,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import {
ErrorIcon,
HostIcon,
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ConnectionLostError,
ElementCallError,
ErrorCategory,
ErrorCode,
UnknownCallError,
} from "../utils/errors.ts";
import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
interface ErrorPageProps {
error: ElementCallError;
recoveryActionHandler: RecoveryActionHandler;
resetError: () => void;
widget: WidgetHelpers | null;
}
const ErrorPage: FC<ErrorPageProps> = ({
error,
recoveryActionHandler,
widget,
}: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
let icon: ComponentType<SVGAttributes<SVGElement>>;
switch (error.category) {
case ErrorCategory.CONFIGURATION_ISSUE:
icon = HostIcon;
break;
case ErrorCategory.NETWORK_CONNECTIVITY:
icon = OfflineIcon;
break;
case ErrorCategory.CLIENT_CONFIGURATION:
icon = WebBrowserIcon;
break;
default:
icon = ErrorIcon;
}
const actions: { label: string; onClick: () => void }[] = [];
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"),
});
}
return (
<FullScreenView>
<ErrorView
Icon={icon}
title={error.localisedTitle}
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
widget={widget}
>
<p>
{error.localisedMessage ?? (
<Trans
i18nKey="error.unexpected_ec_error"
components={[<b />, <code />]}
values={{ errorCode: error.code }}
/>
)}
</p>
{actions &&
actions.map((action, index) => (
<button onClick={action.onClick} key={`action${index}`}>
{action.label}
</button>
))}
</ErrorView>
</FullScreenView>
);
};
interface BoundaryProps {
children: ReactNode | (() => ReactNode);
recoveryActionHandler: RecoveryActionHandler;
onError?: (error: unknown) => void;
widget: WidgetHelpers | null;
}
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,
children,
widget,
}: BoundaryProps): ReactElement => {
const fallbackRenderer: FallbackRender = useCallback(
({ error, resetError }): ReactElement => {
const callError =
error instanceof ElementCallError
? error
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
<ErrorPage
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
resetError();
recoveryActionHandler(action);
}}
/>
);
},
[recoveryActionHandler, widget],
);
return (
<ErrorBoundary
fallback={fallbackRenderer}
onError={(error) => onError?.(error)}
children={children}
/>
);
};

View File

@@ -1,45 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
import { render } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import {
beforeEach,
expect,
type MockedFunction,
onTestFinished,
test,
vi,
} from "vitest";
import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { of } from "rxjs";
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import { ActiveCall } from "./InCallView";
import {
flushPromises,
mockMatrixRoom,
mockMatrixRoomMember,
mockRtcMembership,
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
vitest.mock("../soundUtils");
vitest.mock("../useAudioContext");
vitest.mock("./InCallView");
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
vi.mock("./InCallView");
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
vitest.mock("../rtcSessionHelpers", async (importOriginal) => {
const enterRTCSession = vi.hoisted(() => vi.fn(async () => Promise.resolve()));
const leaveRTCSession = vi.hoisted(() =>
vi.fn(
async (
rtcSession: unknown,
cause: unknown,
promiseBeforeHangup = Promise.resolve(),
) => await promiseBeforeHangup,
),
);
vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
vitest.spyOn(orig, "leaveRTCSession");
return orig;
return { ...orig, enterRTCSession, leaveRTCSession };
});
let playSound: MockedFunction<
@@ -51,13 +73,13 @@ const carol = mockMatrixRoomMember(localRtcMember);
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
const soundPromise = Promise.resolve(true);
beforeEach(() => {
vi.clearAllMocks();
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
playSound = vitest.fn().mockReturnValue(soundPromise);
playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
});
@@ -73,7 +95,10 @@ beforeEach(() => {
);
});
function createGroupCallView(widget: WidgetHelpers | null): {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
): {
rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"];
} {
@@ -86,7 +111,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vitest.mocked({
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
@@ -104,23 +129,27 @@ function createGroupCallView(widget: WidgetHelpers | null): {
localRtcMember,
[],
).withMemberships(of([]));
rtcSession.joined = joined;
const muteState = {
audio: { enabled: false },
video: { enabled: false },
} as MuteStates;
const { getByText } = render(
<BrowserRouter>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
widget={widget}
/>
<TooltipProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</TooltipProvider>
</BrowserRouter>,
);
return {
@@ -129,17 +158,24 @@ function createGroupCallView(widget: WidgetHelpers | null): {
};
}
test("will play a leave sound asynchronously in SPA mode", async () => {
test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
expect(playSound).toHaveBeenCalledWith("left");
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, undefined);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
expect(leaveRTCSession).toHaveBeenCalledWith(
rtcSession,
"user",
expect.any(Promise),
);
expect(leaveRTCSession).toHaveBeenCalledOnce();
// Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
});
test("will play a leave sound synchronously in widget mode", async () => {
test("GroupCallView plays a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup();
const widget = {
api: {
@@ -147,12 +183,63 @@ test("will play a leave sound synchronously in widget mode", async () => {
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
let resolvePlaySound: () => void;
playSound = vi
.fn()
.mockReturnValue(
new Promise<void>((resolve) => (resolvePlaySound = resolve)),
);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
});
const { getByText, rtcSession } = createGroupCallView(
widget as WidgetHelpers,
);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
await flushPromises();
expect(leaveRTCSession).not.toHaveResolved();
resolvePlaySound!();
await flushPromises();
expect(playSound).toHaveBeenCalledWith("left");
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
expect(leaveRTCSession).toHaveBeenCalledWith(
rtcSession,
"user",
expect.any(Promise),
);
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("GroupCallView leaves the session when an error occurs", async () => {
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
const [error, setError] = useState<Error | null>(null);
if (error !== null) throw error;
return (
<div>
<button onClick={() => setError(new Error())}>Panic!</button>
</div>
);
});
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null);
await user.click(screen.getByRole("button", { name: "Panic!" }));
screen.getByText("Something went wrong");
expect(leaveRTCSession).toHaveBeenCalledWith(
rtcSession,
"error",
expect.any(Promise),
);
});
test("GroupCallView shows errors that occur during joining", async () => {
const user = userEvent.setup();
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError(""));
onTestFinished(() => {
enterRTCSession.mockReset();
});
createGroupCallView(null, false);
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});

View File

@@ -1,28 +1,28 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
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 ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
import {
Room,
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import { Heading, Text } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom";
import type { IWidgetApiRequest } from "matrix-widget-api";
@@ -31,7 +31,6 @@ import {
type JoinCallData,
type WidgetHelpers,
} from "../widget";
import { FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
@@ -40,13 +39,9 @@ import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import {
useMediaDevices,
type MediaDevices,
} from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
@@ -54,11 +49,23 @@ import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
E2EENotSupportedError,
ElementCallError,
ErrorCode,
RTCSessionError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
declare global {
interface Window {
@@ -74,6 +81,7 @@ interface Props {
skipLobby: boolean;
hideHeader: boolean;
rtcSession: MatrixRTCSession;
isJoined: boolean;
muteStates: MuteStates;
widget: WidgetHelpers | null;
}
@@ -86,11 +94,16 @@ export const GroupCallView: FC<Props> = ({
skipLobby,
hideHeader,
rtcSession,
isJoined,
muteStates,
widget,
}) => {
// Used to thread through any errors that occur outside the error boundary
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
@@ -111,6 +124,18 @@ export const GroupCallView: FC<Props> = ({
};
}, [rtcSession]);
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setExternalError(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
),
);
},
);
useEffect(() => {
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
@@ -119,11 +144,14 @@ export const GroupCallView: FC<Props> = ({
);
}, [client, rtcSession.room]);
const room = rtcSession.room as Room;
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
usePageTitle(roomName);
const matrixInfo = useMemo((): MatrixInfo => {
@@ -131,21 +159,13 @@ export const GroupCallView: FC<Props> = ({
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: rtcSession.room.roomId,
roomId: room.roomId,
roomName,
roomAlias: rtcSession.room.getCanonicalAlias(),
roomAlias: room.getCanonicalAlias(),
roomAvatar,
e2eeSystem,
};
}, [
client,
displayName,
avatarUrl,
rtcSession.room,
roomName,
roomAvatar,
e2eeSystem,
]);
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
@@ -154,12 +174,35 @@ export const GroupCallView: FC<Props> = ({
);
const deviceContext = useMediaDevices();
const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext;
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
// TODO: why do we use a ref here instead of using muteStates directly?
const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates;
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
}
},
[setExternalError],
);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -170,7 +213,7 @@ export const GroupCallView: FC<Props> = ({
// permissions and give you device names unless you specify a kind, but
// here we want all kinds of devices. This needs a fix in livekit-client
// for the following name-matching logic to do anything useful.
const devices = await Room.getLocalDevices(undefined, true);
const devices = await LivekitRoom.getLocalDevices(undefined, true);
if (audioInput) {
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
@@ -210,7 +253,11 @@ export const GroupCallView: FC<Props> = ({
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSession(rtcSession, perParticipantE2EE);
await enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
@@ -223,49 +270,72 @@ export const GroupCallView: FC<Props> = ({
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSession(rtcSession, perParticipantE2EE);
await enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
}
} else {
void enterRTCSession(rtcSession, perParticipantE2EE);
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
}
}
}, [widget, rtcSession, preload, skipLobby, perParticipantE2EE]);
}, [
widget,
rtcSession,
preload,
skipLobby,
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
const navigate = useNavigate();
const onLeave = useCallback(
(leaveError?: Error): void => {
(cause: "user" | "error" = "user"): void => {
const audioPromise = leaveSoundContext.current?.playSound("left");
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
setLeaveError(leaveError);
setLeft(true);
PosthogAnalytics.instance.eventCallEnded.track(
rtcSession.room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcSession,
);
// we need to wait until the callEnded event is tracked on posthog.
// Otherwise the iFrame gets killed before the callEnded event got tracked.
const posthogRequest = new Promise((resolve) => {
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcSession,
);
window.setTimeout(resolve, 10);
});
leaveRTCSession(
rtcSession,
cause,
// Wait for the sound in widget mode (it's not long)
sendInstantly && audioPromise ? audioPromise : undefined,
Promise.all([audioPromise, posthogRequest]),
)
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
.then(() => {
.then(async () => {
if (
!isPasswordlessUser &&
!confineToRoom &&
!PosthogAnalytics.instance.isEnabled()
) {
navigate("/");
await navigate("/");
}
})
.catch((e) => {
@@ -273,11 +343,12 @@ export const GroupCallView: FC<Props> = ({
});
},
[
leaveSoundContext,
widget,
rtcSession,
room.roomId,
isPasswordlessUser,
confineToRoom,
leaveSoundContext,
navigate,
],
);
@@ -292,7 +363,7 @@ export const GroupCallView: FC<Props> = ({
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
widget.api.transport.reply(ev.detail, {});
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
leaveRTCSession(rtcSession).catch((e) => {
leaveRTCSession(rtcSession, "user").catch((e) => {
logger.error("Failed to leave RTC session", e);
});
};
@@ -303,15 +374,7 @@ export const GroupCallView: FC<Props> = ({
}
}, [widget, isJoined, rtcSession]);
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
logger.error("Error re-entering RTC session on reconnect", e);
});
}, [rtcSession, perParticipantE2EE]);
const joinRule = useJoinRule(rtcSession.room);
const joinRule = useJoinRule(room);
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
@@ -325,22 +388,14 @@ export const GroupCallView: FC<Props> = ({
);
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
const { t } = useTranslation();
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
// If we have a encryption system but the browser does not support it.
return (
<FullScreenView>
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
<Text>{t("browser_media_e2ee_unsupported")}</Text>
<Link to="/">{t("common.home")}</Link>
</FullScreenView>
);
throw new E2EENotSupportedError();
}
const shareModal = (
<InviteModal
room={rtcSession.room}
room={room}
open={shareModalOpen}
onDismiss={onDismissInviteModal}
/>
@@ -352,7 +407,13 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
onEnter={() =>
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
)
}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
@@ -361,8 +422,17 @@ export const GroupCallView: FC<Props> = ({
</>
);
if (isJoined) {
return (
let body: ReactNode;
if (externalError) {
// If an error was recorded within this component but outside
// GroupCallErrorBoundary, create a component that rethrows the error from
// within the error boundary, so it can be handled uniformly
const ErrorComponent = (): ReactNode => {
throw externalError;
};
body = <ErrorComponent />;
} else if (isJoined) {
body = (
<>
{shareModal}
<ActiveCall
@@ -390,36 +460,53 @@ export const GroupCallView: FC<Props> = ({
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
leaveError
(PosthogAnalytics.instance.isEnabled() && widget === null)
) {
return (
<>
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
leaveError={leaveError}
reconnect={onReconnect}
/>
;
</>
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
/>
);
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the
// LobbyView again which would open capture devices again.
return null;
body = null;
}
} else if (left && widget !== null) {
// Left in widget mode:
if (!returnToLobby) {
return null;
}
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
return null;
body = null;
} else {
body = lobbyView;
}
return lobbyView;
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={(action) => {
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={
(/**error*/) => {
if (rtcSession.isJoined()) onLeave("error");
}
}
>
{body}
</GroupCallErrorBoundary>
);
};

View File

@@ -1,7 +1,7 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -11,7 +11,7 @@ import {
useLocalParticipant,
} from "@livekit/components-react";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
@@ -23,13 +23,14 @@ import {
useMemo,
useRef,
useState,
type JSX,
} from "react";
import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -96,6 +97,7 @@ import {
useSetting,
} from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader";
import { ConnectionLostError } from "../utils/errors.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -172,7 +174,8 @@ export interface InCallViewProps {
livekitRoom: Room;
muteStates: MuteStates;
participantCount: number;
onLeave: (error?: Error) => void;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
@@ -197,13 +200,10 @@ export const InCallView: FC<InCallViewProps> = ({
useWakeLock();
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
if (connState === ConnectionState.Disconnected)
throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure();
@@ -678,6 +678,7 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
livekitRoom={livekitRoom}
/>
</>
)}

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,13 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -30,6 +30,6 @@ test("InviteModal is accessible", async () => {
);
expect(await axe(container)).toHaveNoViolations();
await user.click(screen.getByRole("button", { name: "action.copy_link" }));
await user.click(screen.getByRole("button", { name: "Copy link" }));
expect(onDismiss).toBeCalled();
});

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -13,7 +13,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,16 +1,16 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useMemo, useState } from "react";
import { type FC, useCallback, useMemo, useState, type JSX } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react";
import {
type CreateLocalTracksOptions,
@@ -97,7 +97,11 @@ export const LobbyView: FC<Props> = ({
);
const navigate = useNavigate();
const onLeaveClick = useCallback(() => navigate("/"), [navigate]);
const onLeaveClick = useCallback(() => {
navigate("/")?.catch((error) => {
logger.error("Failed to navigate to /", error);
});
}, [navigate]);
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
const recentsButton = !confineToRoom && (

View File

@@ -1,14 +1,15 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { type ReactNode } from "react";
import { type FC, useCallback, useState } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { useMuteStates } from "./MuteStates";
import {
@@ -19,19 +20,28 @@ import {
} from "../livekit/MediaDevicesContext";
import { mockConfig } from "../utils/test";
function TestComponent(): ReactNode {
const muteStates = useMuteStates();
interface TestComponentProps {
isJoined?: boolean;
}
const TestComponent: FC<TestComponentProps> = ({ isJoined = false }) => {
const muteStates = useMuteStates(isJoined);
const onToggleAudio = useCallback(
() => muteStates.audio.setEnabled?.(!muteStates.audio.enabled),
[muteStates],
);
return (
<div>
<div data-testid="audio-enabled">
{muteStates.audio.enabled.toString()}
</div>
<button onClick={onToggleAudio}>Toggle audio</button>
<div data-testid="video-enabled">
{muteStates.video.enabled.toString()}
</div>
</div>
);
}
};
const mockMicrophone: MediaDeviceInfo = {
deviceId: "",
@@ -128,7 +138,7 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("should be enabled by default", () => {
it("enables devices by default in the lobby", () => {
mockConfig();
render(
@@ -142,6 +152,22 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
it("disables devices by default in the call", () => {
// Disabling new devices in the call ensures that connecting a webcam
// mid-call won't cause it to suddenly be enabled without user input
mockConfig();
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<TestComponent isJoined />
</MediaDevicesContext.Provider>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("uses defaults from config", () => {
mockConfig({
media_devices: {
@@ -174,4 +200,50 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("remembers previous state when devices disappear and reappear", async () => {
const user = userEvent.setup();
mockConfig();
const noDevices = mockMediaDevices({ microphone: false, camera: false });
const someDevices = mockMediaDevices();
const ReappearanceTest: FC = () => {
const [devices, setDevices] = useState(someDevices);
const onConnectDevicesClick = useCallback(
() => setDevices(someDevices),
[],
);
const onDisconnectDevicesClick = useCallback(
() => setDevices(noDevices),
[],
);
return (
<MemoryRouter>
<MediaDevicesContext.Provider value={devices}>
<TestComponent />
<button onClick={onConnectDevicesClick}>Connect devices</button>
<button onClick={onDisconnectDevicesClick}>
Disconnect devices
</button>
</MediaDevicesContext.Provider>
</MemoryRouter>
);
};
render(<ReappearanceTest />);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
await user.click(screen.getByRole("button", { name: "Toggle audio" }));
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
await user.click(
screen.getByRole("button", { name: "Disconnect devices" }),
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
await user.click(screen.getByRole("button", { name: "Connect devices" }));
// Audio should remember that it was muted, while video should re-enable
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});

View File

@@ -1,7 +1,7 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -13,7 +13,7 @@ import {
useMemo,
} from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MediaDevice,
@@ -57,8 +57,9 @@ function useMuteState(
enabledByDefault: () => boolean,
): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
// Determine the default value once devices are actually connected
(prev) =>
device.available.size > 0 ? (prev ?? enabledByDefault()) : undefined,
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
[device],
);
return useMemo(
@@ -73,17 +74,17 @@ function useMuteState(
);
}
export function useMuteStates(): MuteStates {
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
const { skipLobby } = useUrlParams();
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby;
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
const video = useMuteState(
devices.videoInput,
() => Config.get().media_devices.enable_video && !skipLobby,
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
);
useEffect(() => {

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,14 +1,14 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useState } from "react";
import { useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { Button, Heading, Text } from "@vector-im/compound-web";
import styles from "./RoomAuthView.module.css";
@@ -80,10 +80,10 @@ export const RoomAuthView: FC = () => {
/>
</FieldRow>
<Text size="sm">
<Trans i18nKey="room_auth_view_eula_caption">
<Trans i18nKey="room_auth_view_ssla_caption">
By clicking "Join call now", you agree to our{" "}
<ExternalLink href={Config.get().eula}>
End User Licensing Agreement (EULA)
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
</Trans>
</Text>

View File

@@ -1,19 +1,28 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MatrixError } from "matrix-js-sdk/src/http-api";
import { Heading, Text } from "@vector-im/compound-web";
import {
type FC,
useEffect,
useState,
type ReactNode,
useRef,
type JSX,
} from "react";
import { type MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
import {
CheckIcon,
UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
@@ -30,6 +39,8 @@ import { useMuteStates } from "./MuteStates";
import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config";
import { Link } from "../button/Link";
import { ErrorView } from "../ErrorView";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
export const RoomPage: FC = () => {
const {
@@ -56,7 +67,10 @@ export const RoomPage: FC = () => {
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
const muteStates = useMuteStates();
const isJoined = useMatrixRTCSessionJoinState(
groupCallState.kind === "loaded" ? groupCallState.rtcSession : undefined,
);
const muteStates = useMuteStates(isJoined);
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
@@ -101,6 +115,7 @@ export const RoomPage: FC = () => {
widget={widget}
client={client!}
rtcSession={groupCallState.rtcSession}
isJoined={isJoined}
isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom}
preload={preload}
@@ -164,29 +179,42 @@ export const RoomPage: FC = () => {
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
<FullScreenView>
<Heading>{t("group_call_loader.failed_heading")}</Heading>
<Text>{t("group_call_loader.failed_text")}</Text>
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
dupes of this flow, let's make a common component and put it here. */}
<Link to="/">{t("common.home")}</Link>
<ErrorView
Icon={UnknownSolidIcon}
title={t("error.call_not_found")}
widget={widget}
>
<Trans i18nKey="error.call_not_found_description">
<p>
That link doesn't appear to belong to any existing call.
Check that you have the right link, or{" "}
<Link to="/">create a new one</Link>.
</p>
</Trans>
</ErrorView>
</FullScreenView>
);
} else if (groupCallState.error instanceof CallTerminatedMessage) {
return (
<FullScreenView>
<Heading>{groupCallState.error.message}</Heading>
<Text>{groupCallState.error.messageBody}</Text>
{groupCallState.error.reason && (
<>
{t("group_call_loader.reason")}:
<Text size="sm">"{groupCallState.error.reason}"</Text>
</>
)}
<Link to="/">{t("common.home")}</Link>
<ErrorView
Icon={groupCallState.error.icon}
title={groupCallState.error.message}
widget={widget}
>
<p>{groupCallState.error.messageBody}</p>
{groupCallState.error.reason && (
<p>
{t("group_call_loader.reason", {
reason: groupCallState.error.reason,
})}
</p>
)}
</ErrorView>
</FullScreenView>
);
} else {
return <ErrorView error={groupCallState.error} />;
return <ErrorPage widget={widget} error={groupCallState.error} />;
}
default:
return <> </>;
@@ -195,9 +223,9 @@ export const RoomPage: FC = () => {
let content: ReactNode;
if (loading || isRegistering) {
content = <LoadingView />;
content = <LoadingPage />;
} else if (error) {
content = <ErrorView error={error} />;
content = <ErrorPage widget={widget} error={error} />;
} else if (!client) {
content = <RoomAuthView />;
} else if (!roomIdOrAlias) {

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -66,8 +66,6 @@ describe("VideoPreview", () => {
children={<></>}
/>,
);
expect(queryByRole("status")).toHaveTextContent(
"video_tile.camera_starting",
);
expect(queryByRole("status")).toHaveTextContent("Video loading...");
});
});

View File

@@ -1,7 +1,7 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { vi, type Mocked, test, expect } from "vitest";
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
import { type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";

View File

@@ -1,12 +1,11 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType } from "matrix-js-sdk/src/@types/event";
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
import { EventType, type RoomState } from "matrix-js-sdk";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";

View File

@@ -1,21 +1,18 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
} from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react";
import { deepCompare } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import {
type LivekitFocus,
isLivekitFocus,
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
/**
* Gets the currently active (livekit) focus for a MatrixRTC session

View File

@@ -1,14 +1,13 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { type JoinRule } from "matrix-js-sdk/src/matrix";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { JoinRule, Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useJoinRule(room: Room): JoinRule {

View File

@@ -1,24 +1,38 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
useState,
useEffect,
useRef,
useCallback,
type ComponentType,
type SVGAttributes,
} from "react";
import {
JoinRule,
EventType,
SyncState,
MatrixError,
KnownMembership,
ClientEvent,
type MatrixClient,
type RoomSummary,
} from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { RoomEvent, type Room } from "matrix-js-sdk/src/models/room";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { JoinRule, MatrixError } from "matrix-js-sdk/src/matrix";
RoomEvent,
type Room,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useTranslation } from "react-i18next";
import {
AdminIcon,
CloseIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { widget } from "../widget";
@@ -92,27 +106,25 @@ async function joinRoomAfterInvite(
export class CallTerminatedMessage extends Error {
/**
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
*/
public messageBody: string;
/**
* @param reason The user provided reason for the termination (kick/ban)
*/
public reason?: string;
/**
*
* @param messageTitle The title of the call ended screen message (translated)
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
* @param reason The user provided reason for the termination (kick/ban)
*/
public constructor(
/**
* The icon to display with the message.
*/
public readonly icon: ComponentType<SVGAttributes<SVGElement>>,
messageTitle: string,
messageBody: string,
reason?: string,
/**
* The message explaining the kind of termination (kick, ban, knock reject,
* etc.) (translated)
*/
public readonly messageBody: string,
/**
* The user-provided reason for the termination (kick/ban)
*/
public readonly reason?: string,
) {
super(messageTitle);
this.messageBody = messageBody;
this.reason = reason;
}
}
@@ -122,12 +134,13 @@ export const useLoadGroupCall = (
viaServers: string[],
): GroupCallStatus => {
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
const activeRoom = useRef<Room>();
const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation();
const bannedError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
@@ -137,6 +150,7 @@ export const useLoadGroupCall = (
const knockRejectError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
CloseIcon,
t("group_call_loader.knock_reject_heading"),
t("group_call_loader.knock_reject_body"),
leaveReason(),
@@ -146,6 +160,7 @@ export const useLoadGroupCall = (
const removeNoticeError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),

View File

@@ -1,12 +1,12 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room } from "matrix-js-sdk/src/models/room";
import { type Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";

View File

@@ -1,11 +1,11 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { type Room, RoomEvent } from "matrix-js-sdk";
import { useState } from "react";
import { useTypedEventEmitter } from "../useEvents";

View File

@@ -1,17 +1,13 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk/src/models/room-state";
import { useCallback, useMemo, useState } from "react";
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
import type { Room } from "matrix-js-sdk/src/models/room";
import { useTypedEventEmitter } from "../useEvents";
/**

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -20,7 +20,7 @@ import {
TrackEvent,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { platform } from "../Platform";