* Add media hints for notification events. * Prevent showing calling view when disconnected from Livekit. (#3491) * Refactor disconnection handling * Use "unknown" * Update signature * Add tests * Expose livekitConnectionState directly * fix whoopsie * Update dependency livekit-client to v2.15.7 (#3496) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486) * Fix the interactivity of buttons while reconnecting or in earpiece mode When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles. * Write a Playwright test for reconnecting * fix lints Signed-off-by: Timo K <toger5@hotmail.de> * fix test Signed-off-by: Timo K <toger5@hotmail.de> * enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU * remove rate limit for delayed events * more time to connect to livekit SFU * Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button * adapt to most recent Element Web version * Use the "End call" button as proofe for a started call * Currrenty disabled due to recent Element Web - not indicating the number of participants - bypassing Lobby * linting * disable 'can only interact with header and footer while reconnecting' for firefox --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> Co-authored-by: Timo K <toger5@hotmail.de> Co-authored-by: fkwp <github-fkwp@w4ve.de> * Log when a track is unpublished or runs into an error (#3495) * default mute states (unmuted!) in widget mode (embedded + intent) (#3494) * default mute states (unmuted!) in widget mode (embedded + intent) Signed-off-by: Timo K <toger5@hotmail.de> * review Signed-off-by: Timo K <toger5@hotmail.de> * introduce a cache for the url params. Signed-off-by: Timo K <toger5@hotmail.de> * Add an option to skip the cache. Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de> * Apply new hint code * missed a bit * fix intent * Automatically update intent on mute change * update packages * lint * Fix tests * fix merge fails --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Robin <robin@robin.town> Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> Co-authored-by: Timo K <toger5@hotmail.de> Co-authored-by: fkwp <github-fkwp@w4ve.de>
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
/*
|
|
Copyright 2023, 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 { map, type Observable, of, type SchedulerLike } from "rxjs";
|
|
import { type RunHelpers, TestScheduler } from "rxjs/testing";
|
|
import { expect, vi, vitest } from "vitest";
|
|
import {
|
|
type RoomMember,
|
|
type Room as MatrixRoom,
|
|
MatrixEvent,
|
|
type Room,
|
|
TypedEventEmitter,
|
|
} from "matrix-js-sdk";
|
|
import {
|
|
CallMembership,
|
|
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,
|
|
type RemoteParticipant,
|
|
type RemoteTrackPublication,
|
|
type Room as LivekitRoom,
|
|
Track,
|
|
} from "livekit-client";
|
|
import { randomUUID } from "crypto";
|
|
import {
|
|
type RoomAndToDeviceEvents,
|
|
type RoomAndToDeviceEventsHandlerMap,
|
|
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
|
import { type TrackReference } from "@livekit/components-core";
|
|
import EventEmitter from "events";
|
|
|
|
import {
|
|
LocalUserMediaViewModel,
|
|
RemoteUserMediaViewModel,
|
|
} from "../state/MediaViewModel";
|
|
import { E2eeType } from "../e2ee/e2eeType";
|
|
import {
|
|
DEFAULT_CONFIG,
|
|
type ResolvedConfigOptions,
|
|
} from "../config/ConfigOptions";
|
|
import { Config } from "../config/Config";
|
|
import { type MediaDevices } from "../state/MediaDevices";
|
|
import { type Behavior, constant } from "../state/Behavior";
|
|
import { ObservableScope } from "../state/ObservableScope";
|
|
|
|
export function withFakeTimers(continuation: () => void): void {
|
|
vi.useFakeTimers();
|
|
try {
|
|
continuation();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}
|
|
|
|
export async function flushPromises(): Promise<void> {
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve));
|
|
}
|
|
|
|
export interface OurRunHelpers extends RunHelpers {
|
|
/**
|
|
* Schedules a sequence of actions to happen, as described by a marble
|
|
* diagram.
|
|
*/
|
|
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
|
behavior<T = string>(
|
|
marbles: string,
|
|
values?: { [marble: string]: T },
|
|
error?: unknown,
|
|
): Behavior<T>;
|
|
scope: ObservableScope;
|
|
}
|
|
|
|
interface TestRunnerGlobal {
|
|
rxjsTestScheduler?: SchedulerLike;
|
|
}
|
|
|
|
/**
|
|
* Run Observables with a scheduler that virtualizes time, for testing purposes.
|
|
*/
|
|
export function withTestScheduler(
|
|
continuation: (helpers: OurRunHelpers) => void,
|
|
): void {
|
|
const scheduler = new TestScheduler((actual, expected) => {
|
|
expect(actual).deep.equals(expected);
|
|
});
|
|
const scope = new ObservableScope();
|
|
// we set the test scheduler as a global so that you can watch it in a debugger
|
|
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
|
|
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
|
|
scheduler.run((helpers) =>
|
|
continuation({
|
|
...helpers,
|
|
scope,
|
|
schedule(marbles, actions) {
|
|
const actionsObservable$ = helpers
|
|
.cold(marbles)
|
|
.pipe(map((value) => actions[value]()));
|
|
const results = Object.fromEntries(
|
|
Object.keys(actions).map((value) => [value, undefined] as const),
|
|
);
|
|
// Run the actions and verify that none of them error
|
|
helpers.expectObservable(actionsObservable$).toBe(marbles, results);
|
|
},
|
|
behavior<T>(
|
|
marbles: string,
|
|
values?: { [marble: string]: T },
|
|
error?: unknown,
|
|
) {
|
|
// Generate a hot Observable with helpers.hot and use it as a Behavior.
|
|
// To do this, we need to ensure that the initial value emits
|
|
// synchronously upon subscription. The issue is that helpers.hot emits
|
|
// frame 0 of the marble diagram *asynchronously*, only once we return
|
|
// from the continuation, so we need to splice out the initial marble
|
|
// and turn it into a proper initial value.
|
|
const initialMarbleIndex = marbles.search(/[^ ]/);
|
|
if (initialMarbleIndex === -1)
|
|
throw new Error("Behavior must have an initial value");
|
|
const initialMarble = marbles[initialMarbleIndex];
|
|
const initialValue =
|
|
values === undefined ? (initialMarble as T) : values[initialMarble];
|
|
// The remainder of the marble diagram should start on frame 1
|
|
return scope.behavior(
|
|
helpers.hot(
|
|
`-${marbles.slice(initialMarbleIndex + 1)}`,
|
|
values,
|
|
error,
|
|
),
|
|
initialValue,
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
scope.end();
|
|
}
|
|
|
|
interface EmitterMock<T> {
|
|
on: (...args: unknown[]) => T;
|
|
off: (...args: unknown[]) => T;
|
|
addListener: (...args: unknown[]) => T;
|
|
removeListener: (...args: unknown[]) => T;
|
|
emit: (event: string | symbol, ...args: unknown[]) => boolean;
|
|
}
|
|
|
|
export function mockEmitter<T>(): EmitterMock<T> {
|
|
const ee = new EventEmitter();
|
|
return {
|
|
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
|
|
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
|
|
addListener: ee.addListener.bind(ee) as unknown as (
|
|
...args: unknown[]
|
|
) => T,
|
|
removeListener: ee.removeListener.bind(ee) as unknown as (
|
|
...args: unknown[]
|
|
) => T,
|
|
emit: ee.emit.bind(ee),
|
|
};
|
|
}
|
|
|
|
export function mockRtcMembership(
|
|
user: string | RoomMember,
|
|
deviceId: string,
|
|
callId = "",
|
|
fociPreferred: Focus[] = [],
|
|
focusActive: Focus = { type: "oldest_membership" },
|
|
membership: Partial<SessionMembershipData> = {},
|
|
): CallMembership {
|
|
const data: SessionMembershipData = {
|
|
application: "m.call",
|
|
call_id: callId,
|
|
device_id: deviceId,
|
|
foci_preferred: fociPreferred,
|
|
focus_active: focusActive,
|
|
...membership,
|
|
};
|
|
const event = new MatrixEvent({
|
|
sender: typeof user === "string" ? user : user.userId,
|
|
event_id: `$-ev-${randomUUID()}:example.org`,
|
|
});
|
|
return new CallMembership(event, data);
|
|
}
|
|
|
|
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
|
|
// rather simple, but if one util to mock a member is good enough for us, maybe
|
|
// it's useful for matrix-js-sdk consumers in general.
|
|
export function mockMatrixRoomMember(
|
|
rtcMembership: CallMembership,
|
|
member: Partial<RoomMember> = {},
|
|
): RoomMember {
|
|
return {
|
|
...mockEmitter(),
|
|
userId: rtcMembership.sender,
|
|
...member,
|
|
} as RoomMember;
|
|
}
|
|
|
|
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
|
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
|
}
|
|
|
|
export function mockLivekitRoom(
|
|
room: Partial<LivekitRoom>,
|
|
{
|
|
remoteParticipants$,
|
|
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
|
|
): LivekitRoom {
|
|
const livekitRoom = {
|
|
...mockEmitter(),
|
|
...room,
|
|
} as Partial<LivekitRoom> as LivekitRoom;
|
|
if (remoteParticipants$) {
|
|
livekitRoom.remoteParticipants = new Map();
|
|
remoteParticipants$.subscribe((newRemoteParticipants) => {
|
|
livekitRoom.remoteParticipants.clear();
|
|
newRemoteParticipants.forEach((p) => {
|
|
livekitRoom.remoteParticipants.set(p.identity, p);
|
|
});
|
|
});
|
|
}
|
|
|
|
return livekitRoom;
|
|
}
|
|
|
|
export function mockLocalParticipant(
|
|
participant: Partial<LocalParticipant>,
|
|
): LocalParticipant {
|
|
return {
|
|
isLocal: true,
|
|
trackPublications: new Map(),
|
|
getTrackPublication: () =>
|
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
|
...mockEmitter(),
|
|
...participant,
|
|
} as Partial<LocalParticipant> as LocalParticipant;
|
|
}
|
|
|
|
export async function withLocalMedia(
|
|
localRtcMember: CallMembership,
|
|
roomMember: Partial<RoomMember>,
|
|
localParticipant: LocalParticipant,
|
|
mediaDevices: MediaDevices,
|
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
|
): Promise<void> {
|
|
const vm = new LocalUserMediaViewModel(
|
|
"local",
|
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
|
constant(localParticipant),
|
|
{
|
|
kind: E2eeType.PER_PARTICIPANT,
|
|
},
|
|
mockLivekitRoom({ localParticipant }),
|
|
mediaDevices,
|
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
|
constant(null),
|
|
constant(null),
|
|
);
|
|
try {
|
|
await continuation(vm);
|
|
} finally {
|
|
vm.destroy();
|
|
}
|
|
}
|
|
|
|
export function mockRemoteParticipant(
|
|
participant: Partial<RemoteParticipant>,
|
|
): RemoteParticipant {
|
|
return {
|
|
isLocal: false,
|
|
setVolume() {},
|
|
getTrackPublication: () =>
|
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
|
...mockEmitter(),
|
|
...participant,
|
|
} as RemoteParticipant;
|
|
}
|
|
|
|
export async function withRemoteMedia(
|
|
localRtcMember: CallMembership,
|
|
roomMember: Partial<RoomMember>,
|
|
participant: Partial<RemoteParticipant>,
|
|
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
|
): Promise<void> {
|
|
const remoteParticipant = mockRemoteParticipant(participant);
|
|
const vm = new RemoteUserMediaViewModel(
|
|
"remote",
|
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
|
of(remoteParticipant),
|
|
{
|
|
kind: E2eeType.PER_PARTICIPANT,
|
|
},
|
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
|
constant(false),
|
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
|
constant(null),
|
|
constant(null),
|
|
);
|
|
try {
|
|
await continuation(vm);
|
|
} finally {
|
|
vm.destroy();
|
|
}
|
|
}
|
|
|
|
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
|
vi.spyOn(Config, "get").mockReturnValue({
|
|
...DEFAULT_CONFIG,
|
|
...config,
|
|
});
|
|
// simulate loading the config
|
|
vi.spyOn(Config, "init").mockResolvedValue(void 0);
|
|
}
|
|
|
|
export class MockRTCSession extends TypedEventEmitter<
|
|
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
|
MatrixRTCSessionEventHandlerMap &
|
|
RoomAndToDeviceEventsHandlerMap &
|
|
MembershipManagerEventHandlerMap
|
|
> {
|
|
public readonly statistics = {
|
|
counters: {},
|
|
};
|
|
|
|
public leaveRoomSession = vitest.fn().mockResolvedValue(undefined);
|
|
|
|
public constructor(
|
|
public readonly room: Room,
|
|
public memberships: CallMembership[] = [],
|
|
) {
|
|
super();
|
|
}
|
|
|
|
public joined = true;
|
|
public isJoined(): boolean {
|
|
return this.joined;
|
|
}
|
|
|
|
public withMemberships(
|
|
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
|
): MockRTCSession {
|
|
rtcMembers$.subscribe((m) => {
|
|
const old = this.memberships;
|
|
this.memberships = m as CallMembership[];
|
|
this.emit(
|
|
MatrixRTCSessionEvent.MembershipsChanged,
|
|
old,
|
|
this.memberships,
|
|
);
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
public updateCallIntent = vitest.fn();
|
|
|
|
private _membershipStatus = Status.Connected;
|
|
public get membershipStatus(): Status {
|
|
return this._membershipStatus;
|
|
}
|
|
public set membershipStatus(value: Status) {
|
|
const prev = this._membershipStatus;
|
|
this._membershipStatus = value;
|
|
if (value !== prev)
|
|
this.emit(MembershipManagerEvent.StatusChanged, prev, value);
|
|
}
|
|
|
|
private _probablyLeft = false;
|
|
public get probablyLeft(): boolean {
|
|
return this._probablyLeft;
|
|
}
|
|
public set probablyLeft(value: boolean) {
|
|
const prev = this._probablyLeft;
|
|
this._probablyLeft = value;
|
|
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
|
|
}
|
|
}
|
|
|
|
export const mockTrack = (identity: string): TrackReference =>
|
|
({
|
|
participant: {
|
|
identity,
|
|
},
|
|
publication: {
|
|
kind: Track.Kind.Audio,
|
|
source: "mic",
|
|
trackSid: "123",
|
|
track: {
|
|
attach: vi.fn(),
|
|
detach: vi.fn(),
|
|
setAudioContext: vi.fn(),
|
|
setWebAudioPlugins: vi.fn(),
|
|
setVolume: vi.fn(),
|
|
},
|
|
},
|
|
track: {},
|
|
source: {},
|
|
}) as unknown as TrackReference;
|
|
|
|
export const deviceStub = {
|
|
available$: of(new Map<never, never>()),
|
|
selected$: of(undefined),
|
|
select(): void {},
|
|
};
|
|
|
|
export function mockMediaDevices(data: Partial<MediaDevices>): MediaDevices {
|
|
return {
|
|
audioInput: deviceStub,
|
|
audioOutput: deviceStub,
|
|
videoInput: deviceStub,
|
|
...data,
|
|
} as MediaDevices;
|
|
}
|