Show a lobby screen in video rooms (#8287)

* Show a lobby screen in video rooms

* Add connecting state

* Test VideoRoomView

* Test VideoLobby

* Get the local video stream with useAsyncMemo

* Clean up code review nits

* Explicitly state what !important is overriding

* Use spacing variables

* Wait for video channel messaging

* Update join button copy

* Show frame on both the lobby and widget

* Force dark theme for video lobby

* Wait for the widget to be ready

* Make VideoChannelStore constructor private

* Allow video lobby to shrink

* Add invite button to video room header

* Show connected members on lobby screen

* Make avatars in video lobby clickable

* Increase video channel store timeout

* Fix Jitsi Meet getting wedged on startup in Chrome and Safari

* Revert "Fix Jitsi Meet getting wedged on startup in Chrome and Safari"

This reverts commit 9f77b8c227c1a5bffa5d91b0c48bf3bbc44d4cec.

* Disable device buttons while connecting

* Factor RoomFacePile into a separate file

* Fix i18n lint

* Fix switching video channels while connected

* Properly limit number of connected members in face pile

* Fix CSS lint
This commit is contained in:
Robin 2022-04-20 11:03:33 -04:00 committed by GitHub
parent 9a065581e5
commit 6e86a14cc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1338 additions and 267 deletions

View file

@ -14,23 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
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 ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
import {
VIDEO_CHANNEL,
VIDEO_CHANNEL_MEMBER,
IVideoChannelMemberContent,
getVideoChannel,
} from "../utils/VideoChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
export enum VideoChannelEvent {
StartConnect = "start_connect",
Connect = "connect",
Disconnect = "disconnect",
Participants = "participants",
@ -43,10 +44,25 @@ export interface IJitsiParticipant {
participantId: string;
}
const TIMEOUT_MS = 16000;
// Wait until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
let listener;
const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener);
});
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener);
if (timedOut) throw new Error("Timed out");
};
/*
* 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;
public static get instance(): VideoChannelStore {
@ -56,65 +72,121 @@ export default class VideoChannelStore extends EventEmitter {
return VideoChannelStore._instance;
}
private readonly cli = MatrixClientPeg.get();
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
private activeChannel: ClientWidgetApi;
private _roomId: string;
private _participants: IJitsiParticipant[];
public get roomId(): string { return this._roomId; }
private set roomId(value: string) { this._roomId = value; }
public get roomId(): string {
return this._roomId;
}
private _connected = false;
public get connected(): boolean { return this._connected; }
private set connected(value: boolean) { this._connected = value; }
public get participants(): IJitsiParticipant[] {
return this._participants;
}
private _participants: IJitsiParticipant[] = [];
public get participants(): IJitsiParticipant[] { return this._participants; }
private set participants(value: IJitsiParticipant[]) { this._participants = value; }
public start = () => {
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate);
};
public connect = async (roomId: string, audioDevice: MediaDeviceInfo, videoDevice: MediaDeviceInfo) => {
if (this.activeChannel) await this.disconnect();
public stop = () => {
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate);
};
private setConnected = async (roomId: string) => {
const jitsi = getVideoChannel(roomId);
if (!jitsi) throw new Error(`No video channel in room ${roomId}`);
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);
const messagingStore = WidgetMessagingStore.instance;
let messaging = messagingStore.getMessagingForUid(jitsiUid);
if (!messaging) {
// The widget might still be initializing, so wait for it
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.StoreMessaging,
(uid: string, widgetApi: ClientWidgetApi) => {
if (uid === jitsiUid) {
messaging = widgetApi;
return true;
}
return false;
},
);
} catch (e) {
throw new Error(`Failed to bind video channel in room ${roomId}: ${e}`);
}
}
if (!messagingStore.isWidgetReady(jitsiUid)) {
// Wait for the widget to be ready to receive our join event
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.WidgetReady,
(uid: string) => uid === jitsiUid,
);
} catch (e) {
throw new Error(`Video channel in room ${roomId} never became ready: ${e}`);
}
}
this.activeChannel = messaging;
this._roomId = roomId;
this._participants = [];
this.roomId = roomId;
// Participant data will come down the event pipeline quickly, so prepare in advance
messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.activeChannel.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.activeChannel.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.emit(VideoChannelEvent.StartConnect, roomId);
this.emit(VideoChannelEvent.Connect);
// Actually perform the join
const waitForJoin = waitForEvent(
messaging,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
this.ack(ev);
return true;
},
);
messaging.transport.send(ElementWidgetActions.JoinCall, {
audioDevice: audioDevice?.label,
videoDevice: videoDevice?.label,
});
try {
await waitForJoin;
} catch (e) {
// If it timed out, clean up our advance preparations
this.activeChannel = null;
this.roomId = null;
messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.emit(VideoChannelEvent.Disconnect, roomId);
throw new Error(`Failed to join call in room ${roomId}: ${e}`);
}
this.connected = true;
messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.emit(VideoChannelEvent.Connect, roomId);
// Tell others that we're connected, by adding our device to room state
await this.updateDevices(devices => Array.from(new Set(devices).add(this.cli.getDeviceId())));
this.updateDevices(roomId, devices => Array.from(new Set(devices).add(this.matrixClient.getDeviceId())));
};
private setDisconnected = async () => {
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.activeChannel = null;
this._participants = null;
public disconnect = async () => {
if (!this.activeChannel) throw new Error("Not connected to any video channel");
const waitForDisconnect = waitForEvent(this, VideoChannelEvent.Disconnect);
this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {});
try {
// Tell others that we're disconnected, by removing our device from room state
await this.updateDevices(devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.cli.getDeviceId());
return Array.from(devicesSet);
});
} finally {
// Save this for last, since updateDevices needs the room ID
this._roomId = null;
this.emit(VideoChannelEvent.Disconnect);
await waitForDisconnect; // onHangup cleans up for us
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
};
@ -124,41 +196,41 @@ export default class VideoChannelStore extends EventEmitter {
this.activeChannel.transport.reply(ev.detail, {});
};
private updateDevices = async (fn: (devices: string[]) => string[]) => {
if (!this.roomId) {
logger.error("Tried to update devices while disconnected");
return;
}
const room = this.cli.getRoom(this.roomId);
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, this.cli.getUserId());
private updateDevices = async (roomId: string, fn: (devices: string[]) => string[]) => {
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(
this.roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(),
await this.matrixClient.sendStateEvent(
roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.matrixClient.getUserId(),
);
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
this.ack(ev);
await this.setDisconnected();
this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
const roomId = this.roomId;
this.activeChannel = null;
this.roomId = null;
this.connected = false;
this.participants = [];
this.emit(VideoChannelEvent.Disconnect, roomId);
// 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.matrixClient.getDeviceId());
return Array.from(devicesSet);
});
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
this._participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, ev.detail.data.participants);
this.participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants);
this.ack(ev);
};
private onActiveWidgetUpdate = async () => {
if (this.activeChannel) {
// We got disconnected from the previous video channel, so clean up
await this.setDisconnected();
}
// If the new active widget is a video channel, that means we joined
if (ActiveWidgetStore.instance.getPersistentWidgetId() === VIDEO_CHANNEL) {
await this.setConnected(ActiveWidgetStore.instance.getPersistentRoomId());
}
};
}

View file

@ -18,6 +18,7 @@ import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
WidgetReady = "io.element.widget_ready",
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
CallParticipants = "io.element.participants",

View file

@ -14,14 +14,20 @@
* limitations under the License.
*/
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import { ClientWidgetApi, Widget, IWidgetApiRequest } from "matrix-widget-api";
import { ElementWidgetActions } from "./ElementWidgetActions";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { EnhancedMap } from "../../utils/maps";
import WidgetUtils from "../../utils/WidgetUtils";
export enum WidgetMessagingStoreEvent {
StoreMessaging = "store_messaging",
WidgetReady = "widget_ready",
}
/**
* Temporary holding store for widget messaging instances. This is eventually
* going to be merged with a more complete WidgetStore, but for now it's
@ -31,6 +37,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
private static internalInstance = new WidgetMessagingStore();
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget UID, ClientWidgetAPi>
private readyWidgets = new Set<string>(); // widgets that have sent a WidgetReady event
public constructor() {
super(defaultDispatcher);
@ -51,11 +58,22 @@ 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);
widgetApi.once(`action:${ElementWidgetActions.WidgetReady}`, (ev: CustomEvent<IWidgetApiRequest>) => {
this.readyWidgets.add(uid);
this.emit(WidgetMessagingStoreEvent.WidgetReady, uid);
widgetApi.transport.reply(ev.detail, {}); // ack
});
this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi);
}
public stopMessaging(widget: Widget, roomId: string) {
this.widgetMap.remove(WidgetUtils.calcWidgetUid(widget.id, roomId))?.stop();
const uid = WidgetUtils.calcWidgetUid(widget.id, roomId);
this.widgetMap.remove(uid)?.stop();
this.readyWidgets.delete(uid);
}
public getMessaging(widget: Widget, roomId: string): ClientWidgetApi {
@ -64,7 +82,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
/**
* Stops the widget messaging instance for a given widget UID.
* @param {string} widgetId The widget UID.
* @param {string} widgetUid The widget UID.
*/
public stopMessagingByUid(widgetUid: string) {
this.widgetMap.remove(widgetUid)?.stop();
@ -72,11 +90,18 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
/**
* Gets the widget messaging class for a given widget UID.
* @param {string} widgetId The widget UID.
* @param {string} widgetUid The widget UID.
* @returns {ClientWidgetApi} The widget API, or a falsey value if not found.
* @deprecated Widget IDs are not globally unique.
*/
public getMessagingForUid(widgetUid: string): ClientWidgetApi {
return this.widgetMap.get(widgetUid);
}
/**
* @param {string} widgetUid The widget UID.
* @returns {boolean} Whether the widget has issued an ElementWidgetActions.WidgetReady event.
*/
public isWidgetReady(widgetUid: string): boolean {
return this.readyWidgets.has(widgetUid);
}
}