mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-02 04:05:56 +00:00
This probably should have been part of https://github.com/element-hq/element-call/pull/2984
994 lines
30 KiB
TypeScript
994 lines
30 KiB
TypeScript
/*
|
|
Copyright 2024 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, vi, onTestFinished, it } from "vitest";
|
|
import {
|
|
BehaviorSubject,
|
|
combineLatest,
|
|
debounceTime,
|
|
distinctUntilChanged,
|
|
map,
|
|
type Observable,
|
|
of,
|
|
skip,
|
|
switchMap,
|
|
} from "rxjs";
|
|
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
|
import {
|
|
ConnectionState,
|
|
type LocalParticipant,
|
|
type Participant,
|
|
ParticipantEvent,
|
|
type RemoteParticipant,
|
|
} from "livekit-client";
|
|
import * as ComponentsCore from "@livekit/components-core";
|
|
import { isEqual } from "lodash-es";
|
|
import {
|
|
type CallMembership,
|
|
type MatrixRTCSession,
|
|
} from "matrix-js-sdk/src/matrixrtc";
|
|
|
|
import { CallViewModel, type Layout } from "./CallViewModel";
|
|
import {
|
|
mockLivekitRoom,
|
|
mockLocalParticipant,
|
|
mockMatrixRoom,
|
|
mockMatrixRoomMember,
|
|
mockRemoteParticipant,
|
|
withTestScheduler,
|
|
mockRtcMembership,
|
|
MockRTCSession,
|
|
} from "../utils/test";
|
|
import {
|
|
ECAddonConnectionState,
|
|
type ECConnectionState,
|
|
} from "../livekit/useECConnectionState";
|
|
import { E2eeType } from "../e2ee/e2eeType";
|
|
import type { RaisedHandInfo } from "../reactions";
|
|
import { showNonMemberTiles } from "../settings/settings";
|
|
import {
|
|
alice,
|
|
aliceDoppelganger,
|
|
aliceDoppelgangerId,
|
|
aliceDoppelgangerRtcMember,
|
|
aliceId,
|
|
aliceParticipant,
|
|
aliceRtcMember,
|
|
bob,
|
|
bobId,
|
|
bobRtcMember,
|
|
bobZeroWidthSpace,
|
|
bobZeroWidthSpaceId,
|
|
bobZeroWidthSpaceRtcMember,
|
|
daveRTL,
|
|
daveRTLId,
|
|
daveRTLRtcMember,
|
|
local,
|
|
localId,
|
|
localRtcMember,
|
|
} from "../utils/test-fixtures";
|
|
|
|
vi.mock("@livekit/components-core");
|
|
|
|
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
|
|
|
const carol = local;
|
|
const carolId = localId;
|
|
const dave = mockMatrixRoomMember(daveRtcMember, { rawDisplayName: "Dave" });
|
|
|
|
const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
|
|
|
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
|
const aliceSharingScreen = mockRemoteParticipant({
|
|
identity: aliceId,
|
|
isScreenShareEnabled: true,
|
|
});
|
|
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
|
const bobSharingScreen = mockRemoteParticipant({
|
|
identity: bobId,
|
|
isScreenShareEnabled: true,
|
|
});
|
|
const daveParticipant = mockRemoteParticipant({ identity: daveId });
|
|
|
|
const roomMembers = new Map(
|
|
[alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map(
|
|
(p) => [p.userId, p],
|
|
),
|
|
);
|
|
|
|
export interface GridLayoutSummary {
|
|
type: "grid";
|
|
spotlight?: string[];
|
|
grid: string[];
|
|
}
|
|
|
|
export interface SpotlightLandscapeLayoutSummary {
|
|
type: "spotlight-landscape";
|
|
spotlight: string[];
|
|
grid: string[];
|
|
}
|
|
|
|
export interface SpotlightPortraitLayoutSummary {
|
|
type: "spotlight-portrait";
|
|
spotlight: string[];
|
|
grid: string[];
|
|
}
|
|
|
|
export interface SpotlightExpandedLayoutSummary {
|
|
type: "spotlight-expanded";
|
|
spotlight: string[];
|
|
pip?: string;
|
|
}
|
|
|
|
export interface OneOnOneLayoutSummary {
|
|
type: "one-on-one";
|
|
local: string;
|
|
remote: string;
|
|
}
|
|
|
|
export interface PipLayoutSummary {
|
|
type: "pip";
|
|
spotlight: string[];
|
|
}
|
|
|
|
export type LayoutSummary =
|
|
| GridLayoutSummary
|
|
| SpotlightLandscapeLayoutSummary
|
|
| SpotlightPortraitLayoutSummary
|
|
| SpotlightExpandedLayoutSummary
|
|
| OneOnOneLayoutSummary
|
|
| PipLayoutSummary;
|
|
|
|
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|
return l$.pipe(
|
|
switchMap((l) => {
|
|
switch (l.type) {
|
|
case "grid":
|
|
return combineLatest(
|
|
[
|
|
l.spotlight?.media$ ?? of(undefined),
|
|
...l.grid.map((vm) => vm.media$),
|
|
],
|
|
(spotlight, ...grid) => ({
|
|
type: l.type,
|
|
spotlight: spotlight?.map((vm) => vm.id),
|
|
grid: grid.map((vm) => vm.id),
|
|
}),
|
|
);
|
|
case "spotlight-landscape":
|
|
case "spotlight-portrait":
|
|
return combineLatest(
|
|
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
|
|
(spotlight, ...grid) => ({
|
|
type: l.type,
|
|
spotlight: spotlight.map((vm) => vm.id),
|
|
grid: grid.map((vm) => vm.id),
|
|
}),
|
|
);
|
|
case "spotlight-expanded":
|
|
return combineLatest(
|
|
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
|
(spotlight, pip) => ({
|
|
type: l.type,
|
|
spotlight: spotlight.map((vm) => vm.id),
|
|
pip: pip?.id,
|
|
}),
|
|
);
|
|
case "one-on-one":
|
|
return combineLatest(
|
|
[l.local.media$, l.remote.media$],
|
|
(local, remote) => ({
|
|
type: l.type,
|
|
local: local.id,
|
|
remote: remote.id,
|
|
}),
|
|
);
|
|
case "pip":
|
|
return l.spotlight.media$.pipe(
|
|
map((spotlight) => ({
|
|
type: l.type,
|
|
spotlight: spotlight.map((vm) => vm.id),
|
|
})),
|
|
);
|
|
}
|
|
}),
|
|
// Sometimes there can be multiple (synchronous) updates per frame. We only
|
|
// care about the most recent value for each time step, so discard these
|
|
// extra values.
|
|
debounceTime(0),
|
|
distinctUntilChanged(isEqual),
|
|
);
|
|
}
|
|
|
|
function withCallViewModel(
|
|
remoteParticipants$: Observable<RemoteParticipant[]>,
|
|
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
|
connectionState$: Observable<ECConnectionState>,
|
|
speaking: Map<Participant, Observable<boolean>>,
|
|
continuation: (
|
|
vm: CallViewModel,
|
|
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
|
) => void,
|
|
): void {
|
|
const room = mockMatrixRoom({
|
|
client: {
|
|
getUserId: () => localRtcMember.sender,
|
|
getDeviceId: () => localRtcMember.deviceId,
|
|
} as Partial<MatrixClient> as MatrixClient,
|
|
getMember: (userId) => roomMembers.get(userId) ?? null,
|
|
});
|
|
const rtcSession = new MockRTCSession(
|
|
room,
|
|
localRtcMember,
|
|
[],
|
|
).withMemberships(rtcMembers$);
|
|
const participantsSpy = vi
|
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
|
.mockReturnValue(remoteParticipants$);
|
|
const mediaSpy = vi
|
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
|
.mockImplementation((p) =>
|
|
of({ participant: p } as Partial<
|
|
ComponentsCore.ParticipantMedia<LocalParticipant>
|
|
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
|
);
|
|
const eventsSpy = vi
|
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
|
.mockImplementation((p, ...eventTypes) => {
|
|
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
|
return (speaking.get(p) ?? of(false)).pipe(
|
|
map((s) => ({ ...p, isSpeaking: s }) as Participant),
|
|
);
|
|
} else {
|
|
return of(p);
|
|
}
|
|
});
|
|
|
|
const roomEventSelectorSpy = vi
|
|
.spyOn(ComponentsCore, "roomEventSelector")
|
|
.mockImplementation((room, eventType) => of());
|
|
|
|
const liveKitRoom = mockLivekitRoom(
|
|
{ localParticipant },
|
|
{ remoteParticipants$ },
|
|
);
|
|
|
|
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
|
|
|
|
const vm = new CallViewModel(
|
|
rtcSession as unknown as MatrixRTCSession,
|
|
liveKitRoom,
|
|
{
|
|
kind: E2eeType.PER_PARTICIPANT,
|
|
},
|
|
connectionState$,
|
|
raisedHands$,
|
|
new BehaviorSubject({}),
|
|
);
|
|
|
|
onTestFinished(() => {
|
|
vm!.destroy();
|
|
participantsSpy!.mockRestore();
|
|
mediaSpy!.mockRestore();
|
|
eventsSpy!.mockRestore();
|
|
roomEventSelectorSpy!.mockRestore();
|
|
});
|
|
|
|
continuation(vm, { raisedHands$: raisedHands$ });
|
|
}
|
|
|
|
test("participants are retained during a focus switch", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
// Participants disappear on frame 2 and come back on frame 3
|
|
const participantInputMarbles = "a-ba";
|
|
// Start switching focus on frame 1 and reconnect on frame 3
|
|
const connectionInputMarbles = " cs-c";
|
|
// The visible participants should remain the same throughout the switch
|
|
const expectedLayoutMarbles = " a";
|
|
|
|
withCallViewModel(
|
|
hot(participantInputMarbles, {
|
|
a: [aliceParticipant, bobParticipant],
|
|
b: [],
|
|
}),
|
|
of([aliceRtcMember, bobRtcMember]),
|
|
hot(connectionInputMarbles, {
|
|
c: ConnectionState.Connected,
|
|
s: ECAddonConnectionState.ECSwitchingFocus,
|
|
}),
|
|
new Map(),
|
|
(vm) => {
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("screen sharing activates spotlight layout", () => {
|
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
|
// Start with no screen shares, then have Alice and Bob share their screens,
|
|
// then return to no screen shares, then have just Alice share for a bit
|
|
const participantInputMarbles = " abcda-ba";
|
|
// While there are no screen shares, switch to spotlight manually, and then
|
|
// switch back to grid at the end
|
|
const modeInputMarbles = " -----s--g";
|
|
// We should automatically enter spotlight for the first round of screen
|
|
// sharing, then return to grid, then manually go into spotlight, and
|
|
// remain in spotlight until we manually go back to grid
|
|
const expectedLayoutMarbles = " abcdaefeg";
|
|
const expectedShowSpeakingMarbles = "y----nyny";
|
|
withCallViewModel(
|
|
hot(participantInputMarbles, {
|
|
a: [aliceParticipant, bobParticipant],
|
|
b: [aliceSharingScreen, bobParticipant],
|
|
c: [aliceSharingScreen, bobSharingScreen],
|
|
d: [aliceParticipant, bobSharingScreen],
|
|
}),
|
|
of([aliceRtcMember, bobRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
schedule(modeInputMarbles, {
|
|
s: () => vm.setGridMode("spotlight"),
|
|
g: () => vm.setGridMode("grid"),
|
|
});
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
b: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0:screen-share`],
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
c: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [
|
|
`${aliceId}:0:screen-share`,
|
|
`${bobId}:0:screen-share`,
|
|
],
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
d: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${bobId}:0:screen-share`],
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
e: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0`],
|
|
grid: ["local:0", `${bobId}:0`],
|
|
},
|
|
f: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0:screen-share`],
|
|
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
|
},
|
|
g: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
|
},
|
|
},
|
|
);
|
|
expectObservable(vm.showSpeakingIndicators$).toBe(
|
|
expectedShowSpeakingMarbles,
|
|
{
|
|
y: true,
|
|
n: false,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("participants stay in the same order unless to appear/disappear", () => {
|
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
|
const visibilityInputMarbles = "a";
|
|
// First Bob speaks, then Dave, then Alice
|
|
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
|
|
const bSpeakingInputMarbles = " ny 1998ms n 1999ms -";
|
|
const dSpeakingInputMarbles = " n- 1998ms y 1999ms n";
|
|
// Nothing should change when Bob speaks, because Bob is already on screen.
|
|
// When Dave speaks he should switch with Alice because she's the one who
|
|
// hasn't spoken at all. Then when Alice speaks, she should return to her
|
|
// place at the top.
|
|
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant, daveParticipant]),
|
|
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map([
|
|
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
|
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
|
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
|
]),
|
|
(vm) => {
|
|
schedule(visibilityInputMarbles, {
|
|
a: () => {
|
|
// We imagine that only three tiles (the first three) will be visible
|
|
// on screen at a time
|
|
vm.layout$.subscribe((layout) => {
|
|
if (layout.type === "grid") layout.setVisibleTiles(3);
|
|
});
|
|
},
|
|
});
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
|
|
},
|
|
b: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
|
|
},
|
|
c: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("participants adjust order when space becomes constrained", () => {
|
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
|
// Start with all tiles on screen then shrink to 3
|
|
const visibilityInputMarbles = "a-b";
|
|
// Bob and Dave speak
|
|
const bSpeakingInputMarbles = " ny";
|
|
const dSpeakingInputMarbles = " ny";
|
|
// Nothing should change when Bob or Dave initially speak, because they are
|
|
// on screen. When the screen becomes smaller Alice should move off screen
|
|
// to make way for the speakers (specifically, she should swap with Dave).
|
|
const expectedLayoutMarbles = " a-b";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant, daveParticipant]),
|
|
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map([
|
|
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
|
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
|
]),
|
|
(vm) => {
|
|
let setVisibleTiles: ((value: number) => void) | null = null;
|
|
vm.layout$.subscribe((layout) => {
|
|
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
|
|
});
|
|
schedule(visibilityInputMarbles, {
|
|
a: () => setVisibleTiles!(Infinity),
|
|
b: () => setVisibleTiles!(3),
|
|
});
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
|
|
},
|
|
b: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("spotlight speakers swap places", () => {
|
|
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
|
// Go immediately into spotlight mode for the test
|
|
const modeInputMarbles = " s";
|
|
// First Bob speaks, then Dave, then Alice
|
|
const aSpeakingInputMarbles = "n--y";
|
|
const bSpeakingInputMarbles = "nyn";
|
|
const dSpeakingInputMarbles = "n-yn";
|
|
// Alice should start in the spotlight, then Bob, then Dave, then Alice
|
|
// again. However, the positions of Dave and Bob in the grid should be
|
|
// reversed by the end because they've been swapped in and out of the
|
|
// spotlight.
|
|
const expectedLayoutMarbles = "abcd";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant, daveParticipant]),
|
|
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map([
|
|
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
|
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
|
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
|
]),
|
|
(vm) => {
|
|
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0`],
|
|
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
|
|
},
|
|
b: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${bobId}:0`],
|
|
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
|
|
},
|
|
c: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${daveId}:0`],
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
d: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0`],
|
|
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("layout enters picture-in-picture mode when requested", () => {
|
|
withTestScheduler(({ schedule, expectObservable }) => {
|
|
// Enable then disable picture-in-picture
|
|
const pipControlInputMarbles = "-ed";
|
|
// Should go into picture-in-picture layout then back to grid
|
|
const expectedLayoutMarbles = " aba";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant]),
|
|
of([aliceRtcMember, bobRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
schedule(pipControlInputMarbles, {
|
|
e: () => window.controls.enablePip(),
|
|
d: () => window.controls.disablePip(),
|
|
});
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
b: {
|
|
type: "pip",
|
|
spotlight: [`${aliceId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("spotlight remembers whether it's expanded", () => {
|
|
withTestScheduler(({ schedule, expectObservable }) => {
|
|
// Start in spotlight mode, then switch to grid and back to spotlight a
|
|
// couple times
|
|
const modeInputMarbles = " s-gs-gs";
|
|
// Expand and collapse the spotlight
|
|
const expandInputMarbles = " -a--a";
|
|
// Spotlight should stay expanded during the first mode switch, and stay
|
|
// collapsed during the second mode switch
|
|
const expectedLayoutMarbles = "abcbada";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant]),
|
|
of([aliceRtcMember, bobRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
schedule(modeInputMarbles, {
|
|
s: () => vm.setGridMode("spotlight"),
|
|
g: () => vm.setGridMode("grid"),
|
|
});
|
|
schedule(expandInputMarbles, {
|
|
a: () => {
|
|
let toggle: () => void;
|
|
vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
|
|
toggle!();
|
|
},
|
|
});
|
|
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "spotlight-landscape",
|
|
spotlight: [`${aliceId}:0`],
|
|
grid: ["local:0", `${bobId}:0`],
|
|
},
|
|
b: {
|
|
type: "spotlight-expanded",
|
|
spotlight: [`${aliceId}:0`],
|
|
pip: "local:0",
|
|
},
|
|
c: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
d: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("participants must have a MatrixRTCSession to be visible", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
// iterate through a number of combinations of participants and MatrixRTC memberships
|
|
// Bob never has an MatrixRTC membership
|
|
const scenarioInputMarbles = " abcdec";
|
|
// Bob should never be visible
|
|
const expectedLayoutMarbles = "a-bc-b";
|
|
|
|
withCallViewModel(
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [bobParticipant],
|
|
c: [aliceParticipant, bobParticipant],
|
|
d: [aliceParticipant, daveParticipant, bobParticipant],
|
|
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
|
}),
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [],
|
|
c: [aliceRtcMember],
|
|
d: [aliceRtcMember, daveRtcMember],
|
|
e: [aliceRtcMember, daveRtcMember],
|
|
}),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
vm.setGridMode("grid");
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0"],
|
|
},
|
|
b: {
|
|
type: "one-on-one",
|
|
local: "local:0",
|
|
remote: `${aliceId}:0`,
|
|
},
|
|
c: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("shows participants without MatrixRTCSession when enabled in settings", () => {
|
|
try {
|
|
// enable the setting:
|
|
showNonMemberTiles.setValue(true);
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
const scenarioInputMarbles = " abc";
|
|
const expectedLayoutMarbles = "abc";
|
|
|
|
withCallViewModel(
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [aliceParticipant],
|
|
c: [aliceParticipant, bobParticipant],
|
|
}),
|
|
of([]), // No one joins the MatrixRTC session
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
vm.setGridMode("grid");
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0"],
|
|
},
|
|
b: {
|
|
type: "one-on-one",
|
|
local: "local:0",
|
|
remote: `${aliceId}:0`,
|
|
},
|
|
c: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
} finally {
|
|
showNonMemberTiles.setValue(showNonMemberTiles.defaultValue);
|
|
}
|
|
});
|
|
|
|
it("should show at least one tile per MatrixRTCSession", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
// iterate through some combinations of MatrixRTC memberships
|
|
const scenarioInputMarbles = " abcd";
|
|
// There should always be one tile for each MatrixRTCSession
|
|
const expectedLayoutMarbles = "abcd";
|
|
|
|
withCallViewModel(
|
|
of([]),
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [aliceRtcMember],
|
|
c: [aliceRtcMember, daveRtcMember],
|
|
d: [daveRtcMember],
|
|
}),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
vm.setGridMode("grid");
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0"],
|
|
},
|
|
b: {
|
|
type: "one-on-one",
|
|
local: "local:0",
|
|
remote: `${aliceId}:0`,
|
|
},
|
|
c: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
|
|
},
|
|
d: {
|
|
type: "one-on-one",
|
|
local: "local:0",
|
|
remote: `${daveId}:0`,
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("should disambiguate users with the same displayname", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
const scenarioInputMarbles = "abcde";
|
|
const expectedLayoutMarbles = "abcde";
|
|
|
|
withCallViewModel(
|
|
of([]),
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [aliceRtcMember],
|
|
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
|
d: [aliceRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
|
|
e: [aliceDoppelgangerRtcMember, bobRtcMember],
|
|
}),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
// Skip the null state.
|
|
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
// Carol has no displayname - So userId is used.
|
|
a: new Map([[carolId, carol.userId]]),
|
|
b: new Map([
|
|
[carolId, carol.userId],
|
|
[aliceId, alice.rawDisplayName],
|
|
]),
|
|
// The second alice joins.
|
|
c: new Map([
|
|
[carolId, carol.userId],
|
|
[aliceId, "Alice (@alice:example.org)"],
|
|
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
|
]),
|
|
// Bob also joins
|
|
d: new Map([
|
|
[carolId, carol.userId],
|
|
[aliceId, "Alice (@alice:example.org)"],
|
|
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
|
[bobId, bob.rawDisplayName],
|
|
]),
|
|
// Alice leaves, and the displayname should reset.
|
|
e: new Map([
|
|
[carolId, carol.userId],
|
|
[aliceDoppelgangerId, "Alice"],
|
|
[bobId, bob.rawDisplayName],
|
|
]),
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("should disambiguate users with invisible characters", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
const scenarioInputMarbles = "ab";
|
|
const expectedLayoutMarbles = "ab";
|
|
|
|
withCallViewModel(
|
|
of([]),
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
|
}),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
// Skip the null state.
|
|
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
// Carol has no displayname - So userId is used.
|
|
a: new Map([[carolId, carol.userId]]),
|
|
// Both Bobs join, and should handle zero width hacks.
|
|
b: new Map([
|
|
[carolId, carol.userId],
|
|
[bobId, `Bob (${bob.userId})`],
|
|
[
|
|
bobZeroWidthSpaceId,
|
|
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
|
],
|
|
]),
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
test("should strip RTL characters from displayname", () => {
|
|
withTestScheduler(({ hot, expectObservable }) => {
|
|
const scenarioInputMarbles = "ab";
|
|
const expectedLayoutMarbles = "ab";
|
|
|
|
withCallViewModel(
|
|
of([]),
|
|
hot(scenarioInputMarbles, {
|
|
a: [],
|
|
b: [daveRtcMember, daveRTLRtcMember],
|
|
}),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm) => {
|
|
// Skip the null state.
|
|
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
// Carol has no displayname - So userId is used.
|
|
a: new Map([[carolId, carol.userId]]),
|
|
// Both Dave's join. Since after stripping
|
|
b: new Map([
|
|
[carolId, carol.userId],
|
|
// Not disambiguated
|
|
[daveId, "Dave"],
|
|
// This one is, since it's using RTL.
|
|
[daveRTLId, `evaD (${daveRTL.userId})`],
|
|
]),
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should rank raised hands above video feeds and below speakers and presenters", () => {
|
|
withTestScheduler(({ schedule, expectObservable }) => {
|
|
// There should always be one tile for each MatrixRTCSession
|
|
const expectedLayoutMarbles = "ab";
|
|
|
|
withCallViewModel(
|
|
of([aliceParticipant, bobParticipant]),
|
|
of([aliceRtcMember, bobRtcMember]),
|
|
of(ConnectionState.Connected),
|
|
new Map(),
|
|
(vm, { raisedHands$ }) => {
|
|
schedule("ab", {
|
|
a: () => {
|
|
// We imagine that only two tiles (the first two) will be visible on screen at a time
|
|
vm.layout$.subscribe((layout) => {
|
|
if (layout.type === "grid") {
|
|
layout.setVisibleTiles(2);
|
|
}
|
|
});
|
|
},
|
|
b: () => {
|
|
raisedHands$.next({
|
|
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]: {
|
|
time: new Date(),
|
|
reactionEventId: "",
|
|
membershipEventId: "",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
|
expectedLayoutMarbles,
|
|
{
|
|
a: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: [
|
|
"local:0",
|
|
"@alice:example.org:AAAA:0",
|
|
"@bob:example.org:BBBB:0",
|
|
],
|
|
},
|
|
b: {
|
|
type: "grid",
|
|
spotlight: undefined,
|
|
grid: [
|
|
"local:0",
|
|
// Bob shifts up!
|
|
"@bob:example.org:BBBB:0",
|
|
"@alice:example.org:AAAA:0",
|
|
],
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
});
|