mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-11 04:27:03 +00:00
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",
|
||||
"reaction": "Reaction",
|
||||
"reactions": "Reactions",
|
||||
"reconnecting": "Reconnecting…",
|
||||
"settings": "Settings",
|
||||
"unencrypted": "Not encrypted",
|
||||
"username": "Username",
|
||||
|
||||
@@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { Initializer } from "./initializer";
|
||||
import { widget } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
@@ -86,7 +85,6 @@ export const App: FC<Props> = ({ vm }) => {
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<DisconnectedBanner />
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -61,7 +61,11 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
className={styles.bar}
|
||||
>
|
||||
<Header>
|
||||
<Header
|
||||
// App bar is mainly seen in the call view, which has its own
|
||||
// 'reconnecting' toast
|
||||
disconnectedBanner={false}
|
||||
>
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
|
||||
@@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
ref?: Ref<HTMLElement>;
|
||||
children: ReactNode;
|
||||
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> = ({
|
||||
ref,
|
||||
children,
|
||||
className,
|
||||
disconnectedBanner = true,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
{disconnectedBanner && <DisconnectedBanner />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ interface Props {
|
||||
* A supporting icon to display within the toast.
|
||||
*/
|
||||
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,
|
||||
children,
|
||||
Icon,
|
||||
portal = true,
|
||||
}) => {
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
|
||||
}
|
||||
}, [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 (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<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>
|
||||
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
@@ -66,7 +67,7 @@ beforeEach(() => {
|
||||
* a noise every time.
|
||||
*/
|
||||
test("plays one sound when entering a call", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
@@ -74,56 +75,58 @@ test("plays one sound when entering a call", () => {
|
||||
|
||||
// Joining a call usually means remote participants are added later.
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("plays a sound when a user joins", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
// Play a sound when joining a call.
|
||||
expect(playSound).toBeCalledWith("join");
|
||||
});
|
||||
|
||||
test("plays a sound when a user leaves", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([]);
|
||||
rtcMemberships$.next([localRtcMember]);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
|
||||
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++) {
|
||||
mockRtcMemberships.push(
|
||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
mockRtcMemberships,
|
||||
);
|
||||
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
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(() => {
|
||||
remoteRtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
||||
rtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
|
||||
);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
|
||||
@@ -137,11 +137,9 @@ function createGroupCallView(
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(constant([]));
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||
constant([localRtcMember]),
|
||||
);
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
|
||||
@@ -452,6 +452,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
participantCount={participantCount}
|
||||
onLeave={onLeave}
|
||||
header={header}
|
||||
|
||||
@@ -175,6 +175,7 @@ function createInCallView(): RenderResult & {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
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 { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { ConnectionState, type Room } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
@@ -111,6 +111,7 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { Toast } from "../Toast.tsx";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -164,6 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
{
|
||||
@@ -182,6 +184,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
}
|
||||
}, [
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
props.e2eeSystem,
|
||||
@@ -210,7 +213,8 @@ export interface InCallViewProps {
|
||||
vm: CallViewModel;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
livekitRoom: Room;
|
||||
matrixRoom: MatrixRoom;
|
||||
livekitRoom: LivekitRoom;
|
||||
muteStates: MuteStates;
|
||||
participantCount: number;
|
||||
/** Function to call when the user explicitly ends the call */
|
||||
@@ -226,6 +230,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
vm,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
@@ -270,7 +275,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
@@ -307,6 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
@@ -499,7 +505,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
break;
|
||||
case "standard":
|
||||
header = (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<Header
|
||||
className={styles.header}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
@@ -639,7 +649,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(() => {
|
||||
@@ -750,6 +760,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The reconnecting toast cannot be dismissed
|
||||
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
@@ -777,8 +790,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<Toast
|
||||
onDismiss={onDismissReconnectingToast}
|
||||
open={reconnecting}
|
||||
portal={false}
|
||||
>
|
||||
{t("common.reconnecting")}
|
||||
</Toast>
|
||||
<EarpieceOverlay
|
||||
show={earpieceMode}
|
||||
show={earpieceMode && !reconnecting}
|
||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||
/>
|
||||
<ReactionsOverlay vm={vm} />
|
||||
@@ -788,7 +808,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
roomId={matrixRoom.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
|
||||
@@ -256,7 +256,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r5»"
|
||||
aria-labelledby="«r8»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -279,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«ra»"
|
||||
aria-labelledby="«rd»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -301,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rf»"
|
||||
aria-labelledby="«ri»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -322,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rk»"
|
||||
aria-labelledby="«rn»"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -348,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="«rp»"
|
||||
aria-labelledby="«rs»"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -366,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="«ru»"
|
||||
aria-labelledby="«r11»"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { SyncState, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
ConnectionState,
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
mockMediaDevices,
|
||||
mockEmitter,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
@@ -94,6 +96,11 @@ vi.mock("rxjs", async (importOriginal) => ({
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const yesNo = {
|
||||
y: true,
|
||||
n: false,
|
||||
};
|
||||
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
|
||||
const carol = local;
|
||||
@@ -234,6 +241,7 @@ function withCallViewModel(
|
||||
mediaDevices: MediaDevices,
|
||||
continuation: (
|
||||
vm: CallViewModel,
|
||||
rtcSession: MockRTCSession,
|
||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||
) => void,
|
||||
options: CallViewModelOptions = {
|
||||
@@ -243,16 +251,14 @@ function withCallViewModel(
|
||||
): void {
|
||||
const room = mockMatrixRoom({
|
||||
client: {
|
||||
...mockEmitter(),
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
} as Partial<MatrixClient> as MatrixClient,
|
||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(rtcMembers$);
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
|
||||
const participantsSpy = vi
|
||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||
.mockReturnValue(remoteParticipants$);
|
||||
@@ -279,7 +285,7 @@ function withCallViewModel(
|
||||
.spyOn(ComponentsCore, "roomEventSelector")
|
||||
.mockImplementation((room, eventType) => of());
|
||||
|
||||
const liveKitRoom = mockLivekitRoom(
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
{ remoteParticipants$ },
|
||||
);
|
||||
@@ -288,7 +294,8 @@ function withCallViewModel(
|
||||
|
||||
const vm = new CallViewModel(
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
room,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
options,
|
||||
connectionState$,
|
||||
@@ -304,7 +311,7 @@ function withCallViewModel(
|
||||
roomEventSelectorSpy!.mockRestore();
|
||||
});
|
||||
|
||||
continuation(vm, { raisedHands$: raisedHands$ });
|
||||
continuation(vm, rtcSession, { raisedHands$: raisedHands$ });
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
@@ -321,7 +328,7 @@ test("participants are retained during a focus switch", () => {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
behavior(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
@@ -364,7 +371,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -419,10 +426,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
);
|
||||
expectObservable(vm.showSpeakingIndicators$).toBe(
|
||||
expectedShowSpeakingMarbles,
|
||||
{
|
||||
y: true,
|
||||
n: false,
|
||||
},
|
||||
yesNo,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -444,21 +448,12 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -511,17 +506,11 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -570,21 +559,12 @@ test("spotlight speakers swap places", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
|
||||
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -629,7 +609,7 @@ test("layout enters picture-in-picture mode when requested", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -671,7 +651,7 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -735,11 +715,11 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||
}),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [],
|
||||
c: [aliceRtcMember],
|
||||
d: [aliceRtcMember, daveRtcMember],
|
||||
e: [aliceRtcMember, daveRtcMember],
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember],
|
||||
c: [localRtcMember, aliceRtcMember],
|
||||
d: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||
e: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
@@ -785,7 +765,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
b: [aliceParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
}),
|
||||
constant([]), // No one joins the MatrixRTC session
|
||||
constant([localRtcMember]), // No one else joins the MatrixRTC session
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -829,10 +809,10 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, daveRtcMember],
|
||||
d: [daveRtcMember],
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
c: [localRtcMember, aliceRtcMember, daveRtcMember],
|
||||
d: [localRtcMember, daveRtcMember],
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
@@ -877,11 +857,16 @@ test("should disambiguate users with the same displayname", () => {
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
d: [aliceRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
||||
e: [aliceDoppelgangerRtcMember, bobRtcMember],
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
d: [
|
||||
localRtcMember,
|
||||
aliceRtcMember,
|
||||
aliceDoppelgangerRtcMember,
|
||||
bobRtcMember,
|
||||
],
|
||||
e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
@@ -927,8 +912,8 @@ test("should disambiguate users with invisible characters", () => {
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
@@ -960,8 +945,8 @@ test("should strip RTL characters from displayname", () => {
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [daveRtcMember, daveRTLRtcMember],
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, daveRtcMember, daveRTLRtcMember],
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
@@ -991,11 +976,11 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm, { raisedHands$ }) => {
|
||||
(vm, _rtcSession, { raisedHands$ }) => {
|
||||
schedule("ab", {
|
||||
a: () => {
|
||||
// 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[]> {
|
||||
return hot("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [aliceRtcMember], // Alice joins
|
||||
c: [aliceRtcMember], // Alice still there
|
||||
d: [], // Alice leaves
|
||||
a: [localRtcMember], // Start empty
|
||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||
c: [localRtcMember, aliceRtcMember], // Alice still there
|
||||
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
|
||||
withCallViewModel(
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
scope.behavior(nooneEverThere$(hot), []),
|
||||
constant([localRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -1236,7 +1221,7 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
constant([]),
|
||||
constant([localRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
devices,
|
||||
@@ -1244,10 +1229,10 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
schedule(toggleInputMarbles, {
|
||||
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
||||
});
|
||||
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
|
||||
n: false,
|
||||
y: true,
|
||||
});
|
||||
expectObservable(vm.earpieceMode$).toBe(
|
||||
expectedEarpieceModeMarbles,
|
||||
yesNo,
|
||||
);
|
||||
expectObservable(
|
||||
vm.audioOutputSwitcher$.pipe(
|
||||
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,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Room as LivekitRoom,
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
} 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 {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
@@ -48,6 +55,8 @@ import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -62,7 +71,12 @@ import {
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { accumulate, finalizeValue } from "../utils/observable";
|
||||
import {
|
||||
accumulate,
|
||||
and$,
|
||||
finalizeValue,
|
||||
pauseWhen,
|
||||
} from "../utils/observable";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
duplicateTiles,
|
||||
@@ -261,6 +275,7 @@ class UserMedia {
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
mediaDevices: MediaDevices,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
@@ -288,6 +303,7 @@ class UserMedia {
|
||||
>,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayname$),
|
||||
this.scope.behavior(handRaised$),
|
||||
this.scope.behavior(reaction$),
|
||||
@@ -341,7 +357,8 @@ class ScreenShare {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
liveKitRoom: LivekitRoom,
|
||||
livekitRoom: LivekitRoom,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Observable<string>,
|
||||
) {
|
||||
this.participant$ = new BehaviorSubject(participant);
|
||||
@@ -351,7 +368,8 @@ class ScreenShare {
|
||||
member,
|
||||
this.participant$.asObservable(),
|
||||
encryptionSystem,
|
||||
liveKitRoom,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayName$),
|
||||
participant.isLocal,
|
||||
);
|
||||
@@ -367,7 +385,7 @@ type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
function getRoomMemberFromRtcMember(
|
||||
rtcMember: CallMembership,
|
||||
room: Room,
|
||||
room: MatrixRoom,
|
||||
): { id: string; member: RoomMember | undefined } {
|
||||
// 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.
|
||||
@@ -389,6 +407,79 @@ function getRoomMemberFromRtcMember(
|
||||
|
||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||
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
|
||||
*/
|
||||
@@ -403,7 +494,7 @@ export class CallViewModel extends ViewModel {
|
||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
||||
RemoteParticipant[][]
|
||||
>(
|
||||
this.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
withLatestFrom(this.rawRemoteParticipants$),
|
||||
mergeMap(([s, ps]) => {
|
||||
// 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
|
||||
forkJoin([
|
||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
this.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||
take(1),
|
||||
),
|
||||
@@ -440,74 +531,80 @@ export class CallViewModel extends ViewModel {
|
||||
/**
|
||||
* The RemoteParticipants including those that are being "held" on the screen
|
||||
*/
|
||||
private readonly remoteParticipants$ = this.scope.behavior<
|
||||
RemoteParticipant[]
|
||||
>(
|
||||
combineLatest(
|
||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
private readonly remoteParticipants$ = this.scope
|
||||
.behavior<RemoteParticipant[]>(
|
||||
combineLatest(
|
||||
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
),
|
||||
)
|
||||
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||
|
||||
/**
|
||||
* Displaynames for each member of the call. This will disambiguate
|
||||
* any displaynames that clashes with another member. Only members
|
||||
* joined to the call are considered here.
|
||||
*/
|
||||
public readonly memberDisplaynames$ = this.memberships$.pipe(
|
||||
map((memberships) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
const { room } = this.matrixRTCSession;
|
||||
// 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 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.
|
||||
for (const rtcMember of memberships) {
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||
if (!member) {
|
||||
logger.error("Could not find member for media id:", matrixIdentifier);
|
||||
continue;
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
||||
if (!member) {
|
||||
logger.error(
|
||||
"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);
|
||||
displaynameMap.set(
|
||||
matrixIdentifier,
|
||||
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:
|
||||
return displaynameMap;
|
||||
}),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
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(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
).pipe(startWith(null)),
|
||||
).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)),
|
||||
showNonMemberTiles.value$,
|
||||
]).pipe(
|
||||
scan(
|
||||
@@ -552,7 +650,7 @@ export class CallViewModel extends ViewModel {
|
||||
) => {
|
||||
const newItems = new Map(
|
||||
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
|
||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||
const { member, id: livekitParticipantId } =
|
||||
@@ -604,6 +702,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -627,6 +726,7 @@ export class CallViewModel extends ViewModel {
|
||||
participant,
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -669,6 +769,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map(
|
||||
(m) => m.get(participant.identity) ?? "[👻]",
|
||||
@@ -772,12 +873,13 @@ export class CallViewModel extends ViewModel {
|
||||
|
||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||
map(({ userIds, leftUserIds }) => {
|
||||
const userId = this.matrixRTCSession.room.client.getUserId();
|
||||
if (!userId) {
|
||||
logger.warn("Could access client.getUserId to compute allOthersLeft");
|
||||
if (!this.userId) {
|
||||
logger.warn("Could not access user ID to compute allOthersLeft");
|
||||
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),
|
||||
distinctUntilChanged(),
|
||||
@@ -889,7 +991,7 @@ export class CallViewModel extends ViewModel {
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1474,10 +1576,11 @@ export class CallViewModel extends ViewModel {
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRTCSession: MatrixRTCSession,
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
private readonly livekitRoom: LivekitRoom,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
private readonly options: CallViewModelOptions,
|
||||
private readonly connectionState$: Observable<ECConnectionState>,
|
||||
private readonly livekitConnectionState$: Observable<ECConnectionState>,
|
||||
private readonly handsRaisedSubject$: Observable<
|
||||
Record<string, RaisedHandInfo>
|
||||
>,
|
||||
@@ -1486,5 +1589,49 @@ export class CallViewModel extends ViewModel {
|
||||
>,
|
||||
) {
|
||||
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.
|
||||
*/
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
private readonly _speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((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).
|
||||
*/
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
|
||||
private readonly _videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
@@ -421,7 +430,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -572,6 +581,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
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 localVolumeAdjustment$ = new Subject<number>();
|
||||
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.
|
||||
*/
|
||||
@@ -624,6 +645,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
@@ -639,11 +661,33 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
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
|
||||
combineLatest([
|
||||
participant$,
|
||||
this.localVolume$.pipe(this.scope.bind()),
|
||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
||||
// The local volume, taking into account whether we're supposed to pretend
|
||||
// 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 {
|
||||
@@ -683,12 +727,20 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
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(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayname$: Behavior<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
|
||||
@@ -54,6 +54,7 @@ interface SpotlightItemBaseProps {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoEnabled: boolean;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
@@ -63,7 +64,6 @@ interface SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
videoEnabled: boolean;
|
||||
videoFit: "contain" | "cover";
|
||||
}
|
||||
|
||||
@@ -90,12 +90,10 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
};
|
||||
@@ -135,6 +133,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
|
||||
@@ -160,6 +159,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoEnabled,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
@@ -169,7 +169,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
||||
<MediaView videoFit="contain" mirror={false} {...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 {
|
||||
type Observable,
|
||||
audit,
|
||||
combineLatest,
|
||||
concat,
|
||||
defer,
|
||||
filter,
|
||||
finalize,
|
||||
map,
|
||||
of,
|
||||
scan,
|
||||
startWith,
|
||||
takeWhile,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
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");
|
||||
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 { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
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 { CallViewModel } from "../state/CallViewModel";
|
||||
import {
|
||||
@@ -34,10 +39,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||
|
||||
export function getBasicRTCSession(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
): {
|
||||
rtcSession: MockRTCSession;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
matrixRoom: Room;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
} {
|
||||
const matrixRoomId = "!myRoomId:example.com";
|
||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||
@@ -51,6 +57,7 @@ export function getBasicRTCSession(
|
||||
client: {
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
|
||||
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
|
||||
@@ -91,52 +98,53 @@ export function getBasicRTCSession(
|
||||
),
|
||||
});
|
||||
|
||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRemoteRtcMemberships,
|
||||
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRtcMemberships,
|
||||
);
|
||||
|
||||
const rtcSession = new MockRTCSession(
|
||||
matrixRoom,
|
||||
localRtcMember,
|
||||
).withMemberships(remoteRtcMemberships$);
|
||||
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
|
||||
rtcMemberships$,
|
||||
);
|
||||
|
||||
return {
|
||||
rtcSession,
|
||||
remoteRtcMemberships$,
|
||||
matrixRoom,
|
||||
rtcMemberships$,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a basic CallViewModel to test components that make use of it.
|
||||
* @param members
|
||||
* @param initialRemoteRtcMemberships
|
||||
* @param initialRtcMemberships
|
||||
* @returns
|
||||
*/
|
||||
export function getBasicCallViewModelEnvironment(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcSession: MockRTCSession;
|
||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||
} {
|
||||
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
|
||||
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
|
||||
members,
|
||||
initialRemoteRtcMemberships,
|
||||
initialRtcMemberships,
|
||||
);
|
||||
const handRaisedSubject$ = new BehaviorSubject({});
|
||||
const reactionsSubject$ = new BehaviorSubject({});
|
||||
|
||||
const remoteParticipants$ = of([aliceParticipant]);
|
||||
const liveKitRoom = mockLivekitRoom(
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
{ remoteParticipants$ },
|
||||
);
|
||||
const vm = new CallViewModel(
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
mockMediaDevices({}),
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
@@ -147,7 +155,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
);
|
||||
return {
|
||||
vm,
|
||||
remoteRtcMemberships$,
|
||||
rtcMemberships$,
|
||||
rtcSession,
|
||||
handRaisedSubject$: handRaisedSubject$,
|
||||
reactionsSubject$: reactionsSubject$,
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
type Focus,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
type SessionMembershipData,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type LocalTrackPublication,
|
||||
@@ -233,6 +236,7 @@ export function mockLocalParticipant(
|
||||
): LocalParticipant {
|
||||
return {
|
||||
isLocal: true,
|
||||
trackPublications: new Map(),
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
...mockEmitter(),
|
||||
@@ -295,6 +299,7 @@ export async function withRemoteMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
constant(false),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
@@ -316,8 +321,10 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||
}
|
||||
|
||||
export class MockRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap &
|
||||
RoomAndToDeviceEventsHandlerMap &
|
||||
MembershipManagerEventHandlerMap
|
||||
> {
|
||||
public readonly statistics = {
|
||||
counters: {},
|
||||
@@ -327,7 +334,6 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
|
||||
public constructor(
|
||||
public readonly room: Room,
|
||||
private localMembership: CallMembership,
|
||||
public memberships: CallMembership[] = [],
|
||||
) {
|
||||
super();
|
||||
@@ -343,14 +349,27 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
): MockRTCSession {
|
||||
rtcMembers$.subscribe((m) => {
|
||||
const old = this.memberships;
|
||||
// always prepend the local participant
|
||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
||||
this.memberships = updated;
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
|
||||
this.memberships = m as CallMembership[];
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
old,
|
||||
this.memberships,
|
||||
);
|
||||
});
|
||||
|
||||
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 =>
|
||||
|
||||
@@ -10280,7 +10280,7 @@ __metadata:
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
||||
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:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
||||
@@ -10296,7 +10296,7 @@ __metadata:
|
||||
sdp-transform: "npm:^2.14.1"
|
||||
unhomoglyph: "npm:^1.0.6"
|
||||
uuid: "npm:11"
|
||||
checksum: 10c0/32e1bdad4d55b12cbcc1874fa9e3b9b8e53ce9f9d848ec35e20061e85b6662ed132214969315ebbd12bcd61cb58ce42c059c345ef21e4b04f9d1bb7b691147b6
|
||||
checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user