Update video rooms to new design specs (#8207)

* Remove radio component

* "Voice room" → "video room"

* Remove interactivity from video room tiles

* Update connection state when joining via widget

* Simplify room header buttons for video rooms

* Split out video room creation into a separate menu option

* Simplify room options for video rooms

* Update video room tile layout

* Tell the Jitsi widget whether it's a video channel

* Update tests

* "Voice" → "video" in more places

* Fix tests

* Re-add frame to immersive Jitsi widgets

* Comment ack

* Make updateDevices more readable

* Type FacePile
This commit is contained in:
Robin 2022-04-01 10:36:10 -04:00 committed by GitHub
parent 020c1c6f31
commit 1f64835fab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 798 additions and 1305 deletions

View file

@ -0,0 +1,164 @@
/*
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 { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import {
VIDEO_CHANNEL,
VIDEO_CHANNEL_MEMBER,
IVideoChannelMemberContent,
getVideoChannel,
} from "../utils/VideoChannelUtils";
import WidgetUtils from "../utils/WidgetUtils";
export enum VideoChannelEvent {
Connect = "connect",
Disconnect = "disconnect",
Participants = "participants",
}
export interface IJitsiParticipant {
avatarURL: string;
displayName: string;
formattedDisplayName: string;
participantId: string;
}
/*
* Holds information about the currently active video channel.
*/
export default class VideoChannelStore extends EventEmitter {
private static _instance: VideoChannelStore;
public static get instance(): VideoChannelStore {
if (!VideoChannelStore._instance) {
VideoChannelStore._instance = new VideoChannelStore();
}
return VideoChannelStore._instance;
}
private readonly cli = MatrixClientPeg.get();
private activeChannel: ClientWidgetApi;
private _roomId: string;
private _participants: IJitsiParticipant[];
public get roomId(): string {
return this._roomId;
}
public get participants(): IJitsiParticipant[] {
return this._participants;
}
public start = () => {
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate);
};
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}`);
this.activeChannel = messaging;
this._roomId = roomId;
this._participants = [];
this.activeChannel.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.activeChannel.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.emit(VideoChannelEvent.Connect);
// 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())));
};
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;
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);
}
};
private ack = (ev: CustomEvent<IWidgetApiRequest>) => {
// Even if we don't have a reply to a given widget action, we still need
// to give the widget API something to acknowledge receipt
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());
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];
await this.cli.sendStateEvent(
this.roomId, VIDEO_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(),
);
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
this.ack(ev);
await this.setDisconnected();
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
this._participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, 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

@ -1,267 +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 { EventEmitter } from "events";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
import {
VOICE_CHANNEL_MEMBER,
IVoiceChannelMemberContent,
getVoiceChannel,
} from "../utils/VoiceChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
export enum VoiceChannelEvent {
Connect = "connect",
Disconnect = "disconnect",
Participants = "participants",
MuteAudio = "mute_audio",
UnmuteAudio = "unmute_audio",
MuteVideo = "mute_video",
UnmuteVideo = "unmute_video",
}
export interface IJitsiParticipant {
avatarURL: string;
displayName: string;
formattedDisplayName: string;
participantId: string;
}
/*
* Holds information about the currently active voice channel.
*/
export default class VoiceChannelStore extends EventEmitter {
private static _instance: VoiceChannelStore;
private static readonly TIMEOUT = 8000;
public static get instance(): VoiceChannelStore {
if (!VoiceChannelStore._instance) {
VoiceChannelStore._instance = new VoiceChannelStore();
}
return VoiceChannelStore._instance;
}
private readonly cli = MatrixClientPeg.get();
private activeChannel: ClientWidgetApi;
private _roomId: string;
private _participants: IJitsiParticipant[];
private _audioMuted: boolean;
private _videoMuted: boolean;
public get roomId(): string {
return this._roomId;
}
public get participants(): IJitsiParticipant[] {
return this._participants;
}
public get audioMuted(): boolean {
return this._audioMuted;
}
public get videoMuted(): boolean {
return this._videoMuted;
}
public connect = async (roomId: string) => {
if (this.activeChannel) await this.disconnect();
const jitsi = getVoiceChannel(roomId);
if (!jitsi) throw new Error(`No voice channel in room ${roomId}`);
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi));
if (!messaging) throw new Error(`Failed to bind voice channel in room ${roomId}`);
this.activeChannel = messaging;
this._roomId = roomId;
// Participant data and mute state will come down the event pipeline very quickly,
// so prepare in advance
messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
messaging.on(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
messaging.on(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
messaging.on(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
messaging.on(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
// Actually perform the join
const waitForJoin = this.waitForAction(ElementWidgetActions.JoinCall);
messaging.transport.send(ElementWidgetActions.JoinCall, {});
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);
messaging.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
messaging.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
messaging.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
messaging.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
throw e;
}
messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.emit(VoiceChannelEvent.Connect);
// 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())));
};
public disconnect = async () => {
this.assertConnected();
const waitForHangup = this.waitForAction(ElementWidgetActions.HangupCall);
this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {});
await waitForHangup;
// onHangup cleans up for us
};
public muteAudio = async () => {
this.assertConnected();
const waitForMute = this.waitForAction(ElementWidgetActions.MuteAudio);
this.activeChannel.transport.send(ElementWidgetActions.MuteAudio, {});
await waitForMute;
};
public unmuteAudio = async () => {
this.assertConnected();
const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteAudio);
this.activeChannel.transport.send(ElementWidgetActions.UnmuteAudio, {});
await waitForUnmute;
};
public muteVideo = async () => {
this.assertConnected();
const waitForMute = this.waitForAction(ElementWidgetActions.MuteVideo);
this.activeChannel.transport.send(ElementWidgetActions.MuteVideo, {});
await waitForMute;
};
public unmuteVideo = async () => {
this.assertConnected();
const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteVideo);
this.activeChannel.transport.send(ElementWidgetActions.UnmuteVideo, {});
await waitForUnmute;
};
private assertConnected = () => {
if (!this.activeChannel) throw new Error("Not connected to any voice channel");
};
private waitForAction = async (action: ElementWidgetActions) => {
const wait = new Promise<void>(resolve =>
this.activeChannel.once(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
this.ack(ev);
resolve();
}),
);
if (await timeout(wait, false, VoiceChannelStore.TIMEOUT) === false) {
throw new Error("Communication with voice channel timed out");
}
};
private ack = (ev: CustomEvent<IWidgetApiRequest>) => {
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 devices = this.cli.getRoom(this.roomId)
.currentState.getStateEvents(VOICE_CHANNEL_MEMBER, this.cli.getUserId())
?.getContent<IVoiceChannelMemberContent>()?.devices ?? [];
await this.cli.sendStateEvent(
this.roomId, VOICE_CHANNEL_MEMBER, { devices: fn(devices) }, this.cli.getUserId(),
);
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
this.ack(ev);
this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
this.activeChannel = null;
this._participants = null;
this._audioMuted = null;
this._videoMuted = null;
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(VoiceChannelEvent.Disconnect);
}
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
this._participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VoiceChannelEvent.Participants, ev.detail.data.participants);
this.ack(ev);
};
private onMuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
this._audioMuted = true;
this.emit(VoiceChannelEvent.MuteAudio);
this.ack(ev);
};
private onUnmuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
this._audioMuted = false;
this.emit(VoiceChannelEvent.UnmuteAudio);
this.ack(ev);
};
private onMuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
this._videoMuted = true;
this.emit(VoiceChannelEvent.MuteVideo);
this.ack(ev);
};
private onUnmuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
this._videoMuted = false;
this.emit(VoiceChannelEvent.UnmuteVideo);
this.ack(ev);
};
}