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:
parent
9a065581e5
commit
6e86a14cc9
30 changed files with 1338 additions and 267 deletions
|
@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue