Merge branch 'livekit' into firefox-audio-output
This commit is contained in:
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
@@ -5,8 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, FormEventHandler, ReactNode, useCallback, useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
type FC,
|
||||
type FormEventHandler,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
201
src/room/CallEventAudioRenderer.test.tsx
Normal file
201
src/room/CallEventAudioRenderer.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
afterAll,
|
||||
beforeEach,
|
||||
expect,
|
||||
type MockedFunction,
|
||||
test,
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { ConnectionState } from "livekit-client";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { afterEach } from "node:test";
|
||||
import { act, type ReactNode } from "react";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/src/matrixrtc";
|
||||
import { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { CallViewModel } from "../state/CallViewModel";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { TestReactionsWrapper } from "../utils/testReactions";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
|
||||
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
const local = mockMatrixRoomMember(localRtcMember);
|
||||
const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
const alice = mockMatrixRoomMember(aliceRtcMember);
|
||||
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
|
||||
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
|
||||
afterEach(() => {
|
||||
vitest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
let playSound: MockedFunction<
|
||||
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||
sound: new ArrayBuffer(0),
|
||||
});
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
});
|
||||
});
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
vm,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
vm: CallViewModel;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TestReactionsWrapper
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<CallEventAudioRenderer vm={vm} />
|
||||
</TestReactionsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function getMockEnv(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
session: MockRTCSession;
|
||||
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
|
||||
} {
|
||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||
const remoteParticipants = of([aliceParticipant]);
|
||||
const liveKitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
{ remoteParticipants },
|
||||
);
|
||||
const matrixRoom = mockMatrixRoom({
|
||||
client: {
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
on: vitest.fn(),
|
||||
off: vitest.fn(),
|
||||
} as Partial<MatrixClient> as MatrixClient,
|
||||
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
|
||||
});
|
||||
|
||||
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
|
||||
initialRemoteRtcMemberships,
|
||||
);
|
||||
|
||||
const session = new MockRTCSession(
|
||||
matrixRoom,
|
||||
localRtcMember,
|
||||
).withMemberships(remoteRtcMemberships);
|
||||
|
||||
const vm = new CallViewModel(
|
||||
session as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
of(ConnectionState.Connected),
|
||||
);
|
||||
return { vm, session, remoteRtcMemberships };
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't want to play a sound when loading the call state
|
||||
* because typically this occurs in two stages. We first join
|
||||
* the call as a local participant and *then* the remote
|
||||
* participants join from our perspective. We don't want to make
|
||||
* a noise every time.
|
||||
*/
|
||||
test("plays one sound when entering a call", () => {
|
||||
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
// Joining a call usually means remote participants are added later.
|
||||
act(() => {
|
||||
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// TODO: Same test?
|
||||
test("plays a sound when a user joins", () => {
|
||||
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
// Play a sound when joining a call.
|
||||
expect(playSound).toBeCalledWith("join");
|
||||
});
|
||||
|
||||
test("plays a sound when a user leaves", () => {
|
||||
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships.next([]);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
|
||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||
const mockRtcMemberships: CallMembership[] = [];
|
||||
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
||||
mockRtcMemberships.push(
|
||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { session, vm, remoteRtcMemberships } = getMockEnv(
|
||||
[local, alice],
|
||||
mockRtcMemberships,
|
||||
);
|
||||
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
expect(playSound).not.toBeCalled();
|
||||
act(() => {
|
||||
remoteRtcMemberships.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
||||
);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
99
src/room/CallEventAudioRenderer.tsx
Normal file
99
src/room/CallEventAudioRenderer.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useDeferredValue, useEffect, useMemo } from "react";
|
||||
import { filter, interval, throttle } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
import joinCallSoundOgg from "../sound/join_call.ogg";
|
||||
import leftCallSoundMp3 from "../sound/left_call.mp3";
|
||||
import leftCallSoundOgg from "../sound/left_call.ogg";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg?url";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useReactions } from "../useReactions";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
export const callEventAudioSounds = prefetchSounds({
|
||||
join: {
|
||||
mp3: joinCallSoundMp3,
|
||||
ogg: joinCallSoundOgg,
|
||||
},
|
||||
left: {
|
||||
mp3: leftCallSoundMp3,
|
||||
ogg: leftCallSoundOgg,
|
||||
},
|
||||
raiseHand: {
|
||||
mp3: handSoundMp3,
|
||||
ogg: handSoundOgg,
|
||||
},
|
||||
});
|
||||
|
||||
export function CallEventAudioRenderer({
|
||||
vm,
|
||||
}: {
|
||||
vm: CallViewModel;
|
||||
}): ReactNode {
|
||||
const audioEngineCtx = useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
latencyHint: "interactive",
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
const { raisedHands } = useReactions();
|
||||
const raisedHandCount = useMemo(
|
||||
() => Object.keys(raisedHands).length,
|
||||
[raisedHands],
|
||||
);
|
||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
|
||||
void audioEngineRef.current.playSound("raiseHand");
|
||||
}
|
||||
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const joinSub = vm.memberChanges
|
||||
.pipe(
|
||||
filter(
|
||||
({ joined, ids }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("join");
|
||||
});
|
||||
|
||||
const leftSub = vm.memberChanges
|
||||
.pipe(
|
||||
filter(
|
||||
({ ids, left }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("left");
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
joinSub.unsubscribe();
|
||||
leftSub.unsubscribe();
|
||||
};
|
||||
}, [audioEngineRef, vm]);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC } from "react";
|
||||
import { type FC } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
|
||||
153
src/room/GroupCallView.test.tsx
Normal file
153
src/room/GroupCallView.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
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 { of } from "rxjs";
|
||||
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
import { Router } from "react-router-dom";
|
||||
import { createBrowserHistory } from "history";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import {
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
|
||||
vitest.mock("../soundUtils");
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("./InCallView");
|
||||
|
||||
vitest.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;
|
||||
});
|
||||
|
||||
let playSound: MockedFunction<
|
||||
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
||||
>;
|
||||
|
||||
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
const carol = mockMatrixRoomMember(localRtcMember);
|
||||
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
|
||||
|
||||
const roomId = "!foo:bar";
|
||||
const soundPromise = Promise.resolve(true);
|
||||
|
||||
beforeEach(() => {
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||
sound: new ArrayBuffer(0),
|
||||
});
|
||||
playSound = vitest.fn().mockReturnValue(soundPromise);
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
});
|
||||
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
||||
({ onLeave }) => {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onLeave()}>Leave</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
rtcSession: MockRTCSession;
|
||||
getByText: ReturnType<typeof render>["getByText"];
|
||||
} {
|
||||
const history = createBrowserHistory();
|
||||
const client = {
|
||||
getUser: () => null,
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getRoom: (rId) => (rId === roomId ? room : null),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const room = mockMatrixRoom({
|
||||
client,
|
||||
roomId,
|
||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
getMxcAvatarUrl: () => null,
|
||||
getCanonicalAlias: () => null,
|
||||
currentState: {
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(of([]));
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
} as MuteStates;
|
||||
const { getByText } = render(
|
||||
<Router history={history}>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
return {
|
||||
getByText,
|
||||
rtcSession,
|
||||
};
|
||||
}
|
||||
|
||||
test("will play 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();
|
||||
});
|
||||
|
||||
test("will play a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
const { getByText, rtcSession } = createGroupCallView(
|
||||
widget as WidgetHelpers,
|
||||
);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
expect(playSound).toHaveBeenCalledWith("left");
|
||||
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
|
||||
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -5,31 +5,45 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
Room,
|
||||
isE2EESupported as isE2EESupportedBrowser,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
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 type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "../widget";
|
||||
import { FullScreenView } from "../FullScreenView";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../utils/media";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
||||
import {
|
||||
useMediaDevices,
|
||||
type MediaDevices,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
@@ -41,6 +55,9 @@ 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";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -57,6 +74,7 @@ interface Props {
|
||||
hideHeader: boolean;
|
||||
rtcSession: MatrixRTCSession;
|
||||
muteStates: MuteStates;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -68,10 +86,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
hideHeader,
|
||||
rtcSession,
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
latencyHint: "interactive",
|
||||
}),
|
||||
);
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
@@ -131,48 +155,46 @@ export const GroupCallView: FC<Props> = ({
|
||||
const latestDevices = useRef<MediaDevices>();
|
||||
latestDevices.current = deviceContext;
|
||||
|
||||
// TODO: why do we use a ref here instead of using muteStates directly?
|
||||
const latestMuteStates = useRef<MuteStates>();
|
||||
latestMuteStates.current = muteStates;
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDeviceSetup = async (
|
||||
requestedDeviceData: JoinCallData,
|
||||
): Promise<void> => {
|
||||
const defaultDeviceSetup = async ({
|
||||
audioInput,
|
||||
videoInput,
|
||||
}: JoinCallData): Promise<void> => {
|
||||
// XXX: I think this is broken currently - LiveKit *won't* request
|
||||
// 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 { audioInput, videoInput } = requestedDeviceData;
|
||||
if (audioInput === null) {
|
||||
latestMuteStates.current!.audio.setEnabled?.(false);
|
||||
} else {
|
||||
|
||||
if (audioInput) {
|
||||
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
|
||||
if (!deviceId) {
|
||||
logger.warn("Unknown audio input: " + audioInput);
|
||||
// override the default mute state
|
||||
latestMuteStates.current!.audio.setEnabled?.(false);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
||||
);
|
||||
latestDevices.current!.audioInput.select(deviceId);
|
||||
latestMuteStates.current!.audio.setEnabled?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (videoInput === null) {
|
||||
latestMuteStates.current!.video.setEnabled?.(false);
|
||||
} else {
|
||||
if (videoInput) {
|
||||
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
|
||||
if (!deviceId) {
|
||||
logger.warn("Unknown video input: " + videoInput);
|
||||
// override the default mute state
|
||||
latestMuteStates.current!.video.setEnabled?.(false);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Found video input ID ${deviceId} for name ${videoInput}`,
|
||||
);
|
||||
latestDevices.current!.videoInput.select(deviceId);
|
||||
latestMuteStates.current!.video.setEnabled?.(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -187,19 +209,18 @@ export const GroupCallView: FC<Props> = ({
|
||||
ev.detail.data as unknown as JoinCallData,
|
||||
);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
widget.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
@@ -209,7 +230,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
void enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
}, [widget, rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
const [left, setLeft] = useState(false);
|
||||
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
|
||||
@@ -217,12 +238,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
const onLeave = useCallback(
|
||||
(leaveError?: Error): void => {
|
||||
setLeaveError(leaveError);
|
||||
setLeft(true);
|
||||
|
||||
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,
|
||||
@@ -230,8 +251,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession,
|
||||
);
|
||||
|
||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||
leaveRTCSession(rtcSession)
|
||||
leaveRTCSession(
|
||||
rtcSession,
|
||||
// Wait for the sound in widget mode (it's not long)
|
||||
sendInstantly && audioPromise ? audioPromise : undefined,
|
||||
)
|
||||
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
||||
.then(() => {
|
||||
if (
|
||||
!isPasswordlessUser &&
|
||||
@@ -245,18 +270,25 @@ export const GroupCallView: FC<Props> = ({
|
||||
logger.error("Error leaving RTC session", e);
|
||||
});
|
||||
},
|
||||
[rtcSession, isPasswordlessUser, confineToRoom, history],
|
||||
[
|
||||
widget,
|
||||
rtcSession,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
leaveSoundContext,
|
||||
history,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget && isJoined) {
|
||||
// set widget to sticky once joined.
|
||||
widget!.api.setAlwaysOnScreen(true).catch((e) => {
|
||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||
});
|
||||
|
||||
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
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) => {
|
||||
logger.error("Failed to leave RTC session", e);
|
||||
@@ -264,10 +296,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
||||
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
||||
};
|
||||
}
|
||||
}, [isJoined, rtcSession]);
|
||||
}, [widget, isJoined, rtcSession]);
|
||||
|
||||
const onReconnect = useCallback(() => {
|
||||
setLeft(false);
|
||||
@@ -334,7 +366,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
<ActiveCall
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
participantCount={participantCount}
|
||||
onLeave={onLeave}
|
||||
hideHeader={hideHeader}
|
||||
@@ -360,14 +392,17 @@ export const GroupCallView: FC<Props> = ({
|
||||
leaveError
|
||||
) {
|
||||
return (
|
||||
<CallEndedView
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
leaveError={leaveError}
|
||||
reconnect={onReconnect}
|
||||
/>
|
||||
<>
|
||||
<CallEndedView
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
leaveError={leaveError}
|
||||
reconnect={onReconnect}
|
||||
/>
|
||||
;
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
// If the user is a regular user, we'll have sent them back to the homepage,
|
||||
|
||||
@@ -36,10 +36,9 @@ Please see LICENSE in the repository root for full details.
|
||||
inset-block-end: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--inline-content-inset)) 1fr auto 1fr minmax(
|
||||
0,
|
||||
var(--inline-content-inset)
|
||||
);
|
||||
grid-template-columns:
|
||||
minmax(0, var(--inline-content-inset))
|
||||
1fr auto 1fr minmax(0, var(--inline-content-inset));
|
||||
grid-template-areas: ". logo buttons layout .";
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
|
||||
@@ -10,23 +10,22 @@ import {
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
} from "@livekit/components-react";
|
||||
import { ConnectionState, Room } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { ConnectionState, type Room } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import {
|
||||
FC,
|
||||
PointerEvent,
|
||||
PropsWithoutRef,
|
||||
TouchEvent,
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
type PropsWithoutRef,
|
||||
type TouchEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
@@ -50,28 +49,32 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useLiveKit } from "../livekit/useLiveKit";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { type ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import { CallViewModel, GridMode, Layout } from "../state/CallViewModel";
|
||||
import { Grid, TileProps } from "../grid/Grid";
|
||||
import {
|
||||
CallViewModel,
|
||||
type GridMode,
|
||||
type Layout,
|
||||
} from "../state/CallViewModel";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
CallLayoutOutputs,
|
||||
type CallLayoutOutputs,
|
||||
defaultPipAlignment,
|
||||
defaultSpotlightAlignment,
|
||||
} from "../grid/CallLayout";
|
||||
@@ -79,14 +82,16 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
import { GridTileViewModel, TileViewModel } from "../state/TileViewModel";
|
||||
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsProvider, useReactions } from "../useReactions";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg?url";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { useSwitchCamera } from "./useSwitchCamera";
|
||||
import { soundEffectVolumeSetting, useSetting } from "../settings/settings";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -123,7 +128,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession.room,
|
||||
props.rtcSession,
|
||||
livekitRoom,
|
||||
props.e2eeSystem,
|
||||
connStateObservable,
|
||||
@@ -131,12 +136,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
setVm(vm);
|
||||
return (): void => vm.destroy();
|
||||
}
|
||||
}, [
|
||||
props.rtcSession.room,
|
||||
livekitRoom,
|
||||
props.e2eeSystem,
|
||||
connStateObservable,
|
||||
]);
|
||||
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
|
||||
|
||||
if (livekitRoom === undefined || vm === null) return null;
|
||||
|
||||
@@ -182,14 +182,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
|
||||
useReactions();
|
||||
const raisedHandCount = useMemo(
|
||||
() => Object.keys(raisedHands).length,
|
||||
[raisedHands],
|
||||
);
|
||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
||||
const { supportsReactions, sendReaction, toggleRaisedHand } = useReactions();
|
||||
|
||||
useWakeLock();
|
||||
|
||||
@@ -234,6 +227,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const windowMode = useObservableEagerState(vm.windowMode);
|
||||
const layout = useObservableEagerState(vm.layout);
|
||||
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration);
|
||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||
const gridMode = useObservableEagerState(vm.gridMode);
|
||||
const showHeader = useObservableEagerState(vm.showHeader);
|
||||
const showFooter = useObservableEagerState(vm.showFooter);
|
||||
@@ -339,25 +334,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
[vm],
|
||||
);
|
||||
|
||||
// Play a sound when the raised hand count increases.
|
||||
const handRaisePlayer = useRef<HTMLAudioElement>(null);
|
||||
useEffect(() => {
|
||||
if (!handRaisePlayer.current) {
|
||||
return;
|
||||
}
|
||||
if (previousRaisedHandCount < raisedHandCount) {
|
||||
handRaisePlayer.current.volume = soundEffectVolume;
|
||||
handRaisePlayer.current.play().catch((ex) => {
|
||||
logger.warn("Failed to play raise hand sound", ex);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
raisedHandCount,
|
||||
handRaisePlayer,
|
||||
previousRaisedHandCount,
|
||||
soundEffectVolume,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport
|
||||
.send(
|
||||
@@ -615,6 +591,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
height={11}
|
||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||
/>
|
||||
{/* Don't mind this odd placement, it's just a little debug label */}
|
||||
{debugTileLayout
|
||||
? `Tiles generation: ${tileStoreGeneration}`
|
||||
: undefined}
|
||||
</div>
|
||||
)}
|
||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
@@ -670,10 +650,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
))}
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
<audio ref={handRaisePlayer} preload="auto" hidden>
|
||||
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
|
||||
<source src={handSoundMp3} type="audio/mpeg" />
|
||||
</audio>
|
||||
<CallEventAudioRenderer vm={vm} />
|
||||
<ReactionsAudioRenderer />
|
||||
<ReactionsOverlay />
|
||||
{footer}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { axe } from "vitest-axe";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -5,9 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
LinkIcon,
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
|
||||
import { type ChangeEvent, type FC, type TouchEvent, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
|
||||
@@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useCallback, useMemo, useState } from "react";
|
||||
import { type FC, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { usePreviewTracks } from "@livekit/components-react";
|
||||
import { LocalVideoTrack, Track } from "livekit-client";
|
||||
import { type LocalVideoTrack, Track } from "livekit-client";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
|
||||
@@ -21,8 +21,8 @@ import inCallStyles from "./InCallView.module.css";
|
||||
import styles from "./LobbyView.module.css";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
EndCallButton,
|
||||
|
||||
177
src/room/MuteStates.test.tsx
Normal file
177
src/room/MuteStates.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
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 { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import {
|
||||
type DeviceLabel,
|
||||
type MediaDevice,
|
||||
type MediaDevices,
|
||||
MediaDevicesContext,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
function TestComponent(): ReactNode {
|
||||
const muteStates = useMuteStates();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="audio-enabled">
|
||||
{muteStates.audio.enabled.toString()}
|
||||
</div>
|
||||
<div data-testid="video-enabled">
|
||||
{muteStates.video.enabled.toString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mockMicrophone: MediaDeviceInfo = {
|
||||
deviceId: "",
|
||||
kind: "audioinput",
|
||||
label: "",
|
||||
groupId: "",
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
const mockSpeaker: MediaDeviceInfo = {
|
||||
deviceId: "",
|
||||
kind: "audiooutput",
|
||||
label: "",
|
||||
groupId: "",
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
const mockCamera: MediaDeviceInfo = {
|
||||
deviceId: "",
|
||||
kind: "videoinput",
|
||||
label: "",
|
||||
groupId: "",
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice {
|
||||
return {
|
||||
available,
|
||||
selectedId: "",
|
||||
selectedGroupId: "",
|
||||
select: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
function mockMediaDevices(
|
||||
{
|
||||
microphone,
|
||||
speaker,
|
||||
camera,
|
||||
}: {
|
||||
microphone?: boolean;
|
||||
speaker?: boolean;
|
||||
camera?: boolean;
|
||||
} = { microphone: true, speaker: true, camera: true },
|
||||
): MediaDevices {
|
||||
return {
|
||||
audioInput: mockDevices(
|
||||
microphone
|
||||
? new Map([[mockMicrophone.deviceId, mockMicrophone]])
|
||||
: new Map(),
|
||||
),
|
||||
audioOutput: mockDevices(
|
||||
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(),
|
||||
),
|
||||
videoInput: mockDevices(
|
||||
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(),
|
||||
),
|
||||
startUsingDeviceNames: (): void => {},
|
||||
stopUsingDeviceNames: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useMuteStates", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("disabled when no input devices", () => {
|
||||
mockConfig();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider
|
||||
value={mockMediaDevices({
|
||||
microphone: false,
|
||||
camera: false,
|
||||
})}
|
||||
>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("should be enabled by default", () => {
|
||||
mockConfig();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
|
||||
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
|
||||
});
|
||||
|
||||
it("uses defaults from config", () => {
|
||||
mockConfig({
|
||||
media_devices: {
|
||||
enable_audio: false,
|
||||
enable_video: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("skipLobby mutes inputs", () => {
|
||||
mockConfig();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,23 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import {
|
||||
type MediaDevice,
|
||||
useMediaDevices,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -72,13 +76,14 @@ function useMuteState(
|
||||
export function useMuteStates(): MuteStates {
|
||||
const devices = useMediaDevices();
|
||||
|
||||
const audio = useMuteState(
|
||||
devices.audioInput,
|
||||
() => Config.get().media_devices.enable_audio,
|
||||
);
|
||||
const { skipLobby } = useUrlParams();
|
||||
|
||||
const audio = useMuteState(devices.audioInput, () => {
|
||||
return Config.get().media_devices.enable_audio && !skipLobby;
|
||||
});
|
||||
const video = useMuteState(
|
||||
devices.videoInput,
|
||||
() => Config.get().media_devices.enable_video,
|
||||
() => Config.get().media_devices.enable_video && !skipLobby,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Modal, Props as ModalProps } from "../Modal";
|
||||
import { Modal, type Props as ModalProps } from "../Modal";
|
||||
import { FieldRow, ErrorMessage } from "../input/Input";
|
||||
import { useSubmitRageshake } from "../settings/submit-rageshake";
|
||||
|
||||
|
||||
@@ -6,9 +6,18 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterAll, expect, test } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
beforeEach,
|
||||
expect,
|
||||
test,
|
||||
vitest,
|
||||
type MockedFunction,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { act, ReactNode } from "react";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { afterEach } from "node:test";
|
||||
|
||||
import {
|
||||
MockRoom,
|
||||
@@ -16,11 +25,13 @@ import {
|
||||
TestReactionsWrapper,
|
||||
} from "../utils/testReactions";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import {
|
||||
playReactionsSound,
|
||||
soundEffectVolumeSetting,
|
||||
} from "../settings/settings";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
|
||||
const memberUserIdAlice = "@alice:example.org";
|
||||
const memberUserIdBob = "@bob:example.org";
|
||||
@@ -49,11 +60,31 @@ function TestComponent({
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlayFn = window.HTMLMediaElement.prototype.play;
|
||||
afterAll(() => {
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
|
||||
afterEach(() => {
|
||||
vitest.resetAllMocks();
|
||||
playReactionsSound.setValue(playReactionsSound.defaultValue);
|
||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||
window.HTMLMediaElement.prototype.play = originalPlayFn;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vitest.restoreAllMocks();
|
||||
});
|
||||
|
||||
let playSound: Mock<
|
||||
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||
sound: new ArrayBuffer(0),
|
||||
});
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
});
|
||||
});
|
||||
|
||||
test("preloads all audio elements", () => {
|
||||
@@ -62,29 +93,11 @@ test("preloads all audio elements", () => {
|
||||
new MockRoom(memberUserIdAlice),
|
||||
membership,
|
||||
);
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(container.getElementsByTagName("audio")).toHaveLength(
|
||||
// All reactions plus the generic sound
|
||||
ReactionSet.filter((r) => r.sound).length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
test("loads no audio elements when disabled in settings", () => {
|
||||
playReactionsSound.setValue(false);
|
||||
const rtcSession = new MockRTCSession(
|
||||
new MockRoom(memberUserIdAlice),
|
||||
membership,
|
||||
);
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(container.getElementsByTagName("audio")).toHaveLength(0);
|
||||
render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(prefetchSounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("will play an audio sound when there is a reaction", () => {
|
||||
const audioIsPlaying: string[] = [];
|
||||
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
|
||||
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
|
||||
return Promise.resolve();
|
||||
};
|
||||
playReactionsSound.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
@@ -100,16 +113,10 @@ test("will play an audio sound when there is a reaction", () => {
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||
});
|
||||
expect(audioIsPlaying).toHaveLength(1);
|
||||
expect(audioIsPlaying[0]).toContain(chosenReaction.sound?.ogg);
|
||||
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
|
||||
});
|
||||
|
||||
test("will play the generic audio sound when there is soundless reaction", () => {
|
||||
const audioIsPlaying: string[] = [];
|
||||
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
|
||||
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
|
||||
return Promise.resolve();
|
||||
};
|
||||
playReactionsSound.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
@@ -125,38 +132,10 @@ test("will play the generic audio sound when there is soundless reaction", () =>
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||
});
|
||||
expect(audioIsPlaying).toHaveLength(1);
|
||||
expect(audioIsPlaying[0]).toContain(GenericReaction.sound?.ogg);
|
||||
});
|
||||
|
||||
test("will play an audio sound with the correct volume", () => {
|
||||
playReactionsSound.setValue(true);
|
||||
soundEffectVolumeSetting.setValue(0.5);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByTestId } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
const chosenReaction = ReactionSet.find((r) => !!r.sound);
|
||||
if (!chosenReaction) {
|
||||
throw Error(
|
||||
"No reactions have sounds configured, this test cannot succeed",
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||
});
|
||||
expect((getByTestId(chosenReaction.name) as HTMLAudioElement).volume).toEqual(
|
||||
0.5,
|
||||
);
|
||||
expect(playSound).toHaveBeenCalledWith(GenericReaction.name);
|
||||
});
|
||||
|
||||
test("will play multiple audio sounds when there are multiple different reactions", () => {
|
||||
const audioIsPlaying: string[] = [];
|
||||
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
|
||||
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
|
||||
return Promise.resolve();
|
||||
};
|
||||
playReactionsSound.setValue(true);
|
||||
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
@@ -175,7 +154,6 @@ test("will play multiple audio sounds when there are multiple different reaction
|
||||
room.testSendReaction(memberEventBob, reaction2, membership);
|
||||
room.testSendReaction(memberEventCharlie, reaction1, membership);
|
||||
});
|
||||
expect(audioIsPlaying).toHaveLength(2);
|
||||
expect(audioIsPlaying[0]).toContain(reaction1.sound?.ogg);
|
||||
expect(audioIsPlaying[1]).toContain(reaction2.sound?.ogg);
|
||||
expect(playSound).toHaveBeenCalledWith(reaction1.name);
|
||||
expect(playSound).toHaveBeenCalledWith(reaction2.name);
|
||||
});
|
||||
|
||||
@@ -5,70 +5,67 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { type ReactNode, useDeferredValue, useEffect, useState } from "react";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import {
|
||||
playReactionsSound,
|
||||
soundEffectVolumeSetting as effectSoundVolumeSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { playReactionsSound, useSetting } from "../settings/settings";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
const soundMap = Object.fromEntries([
|
||||
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
|
||||
v.name,
|
||||
v.sound!,
|
||||
]),
|
||||
[GenericReaction.name, GenericReaction.sound],
|
||||
]);
|
||||
|
||||
export function ReactionsAudioRenderer(): ReactNode {
|
||||
const { reactions } = useReactions();
|
||||
const [shouldPlay] = useSetting(playReactionsSound);
|
||||
const [effectSoundVolume] = useSetting(effectSoundVolumeSetting);
|
||||
const audioElements = useRef<Record<string, HTMLAudioElement | null>>({});
|
||||
const [soundCache, setSoundCache] = useState<ReturnType<
|
||||
typeof prefetchSounds
|
||||
> | null>(null);
|
||||
const audioEngineCtx = useAudioContext({
|
||||
sounds: soundCache,
|
||||
latencyHint: "interactive",
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
const oldReactions = useDeferredValue(reactions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioElements.current) {
|
||||
if (!shouldPlay || soundCache) {
|
||||
return;
|
||||
}
|
||||
// This is fine even if we load the component multiple times,
|
||||
// as the browser's cache should ensure once the media is loaded
|
||||
// once that future fetches come via the cache.
|
||||
setSoundCache(prefetchSounds(soundMap));
|
||||
}, [soundCache, shouldPlay]);
|
||||
|
||||
if (!shouldPlay) {
|
||||
useEffect(() => {
|
||||
if (!shouldPlay || !audioEngineRef.current) {
|
||||
return;
|
||||
}
|
||||
const oldReactionSet = new Set(
|
||||
Object.values(oldReactions).map((r) => r.name),
|
||||
);
|
||||
for (const reactionName of new Set(
|
||||
Object.values(reactions).map((r) => r.name),
|
||||
)) {
|
||||
const audioElement =
|
||||
audioElements.current[reactionName] ?? audioElements.current.generic;
|
||||
if (audioElement?.paused) {
|
||||
audioElement.volume = effectSoundVolume;
|
||||
void audioElement.play();
|
||||
if (oldReactionSet.has(reactionName)) {
|
||||
// Don't replay old reactions
|
||||
return;
|
||||
}
|
||||
if (soundMap[reactionName]) {
|
||||
void audioEngineRef.current.playSound(reactionName);
|
||||
} else {
|
||||
// Fallback sounds.
|
||||
void audioEngineRef.current.playSound("generic");
|
||||
}
|
||||
}
|
||||
}, [audioElements, shouldPlay, reactions, effectSoundVolume]);
|
||||
|
||||
// Do not render any audio elements if playback is disabled. Will save
|
||||
// audio file fetches.
|
||||
if (!shouldPlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOTE: We load all audio elements ahead of time to allow the cache
|
||||
// to be populated, rather than risk a cache miss and have the audio
|
||||
// be delayed.
|
||||
return (
|
||||
<>
|
||||
{[GenericReaction, ...ReactionSet].map(
|
||||
(r) =>
|
||||
r.sound && (
|
||||
<audio
|
||||
ref={(el) => (audioElements.current[r.name] = el)}
|
||||
data-testid={r.name}
|
||||
key={r.name}
|
||||
preload="auto"
|
||||
hidden
|
||||
>
|
||||
<source src={r.sound.ogg} type="audio/ogg; codecs=vorbis" />
|
||||
{r.sound.mp3 ? (
|
||||
<source src={r.sound.mp3} type="audio/mpeg" />
|
||||
) : null}
|
||||
</audio>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [audioEngineRef, shouldPlay, oldReactions, reactions]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { act, ReactNode } from "react";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { afterEach } from "node:test";
|
||||
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useCallback, useState } from "react";
|
||||
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";
|
||||
|
||||
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, ReactNode, useRef } from "react";
|
||||
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 { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { type MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
@@ -98,6 +98,7 @@ export const RoomPage: FC = () => {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
widget={widget}
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
|
||||
@@ -18,8 +18,7 @@ Please see LICENSE in the repository root for full details.
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: black;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
background-color: var(--video-tile-background);
|
||||
}
|
||||
|
||||
video.mirror {
|
||||
@@ -35,7 +34,7 @@ video.mirror {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
background-color: var(--video-tile-background);
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
|
||||
@@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, FC, ReactNode } from "react";
|
||||
import { useEffect, useRef, type FC, type ReactNode } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { facingModeFromLocalTrack, LocalVideoTrack } from "livekit-client";
|
||||
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
|
||||
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { vi, Mocked, test, expect } from "vitest";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
import { vi, type Mocked, test, expect } from "vitest";
|
||||
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
|
||||
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
|
||||
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
import { type RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
MatrixRTCSession,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { deepCompare } from "matrix-js-sdk/src/utils";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
LivekitFocus,
|
||||
type LivekitFocus,
|
||||
isLivekitFocus,
|
||||
} from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useActiveLivekitFocus(
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
||||
Updating focus (focus switch) from ${activeFocus} to ${newActiveFocus}`,
|
||||
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`,
|
||||
);
|
||||
setActiveFocus(newActiveFocus);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
import { type JoinRule } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { useRoomState } from "./useRoomState";
|
||||
|
||||
@@ -10,12 +10,12 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixClient,
|
||||
RoomSummary,
|
||||
type MatrixClient,
|
||||
type RoomSummary,
|
||||
} from "matrix-js-sdk/src/client";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { RoomEvent, Room } from "matrix-js-sdk/src/models/room";
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { type Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { useRoomState } from "./useRoomState";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
|
||||
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import {
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk/src/models/room-state";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
LocalVideoTrack,
|
||||
type LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
|
||||
Reference in New Issue
Block a user