Merge pull request #3453 from robintown/reconnecting
Pause media tracks and show a message when reconnecting to MatrixRTC
This commit is contained in:
@@ -55,6 +55,7 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"reaction": "Reaction",
|
"reaction": "Reaction",
|
||||||
"reactions": "Reactions",
|
"reactions": "Reactions",
|
||||||
|
"reconnecting": "Reconnecting…",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"unencrypted": "Not encrypted",
|
"unencrypted": "Not encrypted",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage";
|
|||||||
import { RoomPage } from "./room/RoomPage";
|
import { RoomPage } from "./room/RoomPage";
|
||||||
import { ClientProvider } from "./ClientContext";
|
import { ClientProvider } from "./ClientContext";
|
||||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
|
||||||
import { Initializer } from "./initializer";
|
import { Initializer } from "./initializer";
|
||||||
import { widget } from "./widget";
|
import { widget } from "./widget";
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
@@ -86,7 +85,6 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
<Sentry.ErrorBoundary
|
<Sentry.ErrorBoundary
|
||||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||||
>
|
>
|
||||||
<DisconnectedBanner />
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<SentryRoute path="/" element={<HomePage />} />
|
<SentryRoute path="/" element={<HomePage />} />
|
||||||
<SentryRoute path="/login" element={<LoginPage />} />
|
<SentryRoute path="/login" element={<LoginPage />} />
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export const AppBar: FC<Props> = ({ children }) => {
|
|||||||
style={{ display: hidden ? "none" : "block" }}
|
style={{ display: hidden ? "none" : "block" }}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
>
|
>
|
||||||
<Header>
|
<Header
|
||||||
|
// App bar is mainly seen in the call view, which has its own
|
||||||
|
// 'reconnecting' toast
|
||||||
|
disconnectedBanner={false}
|
||||||
|
>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<Tooltip label={t("common.back")}>
|
<Tooltip label={t("common.back")}>
|
||||||
<IconButton onClick={onBackClick}>
|
<IconButton onClick={onBackClick}>
|
||||||
|
|||||||
@@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react";
|
|||||||
import { Avatar, Size } from "./Avatar";
|
import { Avatar, Size } from "./Avatar";
|
||||||
import { EncryptionLock } from "./room/EncryptionLock";
|
import { EncryptionLock } from "./room/EncryptionLock";
|
||||||
import { useMediaQuery } from "./useMediaQuery";
|
import { useMediaQuery } from "./useMediaQuery";
|
||||||
|
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||||
|
|
||||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||||
ref?: Ref<HTMLElement>;
|
ref?: Ref<HTMLElement>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Whether the header should display an informational banner whenever the
|
||||||
|
* client is disconnected from the homeserver.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
disconnectedBanner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: FC<HeaderProps> = ({
|
export const Header: FC<HeaderProps> = ({
|
||||||
ref,
|
ref,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
disconnectedBanner = true,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header
|
<>
|
||||||
ref={ref}
|
<header
|
||||||
className={classNames(styles.header, className)}
|
ref={ref}
|
||||||
{...rest}
|
className={classNames(styles.header, className)}
|
||||||
>
|
{...rest}
|
||||||
{children}
|
>
|
||||||
</header>
|
{children}
|
||||||
|
</header>
|
||||||
|
{disconnectedBanner && <DisconnectedBanner />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ interface Props {
|
|||||||
* A supporting icon to display within the toast.
|
* A supporting icon to display within the toast.
|
||||||
*/
|
*/
|
||||||
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
Icon?: ComponentType<SVGAttributes<SVGElement>>;
|
||||||
|
/**
|
||||||
|
* Whether the toast should be portaled into the root of the document (rather
|
||||||
|
* than rendered in-place within the component tree).
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
|
|||||||
autoDismiss,
|
autoDismiss,
|
||||||
children,
|
children,
|
||||||
Icon,
|
Icon,
|
||||||
|
portal = true,
|
||||||
}) => {
|
}) => {
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [open, autoDismiss, onDismiss]);
|
}, [open, autoDismiss, onDismiss]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<DialogOverlay
|
||||||
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||||
|
/>
|
||||||
|
<DialogContent aria-describedby={undefined} asChild>
|
||||||
|
<DialogClose
|
||||||
|
className={classNames(
|
||||||
|
overlayStyles.overlay,
|
||||||
|
overlayStyles.animate,
|
||||||
|
styles.toast,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogTitle asChild>
|
||||||
|
<Text as="h3" size="sm" weight="semibold">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</DialogTitle>
|
||||||
|
{Icon && <Icon width={20} height={20} aria-hidden />}
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||||
<DialogOverlay
|
|
||||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
|
||||||
/>
|
|
||||||
<DialogContent aria-describedby={undefined} asChild>
|
|
||||||
<DialogClose
|
|
||||||
className={classNames(
|
|
||||||
overlayStyles.overlay,
|
|
||||||
overlayStyles.animate,
|
|
||||||
styles.toast,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogTitle asChild>
|
|
||||||
<Text as="h3" size="sm" weight="semibold">
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</DialogTitle>
|
|
||||||
{Icon && <Icon width={20} height={20} aria-hidden />}
|
|
||||||
</DialogClose>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
aliceRtcMember,
|
aliceRtcMember,
|
||||||
bobRtcMember,
|
bobRtcMember,
|
||||||
local,
|
local,
|
||||||
|
localRtcMember,
|
||||||
} from "../utils/test-fixtures";
|
} from "../utils/test-fixtures";
|
||||||
|
|
||||||
vitest.mock("../useAudioContext");
|
vitest.mock("../useAudioContext");
|
||||||
@@ -66,7 +67,7 @@ beforeEach(() => {
|
|||||||
* a noise every time.
|
* a noise every time.
|
||||||
*/
|
*/
|
||||||
test("plays one sound when entering a call", () => {
|
test("plays one sound when entering a call", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
@@ -74,56 +75,58 @@ test("plays one sound when entering a call", () => {
|
|||||||
|
|
||||||
// Joining a call usually means remote participants are added later.
|
// Joining a call usually means remote participants are added later.
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
expect(playSound).toHaveBeenCalledOnce();
|
expect(playSound).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user joins", () => {
|
test("plays a sound when a user joins", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||||
});
|
});
|
||||||
// Play a sound when joining a call.
|
// Play a sound when joining a call.
|
||||||
expect(playSound).toBeCalledWith("join");
|
expect(playSound).toBeCalledWith("join");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user leaves", () => {
|
test("plays a sound when a user leaves", () => {
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||||
local,
|
local,
|
||||||
alice,
|
alice,
|
||||||
]);
|
]);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next([]);
|
rtcMemberships$.next([localRtcMember]);
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("left");
|
expect(playSound).toBeCalledWith("left");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||||
const mockRtcMemberships: CallMembership[] = [];
|
const mockRtcMemberships: CallMembership[] = [localRtcMember];
|
||||||
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
||||||
mockRtcMemberships.push(
|
mockRtcMemberships.push(
|
||||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||||
[local, alice],
|
[local, alice],
|
||||||
mockRtcMemberships,
|
mockRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
expect(playSound).not.toBeCalled();
|
expect(playSound).not.toBeCalled();
|
||||||
|
// Remove the last membership in the array to test the leaving sound
|
||||||
|
// (The array has length MAX_PARTICIPANT_COUNT_FOR_SOUND + 1)
|
||||||
act(() => {
|
act(() => {
|
||||||
remoteRtcMemberships$.next(
|
rtcMemberships$.next(
|
||||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("left");
|
expect(playSound).toBeCalledWith("left");
|
||||||
|
|||||||
@@ -137,11 +137,9 @@ function createGroupCallView(
|
|||||||
getJoinRule: () => JoinRule.Invite,
|
getJoinRule: () => JoinRule.Invite,
|
||||||
} as Partial<RoomState> as RoomState,
|
} as Partial<RoomState> as RoomState,
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||||
room,
|
constant([localRtcMember]),
|
||||||
localRtcMember,
|
);
|
||||||
[],
|
|
||||||
).withMemberships(constant([]));
|
|
||||||
rtcSession.joined = joined;
|
rtcSession.joined = joined;
|
||||||
const muteState = {
|
const muteState = {
|
||||||
audio: { enabled: false },
|
audio: { enabled: false },
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
client={client}
|
client={client}
|
||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
rtcSession={rtcSession as MatrixRTCSession}
|
rtcSession={rtcSession as MatrixRTCSession}
|
||||||
|
matrixRoom={room}
|
||||||
participantCount={participantCount}
|
participantCount={participantCount}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
header={header}
|
header={header}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function createInCallView(): RenderResult & {
|
|||||||
kind: E2eeType.NONE,
|
kind: E2eeType.NONE,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
matrixRoom={room}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
participantCount={0}
|
participantCount={0}
|
||||||
onLeave={function (): void {
|
onLeave={function (): void {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||||
import { ConnectionState, type Room } from "livekit-client";
|
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
@@ -111,6 +111,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
|
|||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
import { Toast } from "../Toast.tsx";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -164,6 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
|
props.matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
{
|
{
|
||||||
@@ -182,6 +184,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
|
props.matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
@@ -210,7 +213,8 @@ export interface InCallViewProps {
|
|||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
livekitRoom: Room;
|
matrixRoom: MatrixRoom;
|
||||||
|
livekitRoom: LivekitRoom;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
participantCount: number;
|
participantCount: number;
|
||||||
/** Function to call when the user explicitly ends the call */
|
/** Function to call when the user explicitly ends the call */
|
||||||
@@ -226,6 +230,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
matrixInfo,
|
matrixInfo,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
|
matrixRoom,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
muteStates,
|
muteStates,
|
||||||
participantCount,
|
participantCount,
|
||||||
@@ -270,7 +275,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const [useExperimentalToDeviceTransport] = useSetting(
|
const [useExperimentalToDeviceTransport] = useSetting(
|
||||||
useExperimentalToDeviceTransportSetting,
|
useExperimentalToDeviceTransportSetting,
|
||||||
);
|
);
|
||||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
|
|
||||||
const showToDeviceEncryption = useMemo(
|
const showToDeviceEncryption = useMemo(
|
||||||
@@ -307,6 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
const windowMode = useBehavior(vm.windowMode$);
|
const windowMode = useBehavior(vm.windowMode$);
|
||||||
const layout = useBehavior(vm.layout$);
|
const layout = useBehavior(vm.layout$);
|
||||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||||
@@ -499,7 +505,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
break;
|
break;
|
||||||
case "standard":
|
case "standard":
|
||||||
header = (
|
header = (
|
||||||
<Header className={styles.header} ref={headerRef}>
|
<Header
|
||||||
|
className={styles.header}
|
||||||
|
ref={headerRef}
|
||||||
|
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||||
|
>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<RoomHeaderInfo
|
<RoomHeaderInfo
|
||||||
id={matrixInfo.roomId}
|
id={matrixInfo.roomId}
|
||||||
@@ -639,7 +649,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||||
rtcSession.room.roomId,
|
matrixRoom.roomId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleScreensharing = useCallback(() => {
|
const toggleScreensharing = useCallback(() => {
|
||||||
@@ -750,6 +760,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The reconnecting toast cannot be dismissed
|
||||||
|
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.inRoom}
|
className={styles.inRoom}
|
||||||
@@ -777,8 +790,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
|
<Toast
|
||||||
|
onDismiss={onDismissReconnectingToast}
|
||||||
|
open={reconnecting}
|
||||||
|
portal={false}
|
||||||
|
>
|
||||||
|
{t("common.reconnecting")}
|
||||||
|
</Toast>
|
||||||
<EarpieceOverlay
|
<EarpieceOverlay
|
||||||
show={earpieceMode}
|
show={earpieceMode && !reconnecting}
|
||||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||||
/>
|
/>
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
@@ -788,7 +808,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
client={client}
|
client={client}
|
||||||
roomId={rtcSession.room.roomId}
|
roomId={matrixRoom.roomId}
|
||||||
open={settingsModalOpen}
|
open={settingsModalOpen}
|
||||||
onDismiss={closeSettings}
|
onDismiss={closeSettings}
|
||||||
tab={settingsTab}
|
tab={settingsTab}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«r5»"
|
aria-labelledby="«r8»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -279,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-labelledby="«ra»"
|
aria-labelledby="«rd»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -301,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rf»"
|
aria-labelledby="«ri»"
|
||||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -322,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="«rk»"
|
aria-labelledby="«rn»"
|
||||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -348,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
class="toggle layout"
|
class="toggle layout"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«rp»"
|
aria-labelledby="«rs»"
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
@@ -366,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="«ru»"
|
aria-labelledby="«r11»"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { SyncState, type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
|
type LocalTrackPublication,
|
||||||
type Participant,
|
type Participant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
MockRTCSession,
|
MockRTCSession,
|
||||||
mockMediaDevices,
|
mockMediaDevices,
|
||||||
|
mockEmitter,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import {
|
import {
|
||||||
ECAddonConnectionState,
|
ECAddonConnectionState,
|
||||||
@@ -94,6 +96,11 @@ vi.mock("rxjs", async (importOriginal) => ({
|
|||||||
|
|
||||||
vi.mock("@livekit/components-core");
|
vi.mock("@livekit/components-core");
|
||||||
|
|
||||||
|
const yesNo = {
|
||||||
|
y: true,
|
||||||
|
n: false,
|
||||||
|
};
|
||||||
|
|
||||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||||
|
|
||||||
const carol = local;
|
const carol = local;
|
||||||
@@ -234,6 +241,7 @@ function withCallViewModel(
|
|||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
continuation: (
|
continuation: (
|
||||||
vm: CallViewModel,
|
vm: CallViewModel,
|
||||||
|
rtcSession: MockRTCSession,
|
||||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||||
) => void,
|
) => void,
|
||||||
options: CallViewModelOptions = {
|
options: CallViewModelOptions = {
|
||||||
@@ -243,16 +251,14 @@ function withCallViewModel(
|
|||||||
): void {
|
): void {
|
||||||
const room = mockMatrixRoom({
|
const room = mockMatrixRoom({
|
||||||
client: {
|
client: {
|
||||||
|
...mockEmitter(),
|
||||||
getUserId: () => localRtcMember.sender,
|
getUserId: () => localRtcMember.sender,
|
||||||
getDeviceId: () => localRtcMember.deviceId,
|
getDeviceId: () => localRtcMember.deviceId,
|
||||||
|
getSyncState: () => SyncState.Syncing,
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
} as Partial<MatrixClient> as MatrixClient,
|
||||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
|
||||||
room,
|
|
||||||
localRtcMember,
|
|
||||||
[],
|
|
||||||
).withMemberships(rtcMembers$);
|
|
||||||
const participantsSpy = vi
|
const participantsSpy = vi
|
||||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
.mockReturnValue(remoteParticipants$);
|
.mockReturnValue(remoteParticipants$);
|
||||||
@@ -279,7 +285,7 @@ function withCallViewModel(
|
|||||||
.spyOn(ComponentsCore, "roomEventSelector")
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
.mockImplementation((room, eventType) => of());
|
.mockImplementation((room, eventType) => of());
|
||||||
|
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const livekitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants$ },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
@@ -288,7 +294,8 @@ function withCallViewModel(
|
|||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
liveKitRoom,
|
room,
|
||||||
|
livekitRoom,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
options,
|
options,
|
||||||
connectionState$,
|
connectionState$,
|
||||||
@@ -304,7 +311,7 @@ function withCallViewModel(
|
|||||||
roomEventSelectorSpy!.mockRestore();
|
roomEventSelectorSpy!.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
continuation(vm, { raisedHands$: raisedHands$ });
|
continuation(vm, rtcSession, { raisedHands$: raisedHands$ });
|
||||||
}
|
}
|
||||||
|
|
||||||
test("participants are retained during a focus switch", () => {
|
test("participants are retained during a focus switch", () => {
|
||||||
@@ -321,7 +328,7 @@ test("participants are retained during a focus switch", () => {
|
|||||||
a: [aliceParticipant, bobParticipant],
|
a: [aliceParticipant, bobParticipant],
|
||||||
b: [],
|
b: [],
|
||||||
}),
|
}),
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
behavior(connectionInputMarbles, {
|
behavior(connectionInputMarbles, {
|
||||||
c: ConnectionState.Connected,
|
c: ConnectionState.Connected,
|
||||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||||
@@ -364,7 +371,7 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
c: [aliceSharingScreen, bobSharingScreen],
|
c: [aliceSharingScreen, bobSharingScreen],
|
||||||
d: [aliceParticipant, bobSharingScreen],
|
d: [aliceParticipant, bobSharingScreen],
|
||||||
}),
|
}),
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
@@ -419,10 +426,7 @@ test("screen sharing activates spotlight layout", () => {
|
|||||||
);
|
);
|
||||||
expectObservable(vm.showSpeakingIndicators$).toBe(
|
expectObservable(vm.showSpeakingIndicators$).toBe(
|
||||||
expectedShowSpeakingMarbles,
|
expectedShowSpeakingMarbles,
|
||||||
{
|
yesNo,
|
||||||
y: true,
|
|
||||||
n: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -444,21 +448,12 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||||
aliceParticipant,
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
],
|
|
||||||
[
|
|
||||||
bobParticipant,
|
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
]),
|
]),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
@@ -511,17 +506,11 @@ test("participants adjust order when space becomes constrained", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
bobParticipant,
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
]),
|
]),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
@@ -570,21 +559,12 @@ test("spotlight speakers swap places", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||||
aliceParticipant,
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
],
|
|
||||||
[
|
|
||||||
bobParticipant,
|
|
||||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
daveParticipant,
|
|
||||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
|
||||||
],
|
|
||||||
]),
|
]),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
(vm) => {
|
(vm) => {
|
||||||
@@ -629,7 +609,7 @@ test("layout enters picture-in-picture mode when requested", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
constant([aliceParticipant, bobParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
@@ -671,7 +651,7 @@ test("spotlight remembers whether it's expanded", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
constant([aliceParticipant, bobParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
@@ -735,11 +715,11 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
|||||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||||
}),
|
}),
|
||||||
behavior(scenarioInputMarbles, {
|
behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [],
|
b: [localRtcMember],
|
||||||
c: [aliceRtcMember],
|
c: [localRtcMember, aliceRtcMember],
|
||||||
d: [aliceRtcMember, daveRtcMember],
|
d: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
e: [aliceRtcMember, daveRtcMember],
|
e: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -785,7 +765,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
|||||||
b: [aliceParticipant],
|
b: [aliceParticipant],
|
||||||
c: [aliceParticipant, bobParticipant],
|
c: [aliceParticipant, bobParticipant],
|
||||||
}),
|
}),
|
||||||
constant([]), // No one joins the MatrixRTC session
|
constant([localRtcMember]), // No one else joins the MatrixRTC session
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
@@ -829,10 +809,10 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
|||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
constant([]),
|
||||||
behavior(scenarioInputMarbles, {
|
behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [aliceRtcMember],
|
b: [localRtcMember, aliceRtcMember],
|
||||||
c: [aliceRtcMember, daveRtcMember],
|
c: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||||
d: [daveRtcMember],
|
d: [localRtcMember, daveRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -877,11 +857,16 @@ test("should disambiguate users with the same displayname", () => {
|
|||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
constant([]),
|
||||||
behavior(scenarioInputMarbles, {
|
behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [aliceRtcMember],
|
b: [localRtcMember, aliceRtcMember],
|
||||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||||
d: [aliceRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
d: [
|
||||||
e: [aliceDoppelgangerRtcMember, bobRtcMember],
|
localRtcMember,
|
||||||
|
aliceRtcMember,
|
||||||
|
aliceDoppelgangerRtcMember,
|
||||||
|
bobRtcMember,
|
||||||
|
],
|
||||||
|
e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -927,8 +912,8 @@ test("should disambiguate users with invisible characters", () => {
|
|||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
constant([]),
|
||||||
behavior(scenarioInputMarbles, {
|
behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -960,8 +945,8 @@ test("should strip RTL characters from displayname", () => {
|
|||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
constant([]),
|
||||||
behavior(scenarioInputMarbles, {
|
behavior(scenarioInputMarbles, {
|
||||||
a: [],
|
a: [localRtcMember],
|
||||||
b: [daveRtcMember, daveRTLRtcMember],
|
b: [localRtcMember, daveRtcMember, daveRTLRtcMember],
|
||||||
}),
|
}),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -991,11 +976,11 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([aliceParticipant, bobParticipant]),
|
constant([aliceParticipant, bobParticipant]),
|
||||||
constant([aliceRtcMember, bobRtcMember]),
|
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
(vm, { raisedHands$ }) => {
|
(vm, _rtcSession, { raisedHands$ }) => {
|
||||||
schedule("ab", {
|
schedule("ab", {
|
||||||
a: () => {
|
a: () => {
|
||||||
// We imagine that only two tiles (the first two) will be visible on screen at a time
|
// We imagine that only two tiles (the first two) will be visible on screen at a time
|
||||||
@@ -1076,10 +1061,10 @@ function rtcMemberJoinLeave$(
|
|||||||
) => Observable<CallMembership[]>,
|
) => Observable<CallMembership[]>,
|
||||||
): Observable<CallMembership[]> {
|
): Observable<CallMembership[]> {
|
||||||
return hot("a-b-c-d", {
|
return hot("a-b-c-d", {
|
||||||
a: [], // Start empty
|
a: [localRtcMember], // Start empty
|
||||||
b: [aliceRtcMember], // Alice joins
|
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||||
c: [aliceRtcMember], // Alice still there
|
c: [localRtcMember, aliceRtcMember], // Alice still there
|
||||||
d: [], // Alice leaves
|
d: [localRtcMember], // Alice leaves
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,7 +1073,7 @@ test("allOthersLeft$ emits only when someone joined and then all others left", (
|
|||||||
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
scope.behavior(nooneEverThere$(hot), []),
|
||||||
scope.behavior(nooneEverThere$(hot), []),
|
constant([localRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
@@ -1236,7 +1221,7 @@ test("audio output changes when toggling earpiece mode", () => {
|
|||||||
|
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
constant([]),
|
constant([]),
|
||||||
constant([]),
|
constant([localRtcMember]),
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
new Map(),
|
new Map(),
|
||||||
devices,
|
devices,
|
||||||
@@ -1244,10 +1229,10 @@ test("audio output changes when toggling earpiece mode", () => {
|
|||||||
schedule(toggleInputMarbles, {
|
schedule(toggleInputMarbles, {
|
||||||
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
||||||
});
|
});
|
||||||
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
|
expectObservable(vm.earpieceMode$).toBe(
|
||||||
n: false,
|
expectedEarpieceModeMarbles,
|
||||||
y: true,
|
yesNo,
|
||||||
});
|
);
|
||||||
expectObservable(
|
expectObservable(
|
||||||
vm.audioOutputSwitcher$.pipe(
|
vm.audioOutputSwitcher$.pipe(
|
||||||
map((switcher) => switcher?.targetOutput),
|
map((switcher) => switcher?.targetOutput),
|
||||||
@@ -1257,3 +1242,64 @@ test("audio output changes when toggling earpiece mode", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("media tracks are paused while reconnecting to MatrixRTC", () => {
|
||||||
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
|
const trackRunning$ = new BehaviorSubject(true);
|
||||||
|
const originalPublications = localParticipant.trackPublications;
|
||||||
|
localParticipant.trackPublications = new Map([
|
||||||
|
[
|
||||||
|
"video",
|
||||||
|
{
|
||||||
|
track: new (class {
|
||||||
|
public get isUpstreamPaused(): boolean {
|
||||||
|
return !trackRunning$.value;
|
||||||
|
}
|
||||||
|
public async pauseUpstream(): Promise<void> {
|
||||||
|
trackRunning$.next(false);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
public async resumeUpstream(): Promise<void> {
|
||||||
|
trackRunning$.next(true);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
} as unknown as LocalTrackPublication,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
onTestFinished(() => {
|
||||||
|
localParticipant.trackPublications = originalPublications;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add marbles for sync state and membership status as well
|
||||||
|
const connectedMarbles = " yny";
|
||||||
|
const expectedReconnectingMarbles = "nyn";
|
||||||
|
const expectedTrackRunningMarbles = "yny";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
constant([]),
|
||||||
|
constant([localRtcMember]),
|
||||||
|
of(ConnectionState.Connected),
|
||||||
|
new Map(),
|
||||||
|
mockMediaDevices({}),
|
||||||
|
(vm, rtcSession) => {
|
||||||
|
schedule(connectedMarbles, {
|
||||||
|
y: () => {
|
||||||
|
rtcSession.probablyLeft = false;
|
||||||
|
},
|
||||||
|
n: () => {
|
||||||
|
rtcSession.probablyLeft = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectObservable(vm.reconnecting$).toBe(
|
||||||
|
expectedReconnectingMarbles,
|
||||||
|
yesNo,
|
||||||
|
);
|
||||||
|
expectObservable(trackRunning$).toBe(
|
||||||
|
expectedTrackRunningMarbles,
|
||||||
|
yesNo,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import {
|
|||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
|
ConnectionState,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
SyncState,
|
||||||
|
type Room as MatrixRoom,
|
||||||
|
type RoomMember,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
@@ -48,6 +55,8 @@ import {
|
|||||||
type CallMembership,
|
type CallMembership,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
|
MembershipManagerEvent,
|
||||||
|
Status,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
@@ -62,7 +71,12 @@ import {
|
|||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "./MediaViewModel";
|
} from "./MediaViewModel";
|
||||||
import { accumulate, finalizeValue } from "../utils/observable";
|
import {
|
||||||
|
accumulate,
|
||||||
|
and$,
|
||||||
|
finalizeValue,
|
||||||
|
pauseWhen,
|
||||||
|
} from "../utils/observable";
|
||||||
import { ObservableScope } from "./ObservableScope";
|
import { ObservableScope } from "./ObservableScope";
|
||||||
import {
|
import {
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
@@ -261,6 +275,7 @@ class UserMedia {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Observable<string>,
|
displayname$: Observable<string>,
|
||||||
handRaised$: Observable<Date | null>,
|
handRaised$: Observable<Date | null>,
|
||||||
reaction$: Observable<ReactionOption | null>,
|
reaction$: Observable<ReactionOption | null>,
|
||||||
@@ -288,6 +303,7 @@ class UserMedia {
|
|||||||
>,
|
>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
|
pretendToBeDisconnected$,
|
||||||
this.scope.behavior(displayname$),
|
this.scope.behavior(displayname$),
|
||||||
this.scope.behavior(handRaised$),
|
this.scope.behavior(handRaised$),
|
||||||
this.scope.behavior(reaction$),
|
this.scope.behavior(reaction$),
|
||||||
@@ -341,7 +357,8 @@ class ScreenShare {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
liveKitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayName$: Observable<string>,
|
displayName$: Observable<string>,
|
||||||
) {
|
) {
|
||||||
this.participant$ = new BehaviorSubject(participant);
|
this.participant$ = new BehaviorSubject(participant);
|
||||||
@@ -351,7 +368,8 @@ class ScreenShare {
|
|||||||
member,
|
member,
|
||||||
this.participant$.asObservable(),
|
this.participant$.asObservable(),
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
liveKitRoom,
|
livekitRoom,
|
||||||
|
pretendToBeDisconnected$,
|
||||||
this.scope.behavior(displayName$),
|
this.scope.behavior(displayName$),
|
||||||
participant.isLocal,
|
participant.isLocal,
|
||||||
);
|
);
|
||||||
@@ -367,7 +385,7 @@ type MediaItem = UserMedia | ScreenShare;
|
|||||||
|
|
||||||
function getRoomMemberFromRtcMember(
|
function getRoomMemberFromRtcMember(
|
||||||
rtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
room: Room,
|
room: MatrixRoom,
|
||||||
): { id: string; member: RoomMember | undefined } {
|
): { id: string; member: RoomMember | undefined } {
|
||||||
// WARN! This is not exactly the sender but the user defined in the state key.
|
// WARN! This is not exactly the sender but the user defined in the state key.
|
||||||
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
|
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
|
||||||
@@ -389,6 +407,79 @@ function getRoomMemberFromRtcMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
|
private readonly userId = this.matrixRoom.client.getUserId();
|
||||||
|
|
||||||
|
private readonly matrixConnected$ = this.scope.behavior(
|
||||||
|
// To consider ourselves connected to MatrixRTC, we check the following:
|
||||||
|
and$(
|
||||||
|
// The client is connected to the sync loop
|
||||||
|
(
|
||||||
|
fromEvent(this.matrixRoom.client, ClientEvent.Sync) as Observable<
|
||||||
|
[SyncState]
|
||||||
|
>
|
||||||
|
).pipe(
|
||||||
|
startWith([this.matrixRoom.client.getSyncState()]),
|
||||||
|
map(([state]) => state === SyncState.Syncing),
|
||||||
|
),
|
||||||
|
// Room state observed by session says we're connected
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MembershipManagerEvent.StatusChanged,
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => this.matrixRTCSession.membershipStatus === Status.Connected),
|
||||||
|
),
|
||||||
|
// Also watch out for warnings that we've likely hit a timeout and our
|
||||||
|
// delayed leave event is being sent (this condition is here because it
|
||||||
|
// provides an earlier warning than the sync loop timeout, and we wouldn't
|
||||||
|
// see the actual leave event until we reconnect to the sync loop)
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MembershipManagerEvent.ProbablyLeft,
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => this.matrixRTCSession.probablyLeft !== true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly connected$ = this.scope.behavior(
|
||||||
|
and$(
|
||||||
|
this.matrixConnected$,
|
||||||
|
this.livekitConnectionState$.pipe(
|
||||||
|
map((state) => state === ConnectionState.Connected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we should tell the user that we're reconnecting to the call.
|
||||||
|
*/
|
||||||
|
public readonly reconnecting$ = this.scope.behavior(
|
||||||
|
this.connected$.pipe(
|
||||||
|
// We are reconnecting if we previously had some successful initial
|
||||||
|
// connection but are now disconnected
|
||||||
|
scan(
|
||||||
|
({ connectedPreviously, reconnecting }, connectedNow) => ({
|
||||||
|
connectedPreviously: connectedPreviously || connectedNow,
|
||||||
|
reconnecting: connectedPreviously && !connectedNow,
|
||||||
|
}),
|
||||||
|
{ connectedPreviously: false, reconnecting: false },
|
||||||
|
),
|
||||||
|
map(({ reconnecting }) => reconnecting),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether various media/event sources should pretend to be disconnected from
|
||||||
|
* all network input, even if their connection still technically works.
|
||||||
|
*/
|
||||||
|
// We do this when the app is in the 'reconnecting' state, because it might be
|
||||||
|
// that the LiveKit connection is still functional while the homeserver is
|
||||||
|
// down, for example, and we want to avoid making people worry that the app is
|
||||||
|
// in a split-brained state.
|
||||||
|
private readonly pretendToBeDisconnected$ = this.reconnecting$;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raw list of RemoteParticipants as reported by LiveKit
|
* The raw list of RemoteParticipants as reported by LiveKit
|
||||||
*/
|
*/
|
||||||
@@ -403,7 +494,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
||||||
RemoteParticipant[][]
|
RemoteParticipant[][]
|
||||||
>(
|
>(
|
||||||
this.connectionState$.pipe(
|
this.livekitConnectionState$.pipe(
|
||||||
withLatestFrom(this.rawRemoteParticipants$),
|
withLatestFrom(this.rawRemoteParticipants$),
|
||||||
mergeMap(([s, ps]) => {
|
mergeMap(([s, ps]) => {
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
// Whenever we switch focuses, we should retain all the previous
|
||||||
@@ -416,7 +507,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
// Wait for time to pass and the connection state to have changed
|
// Wait for time to pass and the connection state to have changed
|
||||||
forkJoin([
|
forkJoin([
|
||||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||||
this.connectionState$.pipe(
|
this.livekitConnectionState$.pipe(
|
||||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||||
take(1),
|
take(1),
|
||||||
),
|
),
|
||||||
@@ -440,74 +531,80 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The RemoteParticipants including those that are being "held" on the screen
|
* The RemoteParticipants including those that are being "held" on the screen
|
||||||
*/
|
*/
|
||||||
private readonly remoteParticipants$ = this.scope.behavior<
|
private readonly remoteParticipants$ = this.scope
|
||||||
RemoteParticipant[]
|
.behavior<RemoteParticipant[]>(
|
||||||
>(
|
combineLatest(
|
||||||
combineLatest(
|
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
(raw, holds) => {
|
||||||
(raw, holds) => {
|
const result = [...raw];
|
||||||
const result = [...raw];
|
const resultIds = new Set(result.map((p) => p.identity));
|
||||||
const resultIds = new Set(result.map((p) => p.identity));
|
|
||||||
|
|
||||||
// Incorporate the held participants into the list
|
// Incorporate the held participants into the list
|
||||||
for (const hold of holds) {
|
for (const hold of holds) {
|
||||||
for (const p of hold) {
|
for (const p of hold) {
|
||||||
if (!resultIds.has(p.identity)) {
|
if (!resultIds.has(p.identity)) {
|
||||||
result.push(p);
|
result.push(p);
|
||||||
resultIds.add(p.identity);
|
resultIds.add(p.identity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||||
private readonly memberships$: Observable<CallMembership[]> = merge(
|
|
||||||
// Handle call membership changes.
|
|
||||||
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged),
|
|
||||||
// Handle room membership changes (and displayname updates)
|
|
||||||
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
|
|
||||||
).pipe(
|
|
||||||
startWith(this.matrixRTCSession.memberships),
|
|
||||||
map(() => {
|
|
||||||
return this.matrixRTCSession.memberships;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displaynames for each member of the call. This will disambiguate
|
* Displaynames for each member of the call. This will disambiguate
|
||||||
* any displaynames that clashes with another member. Only members
|
* any displaynames that clashes with another member. Only members
|
||||||
* joined to the call are considered here.
|
* joined to the call are considered here.
|
||||||
*/
|
*/
|
||||||
public readonly memberDisplaynames$ = this.memberships$.pipe(
|
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
||||||
map((memberships) => {
|
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
||||||
const displaynameMap = new Map<string, string>();
|
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||||
const { room } = this.matrixRTCSession;
|
public readonly memberDisplaynames$ = this.scope.behavior(
|
||||||
|
merge(
|
||||||
|
// Handle call membership changes.
|
||||||
|
fromEvent(
|
||||||
|
this.matrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
|
),
|
||||||
|
// Handle room membership changes (and displayname updates)
|
||||||
|
fromEvent(this.matrixRoom, RoomStateEvent.Members),
|
||||||
|
).pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => {
|
||||||
|
const memberships = this.matrixRTCSession.memberships;
|
||||||
|
const displaynameMap = new Map<string, string>();
|
||||||
|
const room = this.matrixRoom;
|
||||||
|
|
||||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
for (const rtcMember of memberships) {
|
for (const rtcMember of memberships) {
|
||||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
logger.error("Could not find member for media id:", matrixIdentifier);
|
logger.error(
|
||||||
continue;
|
"Could not find member for media id:",
|
||||||
|
matrixIdentifier,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const disambiguate = shouldDisambiguate(member, memberships, room);
|
||||||
|
displaynameMap.set(
|
||||||
|
matrixIdentifier,
|
||||||
|
calculateDisplayName(member, disambiguate),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const disambiguate = shouldDisambiguate(member, memberships, room);
|
return displaynameMap;
|
||||||
displaynameMap.set(
|
}),
|
||||||
matrixIdentifier,
|
pauseWhen(this.pretendToBeDisconnected$),
|
||||||
calculateDisplayName(member, disambiguate),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
return displaynameMap;
|
|
||||||
}),
|
|
||||||
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
|
|
||||||
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
|
||||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
|
public readonly handsRaised$ = this.scope.behavior(
|
||||||
|
this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)),
|
||||||
|
);
|
||||||
|
|
||||||
public readonly reactions$ = this.scope.behavior(
|
public readonly reactions$ = this.scope.behavior(
|
||||||
this.reactionsSubject$.pipe(
|
this.reactionsSubject$.pipe(
|
||||||
@@ -519,6 +616,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
pauseWhen(this.pretendToBeDisconnected$),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -536,7 +634,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
fromEvent(
|
fromEvent(
|
||||||
this.matrixRTCSession,
|
this.matrixRTCSession,
|
||||||
MatrixRTCSessionEvent.MembershipsChanged,
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
).pipe(startWith(null)),
|
).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)),
|
||||||
showNonMemberTiles.value$,
|
showNonMemberTiles.value$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
scan(
|
scan(
|
||||||
@@ -552,7 +650,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
) => {
|
) => {
|
||||||
const newItems = new Map(
|
const newItems = new Map(
|
||||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||||
const room = this.matrixRTCSession.room;
|
const room = this.matrixRoom;
|
||||||
// m.rtc.members are the basis for calculating what is visible in the call
|
// m.rtc.members are the basis for calculating what is visible in the call
|
||||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||||
const { member, id: livekitParticipantId } =
|
const { member, id: livekitParticipantId } =
|
||||||
@@ -604,6 +702,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.mediaDevices,
|
this.mediaDevices,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
),
|
),
|
||||||
@@ -627,6 +726,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
),
|
),
|
||||||
@@ -669,6 +769,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.mediaDevices,
|
this.mediaDevices,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map(
|
map(
|
||||||
(m) => m.get(participant.identity) ?? "[👻]",
|
(m) => m.get(participant.identity) ?? "[👻]",
|
||||||
@@ -772,12 +873,13 @@ export class CallViewModel extends ViewModel {
|
|||||||
|
|
||||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||||
map(({ userIds, leftUserIds }) => {
|
map(({ userIds, leftUserIds }) => {
|
||||||
const userId = this.matrixRTCSession.room.client.getUserId();
|
if (!this.userId) {
|
||||||
if (!userId) {
|
logger.warn("Could not access user ID to compute allOthersLeft");
|
||||||
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
|
return (
|
||||||
|
userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
startWith(false),
|
startWith(false),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
@@ -889,7 +991,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
map((speaker) => (speaker ? [speaker] : [])),
|
map((speaker) => (speaker ? [speaker] : [])),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(shallowEquals),
|
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1474,10 +1576,11 @@ export class CallViewModel extends ViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||||
private readonly matrixRTCSession: MatrixRTCSession,
|
private readonly matrixRTCSession: MatrixRTCSession,
|
||||||
|
private readonly matrixRoom: MatrixRoom,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
private readonly options: CallViewModelOptions,
|
private readonly options: CallViewModelOptions,
|
||||||
private readonly connectionState$: Observable<ECConnectionState>,
|
private readonly livekitConnectionState$: Observable<ECConnectionState>,
|
||||||
private readonly handsRaisedSubject$: Observable<
|
private readonly handsRaisedSubject$: Observable<
|
||||||
Record<string, RaisedHandInfo>
|
Record<string, RaisedHandInfo>
|
||||||
>,
|
>,
|
||||||
@@ -1486,5 +1589,49 @@ export class CallViewModel extends ViewModel {
|
|||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
// Pause upstream of all local media tracks when we're disconnected from
|
||||||
|
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||||
|
// 'reconnecting' and yet still be transmitting your media to others.
|
||||||
|
// We use matrixConnected$ rather than reconnecting$ because we want to
|
||||||
|
// pause tracks during the initial joining sequence too until we're sure
|
||||||
|
// that our own media is displayed on screen.
|
||||||
|
this.matrixConnected$.pipe(this.scope.bind()).subscribe((connected) => {
|
||||||
|
const publications =
|
||||||
|
this.livekitRoom.localParticipant.trackPublications.values();
|
||||||
|
if (connected) {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === true) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(
|
||||||
|
`Resumming ${kind} track (MatrixRTC connection present)`,
|
||||||
|
);
|
||||||
|
p.track
|
||||||
|
.resumeUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === false) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.log(`Pausing ${kind} track (no MatrixRTC connection)`);
|
||||||
|
p.track
|
||||||
|
.pauseUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to pause ${kind} track after MatrixRTC connection loss`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,10 +361,7 @@ export type UserMediaViewModel =
|
|||||||
* Some participant's user media.
|
* Some participant's user media.
|
||||||
*/
|
*/
|
||||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||||
/**
|
private readonly _speaking$ = this.scope.behavior(
|
||||||
* Whether the participant is speaking.
|
|
||||||
*/
|
|
||||||
public readonly speaking$ = this.scope.behavior(
|
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) =>
|
switchMap((p) =>
|
||||||
p
|
p
|
||||||
@@ -376,15 +373,27 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* Whether the participant is speaking.
|
||||||
|
*/
|
||||||
|
// Getter backed by a private field so that subclasses can override it
|
||||||
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
return this._speaking$;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||||
*/
|
*/
|
||||||
public readonly audioEnabled$: Behavior<boolean>;
|
public readonly audioEnabled$: Behavior<boolean>;
|
||||||
|
|
||||||
|
private readonly _videoEnabled$: Behavior<boolean>;
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending video.
|
* Whether this participant is sending video.
|
||||||
*/
|
*/
|
||||||
public readonly videoEnabled$: Behavior<boolean>;
|
// Getter backed by a private field so that subclasses can override it
|
||||||
|
public get videoEnabled$(): Behavior<boolean> {
|
||||||
|
return this._videoEnabled$;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||||
/**
|
/**
|
||||||
@@ -421,7 +430,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
this.audioEnabled$ = this.scope.behavior(
|
this.audioEnabled$ = this.scope.behavior(
|
||||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||||
);
|
);
|
||||||
this.videoEnabled$ = this.scope.behavior(
|
this._videoEnabled$ = this.scope.behavior(
|
||||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -572,6 +581,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* A remote participant's user media.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
// This private field is used to override the value from the superclass
|
||||||
|
private __speaking$: Behavior<boolean>;
|
||||||
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
return this.__speaking$;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly locallyMutedToggle$ = new Subject<void>();
|
private readonly locallyMutedToggle$ = new Subject<void>();
|
||||||
private readonly localVolumeAdjustment$ = new Subject<number>();
|
private readonly localVolumeAdjustment$ = new Subject<number>();
|
||||||
private readonly localVolumeCommit$ = new Subject<void>();
|
private readonly localVolumeCommit$ = new Subject<void>();
|
||||||
@@ -611,6 +626,12 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This private field is used to override the value from the superclass
|
||||||
|
private __videoEnabled$: Behavior<boolean>;
|
||||||
|
public get videoEnabled$(): Behavior<boolean> {
|
||||||
|
return this.__videoEnabled$;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant's audio is disabled.
|
* Whether this participant's audio is disabled.
|
||||||
*/
|
*/
|
||||||
@@ -624,6 +645,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
participant$: Observable<RemoteParticipant | undefined>,
|
participant$: Observable<RemoteParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayname$: Behavior<string>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
@@ -639,11 +661,33 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
reaction$,
|
reaction$,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.__speaking$ = this.scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : super.speaking$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.__videoEnabled$ = this.scope.behavior(
|
||||||
|
pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) =>
|
||||||
|
disconnected ? of(false) : super.videoEnabled$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the local volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
combineLatest([
|
combineLatest([
|
||||||
participant$,
|
participant$,
|
||||||
this.localVolume$.pipe(this.scope.bind()),
|
// The local volume, taking into account whether we're supposed to pretend
|
||||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
// that the audio stream is disconnected (since we don't necessarily want
|
||||||
|
// that to modify the UI state).
|
||||||
|
this.pretendToBeDisconnected$.pipe(
|
||||||
|
switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)),
|
||||||
|
this.scope.bind(),
|
||||||
|
),
|
||||||
|
]).subscribe(([p, volume]) => p?.setVolume(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleLocallyMuted(): void {
|
public toggleLocallyMuted(): void {
|
||||||
@@ -683,12 +727,20 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* Some participant's screen share media.
|
* Some participant's screen share media.
|
||||||
*/
|
*/
|
||||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||||
|
/**
|
||||||
|
* Whether this screen share's video should be displayed.
|
||||||
|
*/
|
||||||
|
public readonly videoEnabled$ = this.scope.behavior(
|
||||||
|
this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayname$: Behavior<string>,
|
||||||
public readonly local: boolean,
|
public readonly local: boolean,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface SpotlightItemBaseProps {
|
|||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
video: TrackReferenceOrPlaceholder | undefined;
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
|
videoEnabled: boolean;
|
||||||
member: RoomMember | undefined;
|
member: RoomMember | undefined;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
encryptionStatus: EncryptionStatus;
|
encryptionStatus: EncryptionStatus;
|
||||||
@@ -63,7 +64,6 @@ interface SpotlightItemBaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
videoEnabled: boolean;
|
|
||||||
videoFit: "contain" | "cover";
|
videoFit: "contain" | "cover";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +90,10 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
|
||||||
const cropVideo = useBehavior(vm.cropVideo$);
|
const cropVideo = useBehavior(vm.cropVideo$);
|
||||||
|
|
||||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||||
RefAttributes<HTMLDivElement> = {
|
RefAttributes<HTMLDivElement> = {
|
||||||
videoEnabled,
|
|
||||||
videoFit: cropVideo ? "cover" : "contain",
|
videoFit: cropVideo ? "cover" : "contain",
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
@@ -135,6 +133,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const displayName = useBehavior(vm.displayName$);
|
const displayName = useBehavior(vm.displayName$);
|
||||||
const video = useBehavior(vm.video$);
|
const video = useBehavior(vm.video$);
|
||||||
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||||
|
|
||||||
@@ -160,6 +159,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
video,
|
video,
|
||||||
|
videoEnabled,
|
||||||
member: vm.member,
|
member: vm.member,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -169,7 +169,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return vm instanceof ScreenShareViewModel ? (
|
return vm instanceof ScreenShareViewModel ? (
|
||||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
<MediaView videoFit="contain" mirror={false} {...baseProps} />
|
||||||
) : (
|
) : (
|
||||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
|
|||||||
24
src/utils/observable.test.ts
Normal file
24
src/utils/observable.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
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 { test } from "vitest";
|
||||||
|
|
||||||
|
import { withTestScheduler } from "./test";
|
||||||
|
import { pauseWhen } from "./observable";
|
||||||
|
|
||||||
|
test("pauseWhen", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
const inputMarbles = " abcdefgh-i-jk-";
|
||||||
|
const pauseMarbles = " n-y--n-yn-y--n";
|
||||||
|
const outputMarbles = "abc--fgh-i---k";
|
||||||
|
expectObservable(
|
||||||
|
behavior(inputMarbles).pipe(
|
||||||
|
pauseWhen(behavior(pauseMarbles, { y: true, n: false })),
|
||||||
|
),
|
||||||
|
).toBe(outputMarbles);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,16 +7,23 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type Observable,
|
type Observable,
|
||||||
|
audit,
|
||||||
|
combineLatest,
|
||||||
concat,
|
concat,
|
||||||
defer,
|
defer,
|
||||||
|
filter,
|
||||||
finalize,
|
finalize,
|
||||||
map,
|
map,
|
||||||
|
of,
|
||||||
scan,
|
scan,
|
||||||
startWith,
|
startWith,
|
||||||
takeWhile,
|
takeWhile,
|
||||||
tap,
|
tap,
|
||||||
|
withLatestFrom,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,3 +93,27 @@ export function getValue<T>(state$: Observable<T>): T {
|
|||||||
if (value === nothing) throw new Error("Not a state Observable");
|
if (value === nothing) throw new Error("Not a state Observable");
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Observable that has a value of true whenever all its inputs are
|
||||||
|
* true.
|
||||||
|
*/
|
||||||
|
export function and$(...inputs: Observable<boolean>[]): Observable<boolean> {
|
||||||
|
return combineLatest(inputs, (...flags) => flags.every((flag) => flag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that pauses all changes in the input value whenever a Behavior
|
||||||
|
* is true. When the Behavior returns to being false, the most recently
|
||||||
|
* suppressed change is emitted as the most recent value.
|
||||||
|
*/
|
||||||
|
export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
||||||
|
return (value$: Observable<T>): Observable<T> =>
|
||||||
|
value$.pipe(
|
||||||
|
withLatestFrom(pause$),
|
||||||
|
audit(([, pause]) =>
|
||||||
|
pause ? pause$.pipe(filter((pause) => !pause)) : of(null),
|
||||||
|
),
|
||||||
|
map(([value]) => value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ import { BehaviorSubject, of } from "rxjs";
|
|||||||
import { vitest } from "vitest";
|
import { vitest } from "vitest";
|
||||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
|
import {
|
||||||
|
type RoomMember,
|
||||||
|
type MatrixClient,
|
||||||
|
type Room,
|
||||||
|
SyncState,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
|
||||||
import type { RoomMember, MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { CallViewModel } from "../state/CallViewModel";
|
import { CallViewModel } from "../state/CallViewModel";
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +39,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
|||||||
|
|
||||||
export function getBasicRTCSession(
|
export function getBasicRTCSession(
|
||||||
members: RoomMember[],
|
members: RoomMember[],
|
||||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||||
): {
|
): {
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
matrixRoom: Room;
|
||||||
|
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||||
} {
|
} {
|
||||||
const matrixRoomId = "!myRoomId:example.com";
|
const matrixRoomId = "!myRoomId:example.com";
|
||||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||||
@@ -51,6 +57,7 @@ export function getBasicRTCSession(
|
|||||||
client: {
|
client: {
|
||||||
getUserId: () => localRtcMember.sender,
|
getUserId: () => localRtcMember.sender,
|
||||||
getDeviceId: () => localRtcMember.deviceId,
|
getDeviceId: () => localRtcMember.deviceId,
|
||||||
|
getSyncState: () => SyncState.Syncing,
|
||||||
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||||
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||||
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
||||||
@@ -91,52 +98,53 @@ export function getBasicRTCSession(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||||
initialRemoteRtcMemberships,
|
initialRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
|
||||||
matrixRoom,
|
rtcMemberships$,
|
||||||
localRtcMember,
|
);
|
||||||
).withMemberships(remoteRtcMemberships$);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rtcSession,
|
rtcSession,
|
||||||
remoteRtcMemberships$,
|
matrixRoom,
|
||||||
|
rtcMemberships$,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a basic CallViewModel to test components that make use of it.
|
* Construct a basic CallViewModel to test components that make use of it.
|
||||||
* @param members
|
* @param members
|
||||||
* @param initialRemoteRtcMemberships
|
* @param initialRtcMemberships
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function getBasicCallViewModelEnvironment(
|
export function getBasicCallViewModelEnvironment(
|
||||||
members: RoomMember[],
|
members: RoomMember[],
|
||||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||||
): {
|
): {
|
||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||||
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||||
} {
|
} {
|
||||||
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
|
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
|
||||||
members,
|
members,
|
||||||
initialRemoteRtcMemberships,
|
initialRtcMemberships,
|
||||||
);
|
);
|
||||||
const handRaisedSubject$ = new BehaviorSubject({});
|
const handRaisedSubject$ = new BehaviorSubject({});
|
||||||
const reactionsSubject$ = new BehaviorSubject({});
|
const reactionsSubject$ = new BehaviorSubject({});
|
||||||
|
|
||||||
const remoteParticipants$ = of([aliceParticipant]);
|
const remoteParticipants$ = of([aliceParticipant]);
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const livekitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants$ },
|
{ remoteParticipants$ },
|
||||||
);
|
);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
liveKitRoom,
|
matrixRoom,
|
||||||
|
livekitRoom,
|
||||||
mockMediaDevices({}),
|
mockMediaDevices({}),
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
@@ -147,7 +155,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
vm,
|
vm,
|
||||||
remoteRtcMemberships$,
|
rtcMemberships$,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
handRaisedSubject$: handRaisedSubject$,
|
handRaisedSubject$: handRaisedSubject$,
|
||||||
reactionsSubject$: reactionsSubject$,
|
reactionsSubject$: reactionsSubject$,
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import {
|
|||||||
type Focus,
|
type Focus,
|
||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
type MatrixRTCSessionEventHandlerMap,
|
type MatrixRTCSessionEventHandlerMap,
|
||||||
|
MembershipManagerEvent,
|
||||||
type SessionMembershipData,
|
type SessionMembershipData,
|
||||||
|
Status,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type LocalTrackPublication,
|
type LocalTrackPublication,
|
||||||
@@ -233,6 +236,7 @@ export function mockLocalParticipant(
|
|||||||
): LocalParticipant {
|
): LocalParticipant {
|
||||||
return {
|
return {
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
|
trackPublications: new Map(),
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
@@ -295,6 +299,7 @@ export async function withRemoteMedia(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||||
|
constant(false),
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
@@ -316,8 +321,10 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MockRTCSession extends TypedEventEmitter<
|
export class MockRTCSession extends TypedEventEmitter<
|
||||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
MatrixRTCSessionEventHandlerMap &
|
||||||
|
RoomAndToDeviceEventsHandlerMap &
|
||||||
|
MembershipManagerEventHandlerMap
|
||||||
> {
|
> {
|
||||||
public readonly statistics = {
|
public readonly statistics = {
|
||||||
counters: {},
|
counters: {},
|
||||||
@@ -327,7 +334,6 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly room: Room,
|
public readonly room: Room,
|
||||||
private localMembership: CallMembership,
|
|
||||||
public memberships: CallMembership[] = [],
|
public memberships: CallMembership[] = [],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -343,14 +349,27 @@ export class MockRTCSession extends TypedEventEmitter<
|
|||||||
): MockRTCSession {
|
): MockRTCSession {
|
||||||
rtcMembers$.subscribe((m) => {
|
rtcMembers$.subscribe((m) => {
|
||||||
const old = this.memberships;
|
const old = this.memberships;
|
||||||
// always prepend the local participant
|
this.memberships = m as CallMembership[];
|
||||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
this.emit(
|
||||||
this.memberships = updated;
|
MatrixRTCSessionEvent.MembershipsChanged,
|
||||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
|
old,
|
||||||
|
this.memberships,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly membershipStatus = Status.Connected;
|
||||||
|
|
||||||
|
private _probablyLeft = false;
|
||||||
|
public get probablyLeft(): boolean {
|
||||||
|
return this._probablyLeft;
|
||||||
|
}
|
||||||
|
public set probablyLeft(value: boolean) {
|
||||||
|
this._probablyLeft = value;
|
||||||
|
this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockTrack = (identity: string): TrackReference =>
|
export const mockTrack = (identity: string): TrackReference =>
|
||||||
|
|||||||
@@ -10280,7 +10280,7 @@ __metadata:
|
|||||||
|
|
||||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
||||||
version: 37.13.0
|
version: 37.13.0
|
||||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2abf7ca7955a283d1532ab9946e21dae8241627a"
|
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=3a33c658bbcb8ce8791ec066db899f2571f5c52f"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.5"
|
"@babel/runtime": "npm:^7.12.5"
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
||||||
@@ -10296,7 +10296,7 @@ __metadata:
|
|||||||
sdp-transform: "npm:^2.14.1"
|
sdp-transform: "npm:^2.14.1"
|
||||||
unhomoglyph: "npm:^1.0.6"
|
unhomoglyph: "npm:^1.0.6"
|
||||||
uuid: "npm:11"
|
uuid: "npm:11"
|
||||||
checksum: 10c0/32e1bdad4d55b12cbcc1874fa9e3b9b8e53ce9f9d848ec35e20061e85b6662ed132214969315ebbd12bcd61cb58ce42c059c345ef21e4b04f9d1bb7b691147b6
|
checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user