Prepare for Element Call integration (#9224)
* Improve accessibility and testability of Tooltip Adding a role to Tooltip was motivated by React Testing Library's reliance on accessibility-related attributes to locate elements. * Make the ReadyWatchingStore constructor safer The ReadyWatchingStore constructor previously had a chance to immediately call onReady, which was dangerous because it was potentially calling the derived class's onReady at a point when the derived class hadn't even finished construction yet. In normal usage, I guess this never was a problem, but it was causing some of the tests I was writing to crash. This is solved by separating out the onReady call into a start method. * Rename 1:1 call components to 'LegacyCall' to reflect the fact that they're slated for removal, and to not clash with the new Call code. * Refactor VideoChannelStore into Call and CallStore Call is an abstract class that currently only has a Jitsi implementation, but this will make it easy to later add an Element Call implementation. * Remove WidgetReady, ClientReady, and ForceHangupCall hacks These are no longer used by the new Jitsi call implementation, and can be removed. * yarn i18n * Delete call map entries instead of inserting nulls * Allow multiple active calls and consolidate call listeners * Fix a race condition when creating a video room * Un-hardcode the media device fallback labels * Apply misc code review fixes * yarn i18n * Disconnect from calls more politely on logout * Fix some strict mode errors * Fix another updateRoom race condition
This commit is contained in:
parent
50f6986f6c
commit
0d6a550c33
107 changed files with 2573 additions and 2157 deletions
|
@ -1,225 +0,0 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import {
|
||||
Widget,
|
||||
ClientWidgetApi,
|
||||
MatrixWidgetType,
|
||||
WidgetApiAction,
|
||||
IWidgetApiRequest,
|
||||
IWidgetApiRequestData,
|
||||
} from "matrix-widget-api";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils";
|
||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
|
||||
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
|
||||
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
|
||||
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
|
||||
import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils";
|
||||
import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore";
|
||||
|
||||
describe("VideoChannelStore", () => {
|
||||
const store = VideoChannelStore.instance;
|
||||
|
||||
const widget = { id: "1" } as unknown as Widget;
|
||||
const app = {
|
||||
id: "1",
|
||||
eventId: "$1:example.org",
|
||||
roomId: "!1:example.org",
|
||||
type: MatrixWidgetType.JitsiMeet,
|
||||
url: "",
|
||||
name: "Video channel",
|
||||
creatorUserId: "@alice:example.org",
|
||||
avatar_url: null,
|
||||
data: { isVideoChannel: true },
|
||||
} as IApp;
|
||||
|
||||
// Set up mocks to simulate the remote end of the widget API
|
||||
let sendMock: (action: WidgetApiAction, data: IWidgetApiRequestData) => void;
|
||||
let onMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
|
||||
let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
|
||||
let messaging: ClientWidgetApi;
|
||||
let cli: Mocked<MatrixClient>;
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
cli = mocked(MatrixClientPeg.get());
|
||||
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
|
||||
setupAsyncStoreWithClient(store, cli);
|
||||
cli.getRoom.mockReturnValue(mkRoom(cli, "!1:example.org"));
|
||||
|
||||
sendMock = jest.fn();
|
||||
onMock = jest.fn();
|
||||
onceMock = jest.fn();
|
||||
|
||||
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]);
|
||||
messaging = {
|
||||
on: onMock,
|
||||
off: () => {},
|
||||
stop: () => {},
|
||||
once: onceMock,
|
||||
transport: {
|
||||
send: sendMock,
|
||||
reply: () => {},
|
||||
},
|
||||
} as unknown as ClientWidgetApi;
|
||||
});
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
const getRequest = <T extends IWidgetApiRequestData>(): Promise<[WidgetApiAction, T]> =>
|
||||
new Promise<[WidgetApiAction, T]>(resolve => {
|
||||
mocked(sendMock).mockImplementationOnce((action, data) => resolve([action, data as T]));
|
||||
});
|
||||
|
||||
const widgetReady = () => {
|
||||
// Tell the WidgetStore that the widget is ready
|
||||
const [, ready] = mocked(onceMock).mock.calls.find(([action]) =>
|
||||
action === `action:${ElementWidgetActions.WidgetReady}`,
|
||||
);
|
||||
ready({ detail: {} } as unknown as CustomEvent<IWidgetApiRequest>);
|
||||
};
|
||||
|
||||
const confirmConnect = async () => {
|
||||
// Wait for the store to contact the widget API
|
||||
await getRequest();
|
||||
// Then, locate the callback that will confirm the join
|
||||
const [, join] = mocked(onMock).mock.calls.find(([action]) =>
|
||||
action === `action:${ElementWidgetActions.JoinCall}`,
|
||||
);
|
||||
// Confirm the join, and wait for the store to update
|
||||
const waitForConnect = new Promise<void>(resolve =>
|
||||
store.once(VideoChannelEvent.Connect, resolve),
|
||||
);
|
||||
join(new CustomEvent("widgetapirequest", { detail: {} }) as CustomEvent<IWidgetApiRequest>);
|
||||
await waitForConnect;
|
||||
};
|
||||
|
||||
const confirmDisconnect = async () => {
|
||||
// Locate the callback that will perform the hangup
|
||||
const [, hangup] = mocked(onceMock).mock.calls.find(([action]) =>
|
||||
action === `action:${ElementWidgetActions.HangupCall}`,
|
||||
);
|
||||
// Hangup and wait for the store, once again
|
||||
const waitForHangup = new Promise<void>(resolve =>
|
||||
store.once(VideoChannelEvent.Disconnect, resolve),
|
||||
);
|
||||
hangup(new CustomEvent("widgetapirequest", { detail: {} }) as CustomEvent<IWidgetApiRequest>);
|
||||
await waitForHangup;
|
||||
};
|
||||
|
||||
it("connects and disconnects", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
|
||||
widgetReady();
|
||||
expect(store.roomId).toBeFalsy();
|
||||
expect(store.connected).toEqual(false);
|
||||
|
||||
const connectConfirmed = confirmConnect();
|
||||
const connectPromise = store.connect("!1:example.org", null, null);
|
||||
await connectConfirmed;
|
||||
await expect(connectPromise).resolves.toBeUndefined();
|
||||
expect(store.roomId).toEqual("!1:example.org");
|
||||
expect(store.connected).toEqual(true);
|
||||
|
||||
// Our device should now appear as connected
|
||||
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
"!1:example.org",
|
||||
VIDEO_CHANNEL_MEMBER,
|
||||
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
|
||||
cli.getUserId(),
|
||||
);
|
||||
cli.sendStateEvent.mockClear();
|
||||
|
||||
// Our devices should be resent within the timeout period to prevent
|
||||
// the data from becoming stale
|
||||
jest.advanceTimersByTime(STUCK_DEVICE_TIMEOUT_MS);
|
||||
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
"!1:example.org",
|
||||
VIDEO_CHANNEL_MEMBER,
|
||||
{ devices: [cli.getDeviceId()], expires_ts: expect.any(Number) },
|
||||
cli.getUserId(),
|
||||
);
|
||||
cli.sendStateEvent.mockClear();
|
||||
|
||||
const disconnectPromise = store.disconnect();
|
||||
await confirmDisconnect();
|
||||
await expect(disconnectPromise).resolves.toBeUndefined();
|
||||
expect(store.roomId).toBeFalsy();
|
||||
expect(store.connected).toEqual(false);
|
||||
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
|
||||
|
||||
// Our device should now be marked as disconnected
|
||||
expect(cli.sendStateEvent).toHaveBeenLastCalledWith(
|
||||
"!1:example.org",
|
||||
VIDEO_CHANNEL_MEMBER,
|
||||
{ devices: [], expires_ts: expect.any(Number) },
|
||||
cli.getUserId(),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for messaging when connecting", async () => {
|
||||
const connectConfirmed = confirmConnect();
|
||||
const connectPromise = store.connect("!1:example.org", null, null);
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
|
||||
widgetReady();
|
||||
await connectConfirmed;
|
||||
await expect(connectPromise).resolves.toBeUndefined();
|
||||
expect(store.roomId).toEqual("!1:example.org");
|
||||
expect(store.connected).toEqual(true);
|
||||
|
||||
store.disconnect();
|
||||
await confirmDisconnect();
|
||||
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
|
||||
});
|
||||
|
||||
it("rejects if the widget's messaging gets stopped mid-connect", async () => {
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
|
||||
widgetReady();
|
||||
expect(store.roomId).toBeFalsy();
|
||||
expect(store.connected).toEqual(false);
|
||||
|
||||
const requestPromise = getRequest();
|
||||
const connectPromise = store.connect("!1:example.org", null, null);
|
||||
// Wait for the store to contact the widget API, then stop the messaging
|
||||
await requestPromise;
|
||||
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
|
||||
await expect(connectPromise).rejects.toBeDefined();
|
||||
expect(store.roomId).toBeFalsy();
|
||||
expect(store.connected).toEqual(false);
|
||||
});
|
||||
|
||||
it("switches to spotlight mode when the widget becomes a PiP", async () => {
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
|
||||
widgetReady();
|
||||
confirmConnect();
|
||||
await store.connect("!1:example.org", null, null);
|
||||
|
||||
const request = getRequest<IWidgetApiRequestData>();
|
||||
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
|
||||
const [action, data] = await request;
|
||||
expect(action).toEqual(ElementWidgetActions.SpotlightLayout);
|
||||
expect(data).toEqual({});
|
||||
|
||||
store.disconnect();
|
||||
await confirmDisconnect();
|
||||
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
|
||||
});
|
||||
});
|
|
@ -38,21 +38,27 @@ describe('VoiceRecordingStore', () => {
|
|||
[room3Id]: undefined,
|
||||
};
|
||||
|
||||
const mkStore = (): VoiceRecordingStore => {
|
||||
const store = new VoiceRecordingStore();
|
||||
store.start();
|
||||
return store;
|
||||
};
|
||||
|
||||
describe('startRecording()', () => {
|
||||
it('throws when roomId is falsy', () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
expect(() => store.startRecording(undefined)).toThrow("Recording must be associated with a room");
|
||||
});
|
||||
|
||||
it('throws when room already has a recording', () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
// @ts-ignore
|
||||
store.storeState = state;
|
||||
expect(() => store.startRecording(room2Id)).toThrow("A recording is already in progress");
|
||||
});
|
||||
|
||||
it('creates and adds recording to state', async () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
const result = store.startRecording(room2Id);
|
||||
|
||||
await flushPromises();
|
||||
|
@ -64,7 +70,7 @@ describe('VoiceRecordingStore', () => {
|
|||
|
||||
describe('disposeRecording()', () => {
|
||||
it('destroys recording for a room if it exists in state', async () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
// @ts-ignore
|
||||
store.storeState = state;
|
||||
|
||||
|
@ -74,7 +80,7 @@ describe('VoiceRecordingStore', () => {
|
|||
});
|
||||
|
||||
it('removes room from state when it has a recording', async () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
// @ts-ignore
|
||||
store.storeState = state;
|
||||
|
||||
|
@ -84,7 +90,7 @@ describe('VoiceRecordingStore', () => {
|
|||
});
|
||||
|
||||
it('removes room from state when it has a falsy recording', async () => {
|
||||
const store = new VoiceRecordingStore();
|
||||
const store = mkStore();
|
||||
// @ts-ignore
|
||||
store.storeState = state;
|
||||
|
||||
|
|
|
@ -14,51 +14,91 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { stubClient, stubVideoChannelStore, mkRoom } from "../../../test-utils";
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { ClientWidgetApi } from "matrix-widget-api";
|
||||
import { stubClient, setupAsyncStoreWithClient, useMockedCalls, MockedCall } from "../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list/models";
|
||||
import { SortAlgorithm, ListAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
|
||||
import "../../../../src/stores/room-list/RoomListStore"; // must be imported before Algorithm to avoid cycles
|
||||
import { Algorithm } from "../../../../src/stores/room-list/algorithms/Algorithm";
|
||||
import { CallStore } from "../../../../src/stores/CallStore";
|
||||
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
|
||||
describe("Algorithm", () => {
|
||||
let videoChannelStore;
|
||||
let algorithm;
|
||||
let textRoom;
|
||||
let videoRoom;
|
||||
useMockedCalls();
|
||||
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let algorithm: Algorithm;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
const cli = MatrixClientPeg.get();
|
||||
client = mocked(MatrixClientPeg.get());
|
||||
DMRoomMap.makeShared();
|
||||
videoChannelStore = stubVideoChannelStore();
|
||||
|
||||
algorithm = new Algorithm();
|
||||
algorithm.start();
|
||||
|
||||
textRoom = mkRoom(cli, "!text:example.org");
|
||||
videoRoom = mkRoom(cli, "!video:example.org");
|
||||
videoRoom.isElementVideoRoom.mockReturnValue(true);
|
||||
algorithm.populateTags(
|
||||
{ [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic },
|
||||
{ [DefaultTagID.Untagged]: ListAlgorithm.Natural },
|
||||
);
|
||||
algorithm.setKnownRooms([textRoom, videoRoom]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
algorithm.stop();
|
||||
});
|
||||
|
||||
it("sticks video rooms to the top when they connect", () => {
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]);
|
||||
videoChannelStore.connect("!video:example.org");
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]);
|
||||
});
|
||||
it("sticks rooms with calls to the top when they're connected", async () => {
|
||||
const room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
const roomWithCall = new Room("!2:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
it("unsticks video rooms from the top when they disconnect", () => {
|
||||
videoChannelStore.connect("!video:example.org");
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([videoRoom, textRoom]);
|
||||
videoChannelStore.disconnect();
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([textRoom, videoRoom]);
|
||||
client.getRoom.mockImplementation(roomId => {
|
||||
switch (roomId) {
|
||||
case room.roomId: return room;
|
||||
case roomWithCall.roomId: return roomWithCall;
|
||||
default: return null;
|
||||
}
|
||||
});
|
||||
client.getRooms.mockReturnValue([room, roomWithCall]);
|
||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
||||
client.reEmitter.reEmit(roomWithCall, [RoomStateEvent.Events]);
|
||||
|
||||
for (const room of client.getRooms()) jest.spyOn(room, "getMyMembership").mockReturnValue("join");
|
||||
algorithm.setKnownRooms(client.getRooms());
|
||||
|
||||
setupAsyncStoreWithClient(CallStore.instance, client);
|
||||
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
|
||||
|
||||
MockedCall.create(roomWithCall, "1");
|
||||
const call = CallStore.instance.get(roomWithCall.roomId);
|
||||
if (call === null) throw new Error("Failed to create call");
|
||||
|
||||
const widget = new Widget(call.widget);
|
||||
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
|
||||
stop: () => {},
|
||||
} as unknown as ClientWidgetApi);
|
||||
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
value: { enumerateDevices: async () => [] },
|
||||
});
|
||||
|
||||
// End of setup
|
||||
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||
await call.connect();
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
|
||||
await call.disconnect();
|
||||
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,7 +18,7 @@ import { mocked } from "jest-mock";
|
|||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider";
|
||||
import CallHandler from "../../../../src/CallHandler";
|
||||
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
|
||||
import VoipUserMapper from "../../../../src/VoipUserMapper";
|
||||
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom";
|
||||
import { RoomListCustomisations } from "../../../../src/customisations/RoomList";
|
||||
|
@ -28,7 +28,7 @@ jest.mock("../../../../src/VoipUserMapper", () => ({
|
|||
sharedInstance: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../src/CallHandler", () => ({
|
||||
jest.mock("../../../../src/LegacyCallHandler", () => ({
|
||||
instance: {
|
||||
getSupportsVirtualRooms: jest.fn(),
|
||||
},
|
||||
|
@ -82,7 +82,7 @@ describe("VisibilityProvider", () => {
|
|||
describe("isRoomVisible", () => {
|
||||
describe("for a virtual room", () => {
|
||||
beforeEach(() => {
|
||||
mocked(CallHandler.instance.getSupportsVirtualRooms).mockReturnValue(true);
|
||||
mocked(LegacyCallHandler.instance.getSupportsVirtualRooms).mockReturnValue(true);
|
||||
mocked(mockVoipUserMapper.isVirtualRoom).mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue