Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show a lobby screen in video rooms #8287

Merged
merged 35 commits into from Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5e215b5
Show a lobby screen in video rooms
robintown Apr 11, 2022
5c431c7
Add connecting state
robintown Apr 11, 2022
fb36fe0
Test VideoRoomView
robintown Apr 11, 2022
9a386cf
Test VideoLobby
robintown Apr 11, 2022
1032f07
Merge branch 'develop' into video-room-lobby
robintown Apr 12, 2022
c0ae630
Get the local video stream with useAsyncMemo
robintown Apr 12, 2022
861cce5
Clean up code review nits
robintown Apr 12, 2022
256ca1d
Explicitly state what !important is overriding
robintown Apr 12, 2022
b3522b5
Use spacing variables
robintown Apr 12, 2022
76822b8
Wait for video channel messaging
robintown Apr 12, 2022
5a9cbec
Update join button copy
robintown Apr 12, 2022
c1269d9
Show frame on both the lobby and widget
robintown Apr 12, 2022
cf4f3ef
Force dark theme for video lobby
robintown Apr 12, 2022
4f71425
Wait for the widget to be ready
robintown Apr 13, 2022
56ea403
Make VideoChannelStore constructor private
robintown Apr 13, 2022
4dc71a8
Merge branch 'develop' into video-room-lobby
robintown Apr 13, 2022
8b3c114
Allow video lobby to shrink
robintown Apr 13, 2022
e0d0ac9
Add invite button to video room header
robintown Apr 13, 2022
24d612f
Show connected members on lobby screen
robintown Apr 13, 2022
0d998a7
Make avatars in video lobby clickable
robintown Apr 13, 2022
a257abf
Merge branch 'develop' into video-room-lobby
robintown Apr 13, 2022
edecb6e
Merge branch 'develop' into video-room-lobby
robintown Apr 14, 2022
95213b1
Increase video channel store timeout
robintown Apr 14, 2022
9f77b8c
Fix Jitsi Meet getting wedged on startup in Chrome and Safari
robintown Apr 14, 2022
7b38e58
Merge branch 'develop' into video-room-lobby
robintown Apr 15, 2022
f43fb08
Revert "Fix Jitsi Meet getting wedged on startup in Chrome and Safari"
robintown Apr 15, 2022
fe99a88
Disable device buttons while connecting
robintown Apr 15, 2022
9529461
Factor RoomFacePile into a separate file
robintown Apr 15, 2022
bc43dec
Fix i18n lint
robintown Apr 15, 2022
b3c2cbd
Merge branch 'develop' into video-room-lobby
robintown Apr 18, 2022
1ea8ee2
Fix switching video channels while connected
robintown Apr 18, 2022
64efeb7
Merge branch 'develop' into video-room-lobby
robintown Apr 20, 2022
2147f82
Properly limit number of connected members in face pile
robintown Apr 20, 2022
324c96a
Merge branch 'develop' into video-room-lobby
robintown Apr 20, 2022
a15cd6c
Fix CSS lint
robintown Apr 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
53 changes: 39 additions & 14 deletions src/stores/VideoChannelStore.ts
Expand Up @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { EventEmitter } from "events";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";

import { MatrixClientPeg } from "../MatrixClientPeg";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
import {
Expand All @@ -27,6 +27,8 @@ import {
} from "../utils/VideoChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";

export enum VideoChannelEvent {
StartConnect = "start_connect",
Expand All @@ -45,7 +47,7 @@ export interface IJitsiParticipant {
/*
* Holds information about the currently active video channel.
*/
export default class VideoChannelStore extends EventEmitter {
export default class VideoChannelStore extends AsyncStoreWithClient<null> {
private static _instance: VideoChannelStore;
private static readonly TIMEOUT_MS = 8000;

Expand All @@ -56,7 +58,14 @@ export default class VideoChannelStore extends EventEmitter {
return VideoChannelStore._instance;
}

private readonly cli = MatrixClientPeg.get();
constructor() {
robintown marked this conversation as resolved.
Show resolved Hide resolved
super(defaultDispatcher);
}

protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}

private activeChannel: ClientWidgetApi;

private _roomId: string;
Expand All @@ -76,10 +85,26 @@ export default class VideoChannelStore extends EventEmitter {

const jitsi = getVideoChannel(roomId);
if (!jitsi) throw new Error(`No video channel in room ${roomId}`);

// TODO: Wait for messaging
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi));
if (!messaging) throw new Error(`Failed to bind video channel in room ${roomId}`);
const jitsiUid = WidgetUtils.getWidgetUid(jitsi);

let messaging = WidgetMessagingStore.instance.getMessagingForUid(jitsiUid);
if (!messaging) {
// The widget might still be initializing, so wait for it
let messagingListener;
const getMessaging = new Promise<void>(resolve => {
messagingListener = (uid: string, widgetApi: ClientWidgetApi) => {
if (uid === jitsiUid) {
messaging = widgetApi;
resolve();
}
};
WidgetMessagingStore.instance.on(UPDATE_EVENT, messagingListener);
});

const timedOut = await timeout(getMessaging, false, VideoChannelStore.TIMEOUT_MS) === false;
WidgetMessagingStore.instance.off(UPDATE_EVENT, messagingListener);
if (timedOut) throw new Error(`Failed to bind video channel in room ${roomId}`);
}

this.activeChannel = messaging;
this.roomId = roomId;
Expand Down Expand Up @@ -113,7 +138,7 @@ export default class VideoChannelStore extends EventEmitter {
this.emit(VideoChannelEvent.Connect, roomId);

// Tell others that we're connected, by adding our device to room state
this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.cli.getDeviceId())));
this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.matrixClient.getDeviceId())));
};

public disconnect = async () => {
Expand Down Expand Up @@ -145,12 +170,12 @@ export default class VideoChannelStore extends EventEmitter {
};

private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => {
const room = this.cli.getRoom(roomId);
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.cli.getUserId());
const room = this.matrixClient.getRoom(roomId);
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.matrixClient.getUserId());
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];

await this.cli.sendStateEvent(
roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(),
await this.matrixClient.sendStateEvent(
roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.matrixClient.getUserId(),
);
};

Expand All @@ -171,7 +196,7 @@ export default class VideoChannelStore extends EventEmitter {
// Tell others that we're disconnected, by removing our device from room state
await this.updateDevices(roomId, devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.cli.getDeviceId());
devicesSet.delete(this.matrixClient.getDeviceId());
return Array.from(devicesSet);
});
};
Expand Down
5 changes: 4 additions & 1 deletion src/stores/widgets/WidgetMessagingStore.ts
Expand Up @@ -21,6 +21,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { EnhancedMap } from "../../utils/maps";
import WidgetUtils from "../../utils/WidgetUtils";
import { UPDATE_EVENT } from "../AsyncStore";

/**
* Temporary holding store for widget messaging instances. This is eventually
Expand Down Expand Up @@ -51,7 +52,9 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {

public storeMessaging(widget: Widget, roomId: string, widgetApi: ClientWidgetApi) {
this.stopMessaging(widget, roomId);
this.widgetMap.set(WidgetUtils.calcWidgetUid(widget.id, roomId), widgetApi);
const uid = WidgetUtils.calcWidgetUid(widget.id, roomId);
this.widgetMap.set(uid, widgetApi);
this.emit(UPDATE_EVENT, uid, widgetApi);
}

public stopMessaging(widget: Widget, roomId: string) {
Expand Down
98 changes: 66 additions & 32 deletions test/stores/VideoChannelStore-test.ts
Expand Up @@ -14,81 +14,115 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api";
import { mocked } from "jest-mock";
import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api";

import { stubClient } from "../test-utils";
import WidgetStore from "../../src/stores/WidgetStore";
import { stubClient, setupAsyncStoreWithClient } from "../test-utils";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import WidgetStore, { IApp } from "../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore";
import { VIDEO_CHANNEL } from "../../src/utils/VideoChannelUtils";

describe("VideoChannelStore", () => {
const store = VideoChannelStore.instance;

const widget = { id: VIDEO_CHANNEL } as unknown as Widget;
const app = {
id: VIDEO_CHANNEL,
eventId: "$1:example.org",
roomId: "!1:example.org",
type: MatrixWidgetType.JitsiMeet,
url: "",
name: "Video channel",
creatorUserId: "@alice:example.org",
avatar_url: null,
} as IApp;

// Set up mocks to simulate the remote end of the widget API
let messageSent;
let messageSendMock;
let onceMock;
let messageSent: Promise<void>;
let messageSendMock: () => void;
let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => void) => void;
let messaging: ClientWidgetApi;
beforeEach(() => {
stubClient();
let resolveMessageSent;
const cli = MatrixClientPeg.get();
setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli);
setupAsyncStoreWithClient(store, cli);

let resolveMessageSent: () => void;
messageSent = new Promise(resolve => resolveMessageSent = resolve);
messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent());
onceMock = jest.fn();

jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{
id: VIDEO_CHANNEL,
eventId: "$1:example.org",
roomId: "!1:example.org",
type: MatrixWidgetType.JitsiMeet,
url: "",
name: "Video channel",
creatorUserId: "@alice:example.org",
avatar_url: null,
}]);
jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]);
messaging = {
on: () => {},
off: () => {},
stop: () => {},
once: onceMock,
transport: {
send: messageSendMock,
reply: () => {},
},
} as unknown as ClientWidgetApi);
} as unknown as ClientWidgetApi;
});

it("connects and disconnects", async () => {
const store = VideoChannelStore.instance;

expect(store.roomId).toBeFalsy();

store.connect("!1:example.org", null, null);
const confirmConnect = async () => {
// Wait for the store to contact the widget API
await messageSent;
// Then, locate the callback that will confirm the join
const [, join] = onceMock.mock.calls.find(([action]) =>
const [, join] = mocked(onceMock).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({ detail: {} });
join({ detail: {} } as unknown as CustomEvent<IWidgetApiRequest>);
await waitForConnect;
};

expect(store.roomId).toEqual("!1:example.org");

store.disconnect();
const confirmDisconnect = async () => {
// Locate the callback that will perform the hangup
const [, hangup] = onceMock.mock.calls.find(([action]) =>
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({ detail: {} });
hangup({ detail: {} } as unknown as CustomEvent<IWidgetApiRequest>);
await waitForHangup;
};

it("connects and disconnects", async () => {
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);

store.connect("!1:example.org", null, null);
await confirmConnect();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);

store.disconnect();
await confirmDisconnect();
expect(store.roomId).toBeFalsy();
expect(store.connected).toEqual(false);
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
});

it("waits for messaging when connecting", async () => {
store.connect("!1:example.org", null, null);
WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging);
await confirmConnect();
expect(store.roomId).toEqual("!1:example.org");
expect(store.connected).toEqual(true);

store.disconnect();
await confirmDisconnect();
WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org");
});
});