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:
Robin
2022-08-30 15:13:39 -04:00
committed by GitHub
parent 50f6986f6c
commit 0d6a550c33
107 changed files with 2573 additions and 2157 deletions
+4
View File
@@ -44,6 +44,10 @@ export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<
})(dispatcher);
}
public async start(): Promise<void> {
await this.readyStore.start();
}
get matrixClient(): MatrixClient {
return this.readyStore.mxClient;
}
+5 -1
View File
@@ -45,7 +45,11 @@ interface IState {
* reported.
*/
export default class AutoRageshakeStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new AutoRageshakeStore();
private static readonly internalInstance = (() => {
const instance = new AutoRageshakeStore();
instance.start();
return instance;
})();
private constructor() {
super(defaultDispatcher, {
+5 -1
View File
@@ -37,7 +37,11 @@ interface IState {
}
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new BreadcrumbsStore();
private static readonly internalInstance = (() => {
const instance = new BreadcrumbsStore();
instance.start();
return instance;
})();
private waitingRooms: { roomId: string, addedTs: number }[] = [];
+185
View File
@@ -0,0 +1,185 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Call, CallEvent, ConnectionState } from "../models/Call";
export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Call = "call",
// Signals a change in the active calls
ActiveCalls = "active_calls",
}
export class CallStore extends AsyncStoreWithClient<{}> {
private static _instance: CallStore;
public static get instance(): CallStore {
if (!this._instance) {
this._instance = new CallStore();
this._instance.start();
}
return this._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
protected async onReady(): Promise<any> {
// We assume that the calls present in a room are a function of room
// state and room widgets, so we initialize the room map here and then
// update it whenever those change
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
this.matrixClient.on(ClientEvent.Room, this.onRoom);
this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
// If the room ID of a previously connected call is still in settings at
// this time, that's a sign that we failed to disconnect from it
// properly, and need to clean up after ourselves
const uncleanlyDisconnectedRoomIds = SettingsStore.getValue<string[]>("activeCallRoomIds");
if (uncleanlyDisconnectedRoomIds.length) {
await Promise.all([
...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => {
logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`);
await this.get(uncleanlyDisconnectedRoomId)?.clean();
}),
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []),
]);
}
}
protected async onNotReady(): Promise<any> {
for (const [call, listenerMap] of this.callListeners) {
// It's important that we remove the listeners before destroying the
// call, because otherwise the call's onDestroy callback would fire
// and immediately repopulate the map
for (const [event, listener] of listenerMap) call.off(event, listener);
call.destroy();
}
this.callListeners.clear();
this.calls.clear();
this.activeCalls = new Set();
this.matrixClient.off(ClientEvent.Room, this.onRoom);
this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
}
private _activeCalls: Set<Call> = new Set();
/**
* The calls to which the user is currently connected.
*/
public get activeCalls(): Set<Call> {
return this._activeCalls;
}
private set activeCalls(value: Set<Call>) {
this._activeCalls = value;
this.emit(CallStoreEvent.ActiveCalls, value);
// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, [...value].map(call => call.roomId));
}
private calls = new Map<string, Call>(); // Key is room ID
private callListeners = new Map<Call, Map<CallEvent, (...args: unknown[]) => unknown>>();
private updateRoom(room: Room) {
if (!this.calls.has(room.roomId)) {
const call = Call.get(room);
if (call) {
const onConnectionState = (state: ConnectionState) => {
if (state === ConnectionState.Connected) {
this.activeCalls = new Set([...this.activeCalls, call]);
} else if (state === ConnectionState.Disconnected) {
this.activeCalls = new Set([...this.activeCalls].filter(c => c !== call));
}
};
const onDestroy = () => {
this.calls.delete(room.roomId);
for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener);
this.updateRoom(room);
};
call.on(CallEvent.ConnectionState, onConnectionState);
call.on(CallEvent.Destroy, onDestroy);
this.calls.set(room.roomId, call);
this.callListeners.set(call, new Map<CallEvent, (...args: unknown[]) => unknown>([
[CallEvent.ConnectionState, onConnectionState],
[CallEvent.Destroy, onDestroy],
]));
}
this.emit(CallStoreEvent.Call, call, room.roomId);
}
}
/**
* Gets the call associated with the given room, if any.
* @param {string} roomId The room's ID.
* @returns {Call | null} The call.
*/
public get(roomId: string): Call | null {
return this.calls.get(roomId) ?? null;
}
private onRoom = (room: Room) => this.updateRoom(room);
private onRoomState = (event: MatrixEvent, state: RoomState) => {
// If there's already a call stored for this room, it's understood to
// still be valid until destroyed
if (!this.calls.has(state.roomId)) {
const room = this.matrixClient.getRoom(state.roomId);
// State events can arrive before the room does, when creating a room
if (room !== null) this.updateRoom(room);
}
};
private onWidgets = (roomId: string | null) => {
if (roomId === null) {
// This store happened to start before the widget store was done
// loading all rooms, so we need to initialize each room again
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
} else {
const room = this.matrixClient.getRoom(roomId);
// Widget updates can arrive before the room does, empirically
if (room !== null) this.updateRoom(room);
}
};
}
+8 -4
View File
@@ -30,10 +30,14 @@ interface IState {
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new ModalWidgetStore();
private modalInstance: IHandle<void[]> = null;
private openSourceWidgetId: string = null;
private openSourceWidgetRoomId: string = null;
private static readonly internalInstance = (() => {
const instance = new ModalWidgetStore();
instance.start();
return instance;
})();
private modalInstance: IHandle<void[]> | null = null;
private openSourceWidgetId: string | null = null;
private openSourceWidgetRoomId: string | null = null;
private constructor() {
super(defaultDispatcher, {});
+5 -1
View File
@@ -92,7 +92,11 @@ const getLocallyCreatedBeaconEventIds = (): string[] => {
return ids;
};
export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
private static internalInstance = new OwnBeaconStore();
private static readonly internalInstance = (() => {
const instance = new OwnBeaconStore();
instance.start();
return instance;
})();
// users beacons, keyed by event type
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<BeaconIdentifier>>();
+5 -1
View File
@@ -37,7 +37,11 @@ const KEY_DISPLAY_NAME = "mx_profile_displayname";
const KEY_AVATAR_URL = "mx_profile_avatar_url";
export class OwnProfileStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new OwnProfileStore();
private static readonly internalInstance = (() => {
const instance = new OwnProfileStore();
instance.start();
return instance;
})();
private monitoredUser: User;
+8 -7
View File
@@ -26,18 +26,19 @@ import { Action } from "../dispatcher/actions";
export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable {
protected matrixClient: MatrixClient;
private readonly dispatcherRef: string;
private dispatcherRef: string | null = null;
constructor(protected readonly dispatcher: Dispatcher<ActionPayload>) {
super();
}
public async start(): Promise<void> {
this.dispatcherRef = this.dispatcher.register(this.onAction);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// noinspection JSIgnoredPromiseFromCall
this.onReady();
const matrixClient = MatrixClientPeg.get();
if (matrixClient) {
this.matrixClient = matrixClient;
await this.onReady();
}
}
@@ -50,7 +51,7 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro
}
public destroy() {
this.dispatcher.unregister(this.dispatcherRef);
if (this.dispatcherRef !== null) this.dispatcher.unregister(this.dispatcherRef);
}
protected async onReady() {
-355
View File
@@ -1,355 +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 { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } 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",
}
export interface IJitsiParticipant {
avatarURL: string;
displayName: string;
formattedDisplayName: string;
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 AsyncStoreWithClient<null> {
private static _instance: VideoChannelStore;
public static get instance(): VideoChannelStore {
if (!VideoChannelStore._instance) {
VideoChannelStore._instance = new VideoChannelStore();
}
return VideoChannelStore._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
private activeChannel: ClientWidgetApi;
private resendDevicesTimer: number;
// This is persisted to settings so we can detect unclean disconnects
public get roomId(): string | null { return SettingsStore.getValue("videoChannelRoomId"); }
private set roomId(value: string | null) {
SettingsStore.setValue("videoChannelRoomId", null, SettingLevel.DEVICE, value);
}
private get room(): Room { return this.matrixClient.getRoom(this.roomId); }
private _connected = false;
public get connected(): boolean { return this._connected; }
private set connected(value: boolean) { this._connected = value; }
private _participants: IJitsiParticipant[] = [];
public get participants(): IJitsiParticipant[] { return this._participants; }
private set participants(value: IJitsiParticipant[]) { this._participants = value; }
public get audioMuted(): boolean { return SettingsStore.getValue("audioInputMuted"); }
public set audioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public get videoMuted(): boolean { return SettingsStore.getValue("videoInputMuted"); }
public set videoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
public connect = async (
roomId: string,
audioDevice: MediaDeviceInfo | null,
videoDevice: MediaDeviceInfo | null,
) => {
if (this.activeChannel) await this.disconnect();
const jitsi = getVideoChannel(roomId);
if (!jitsi) throw new Error(`No 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}`);
}
}
// Now that we got the messaging, we need a way to ensure that it doesn't get stopped
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const listener = (uid: string) => {
if (uid === jitsiUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = () => {
cleanup();
resolve();
};
const cleanup = () => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(VideoChannelEvent.Connect, done);
this.off(VideoChannelEvent.Disconnect, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(VideoChannelEvent.Connect, done);
this.on(VideoChannelEvent.Disconnect, done);
});
if (!messagingStore.isWidgetReady(jitsiUid)) {
// Wait for the widget to be ready to receive our join event
try {
await Promise.race([
waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.WidgetReady,
(uid: string) => uid === jitsiUid,
),
dontStopMessaging,
]);
} catch (e) {
throw new Error(`Video channel in room ${roomId} never became ready: ${e}`);
}
}
// Participant data and mute state will come down the event pipeline quickly, so prepare in advance
this.activeChannel = messaging;
this.roomId = roomId;
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);
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we also
// need to add the hangup listener now rather than later
messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.emit(VideoChannelEvent.StartConnect, roomId);
// Actually perform the join
const waitForJoin = waitForEvent(
messaging,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.ack(ev);
return true;
},
);
messaging.transport.send(ElementWidgetActions.JoinCall, {
audioDevice: audioDevice?.label ?? null,
videoDevice: videoDevice?.label ?? null,
});
try {
await Promise.race([waitForJoin, dontStopMessaging]);
} 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);
messaging.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (messaging.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
messaging.transport.send(ElementWidgetActions.ForceHangupCall, {});
}
this.emit(VideoChannelEvent.Disconnect, roomId);
throw new Error(`Failed to join call in room ${roomId}: ${e}`);
}
this.connected = true;
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.setDisconnected);
this.emit(VideoChannelEvent.Connect, roomId);
// Tell others that we're connected, by adding our device to room state
await addOurDevice(this.room);
// Re-add this device every so often so our video member event doesn't become stale
this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`);
await addOurDevice(this.room);
}, (STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
};
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 {
await waitForDisconnect; // onHangup cleans up for us
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
};
public setDisconnected = async () => {
const roomId = this.roomId;
const room = this.room;
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.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.setDisconnected);
clearInterval(this.resendDevicesTimer);
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 removeOurDevice(room);
};
private ack = (ev: CustomEvent<IWidgetApiRequest>, messaging = this.activeChannel) => {
// 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
messaging.transport.reply(ev.detail, {});
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
const messaging = this.activeChannel;
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (!this.connected) await waitForEvent(this, VideoChannelEvent.Connect);
await this.setDisconnected();
this.ack(ev, messaging);
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants);
this.ack(ev);
};
private onMuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = true;
this.ack(ev);
};
private onUnmuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = false;
this.ack(ev);
};
private onMuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = true;
this.ack(ev);
};
private onUnmuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = false;
this.ack(ev);
};
private onMyMembership = (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.activeChannel.transport.send(ElementWidgetActions.TileLayout, {});
};
private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.activeChannel.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
}
+4 -3
View File
@@ -33,10 +33,11 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): VoiceRecordingStore {
if (!VoiceRecordingStore.internalInstance) {
VoiceRecordingStore.internalInstance = new VoiceRecordingStore();
if (!this.internalInstance) {
this.internalInstance = new VoiceRecordingStore();
this.internalInstance.start();
}
return VoiceRecordingStore.internalInstance;
return this.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {
+6 -2
View File
@@ -36,7 +36,7 @@ export interface IApp extends IWidget {
roomId: string;
eventId: string;
// eslint-disable-next-line camelcase
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
}
interface IRoomWidgets {
@@ -46,7 +46,11 @@ interface IRoomWidgets {
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new WidgetStore();
private static readonly internalInstance = (() => {
const instance = new WidgetStore();
instance.start();
return instance;
})();
private widgetMap = new Map<string, IApp>(); // Key is widget Unique ID (UID)
private roomMap = new Map<string, IRoomWidgets>(); // Key is room ID
+4 -3
View File
@@ -44,10 +44,11 @@ export class EchoStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): EchoStore {
if (!EchoStore._instance) {
EchoStore._instance = new EchoStore();
if (!this._instance) {
this._instance = new EchoStore();
this._instance.start();
}
return EchoStore._instance;
return this._instance;
}
public get contexts(): EchoContext[] {
@@ -34,7 +34,11 @@ interface IState {}
export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator");
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private static readonly internalInstance = (() => {
const instance = new RoomNotificationStateStore();
instance.start();
return instance;
})();
private roomMap = new Map<Room, RoomNotificationState>();
private roomThreadsMap = new Map<Room, ThreadsRoomNotificationState>();
+4 -3
View File
@@ -394,10 +394,11 @@ export default class RightPanelStore extends ReadyWatchingStore {
}
public static get instance(): RightPanelStore {
if (!RightPanelStore.internalInstance) {
RightPanelStore.internalInstance = new RightPanelStore();
if (!this.internalInstance) {
this.internalInstance = new RightPanelStore();
this.internalInstance.start();
}
return RightPanelStore.internalInstance;
return this.internalInstance;
}
}
+11 -7
View File
@@ -25,9 +25,9 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { PollStartEventPreview } from "./previews/PollStartEventPreview";
import { TagID } from "./models";
import { CallInviteEventPreview } from "./previews/CallInviteEventPreview";
import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview";
import { CallHangupEvent } from "./previews/CallHangupEvent";
import { LegacyCallInviteEventPreview } from "./previews/LegacyCallInviteEventPreview";
import { LegacyCallAnswerEventPreview } from "./previews/LegacyCallAnswerEventPreview";
import { LegacyCallHangupEvent } from "./previews/LegacyCallHangupEvent";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
@@ -47,15 +47,15 @@ const PREVIEWS: Record<string, {
},
'm.call.invite': {
isState: false,
previewer: new CallInviteEventPreview(),
previewer: new LegacyCallInviteEventPreview(),
},
'm.call.answer': {
isState: false,
previewer: new CallAnswerEventPreview(),
previewer: new LegacyCallAnswerEventPreview(),
},
'm.call.hangup': {
isState: false,
previewer: new CallHangupEvent(),
previewer: new LegacyCallHangupEvent(),
},
'm.sticker': {
isState: false,
@@ -87,7 +87,11 @@ interface IState {
}
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new MessagePreviewStore();
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
instance.start();
return instance;
})();
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID|TAG_ANY, string|null>>();
+3 -2
View File
@@ -34,8 +34,9 @@ export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new RoomListLayoutStore();
this.internalInstance.start();
}
return RoomListLayoutStore.internalInstance;
}
+5 -3
View File
@@ -602,11 +602,13 @@ export default class RoomListStore {
private static internalInstance: Interface;
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new RoomListStoreClass();
if (!this.internalInstance) {
const instance = new RoomListStoreClass();
instance.start();
this.internalInstance = instance;
}
return RoomListStore.internalInstance;
return this.internalInstance;
}
}
+32 -34
View File
@@ -34,7 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import { VisibilityProvider } from "../filters/VisibilityProvider";
import VideoChannelStore, { VideoChannelEvent } from "../../VideoChannelStore";
import { CallStore, CallStoreEvent } from "../../CallStore";
/**
* Fired when the Algorithm has determined a list has been updated.
@@ -82,13 +82,11 @@ export class Algorithm extends EventEmitter {
public updatesInhibited = false;
public start() {
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public stop() {
VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public get stickyRoom(): Room {
@@ -106,7 +104,7 @@ export class Algorithm extends EventEmitter {
protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val;
this.recalculateStickyRoom();
this.recalculateVideoRoom();
this.recalculateActiveCallRooms();
}
protected get cachedRooms(): ITagMap {
@@ -143,7 +141,7 @@ export class Algorithm extends EventEmitter {
algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
public getListOrdering(tagId: TagID): ListAlgorithm {
@@ -162,7 +160,7 @@ export class Algorithm extends EventEmitter {
algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
private updateStickyRoom(val: Room) {
@@ -279,22 +277,20 @@ export class Algorithm extends EventEmitter {
// a room while filtering and it'll disappear. We don't update the filter earlier in
// this function simply because we don't have to.
this.recalculateStickyRoom();
this.recalculateVideoRoom(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateVideoRoom(lastStickyRoom.tag);
this.recalculateActiveCallRooms(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Update the stickiness of video rooms.
*/
public updateVideoRoom = () => {
// In case we're unsticking a video room, sort it back into natural order
private onActiveCalls = () => {
// In case we're unsticking a room, sort it back into natural order
this.recalculateStickyRoom();
this.recalculateVideoRoom();
// Update the stickiness of rooms with calls
this.recalculateActiveCallRooms();
if (this.updatesInhibited) return;
// This isn't in response to any particular RoomListStore update,
@@ -358,16 +354,16 @@ export class Algorithm extends EventEmitter {
}
/**
* Recalculate the position of any video rooms. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* Recalculate the position of any rooms with calls. If this is being called in
* relation to a specific tag being updated, it should be given to this function to
* optimize the call.
*
* This expects to be called *after* the sticky rooms are updated, and sticks the
* currently connected video room to the top of its tag.
* room with the currently active call to the top of its tag.
*
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateVideoRoom(updatedTag: TagID = null): void {
protected recalculateActiveCallRooms(updatedTag: TagID = null): void {
if (!updatedTag) {
// Assume all tags need updating
// We're not modifying the map here, so can safely rely on the cached values
@@ -376,24 +372,26 @@ export class Algorithm extends EventEmitter {
if (!tagId) {
throw new Error("Unexpected recursion: falsy tag");
}
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
return;
}
const videoRoomId = VideoChannelStore.instance.connected ? VideoChannelStore.instance.roomId : null;
if (videoRoomId) {
// We operate directly on the sticky rooms map
if (CallStore.instance.activeCalls.size) {
// We operate on the sticky rooms map
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
const rooms = this._cachedStickyRooms[updatedTag];
const videoRoomIdxInTag = rooms.findIndex(r => r.roomId === videoRoomId);
if (videoRoomIdxInTag < 0) return; // no-op
const videoRoom = rooms[videoRoomIdxInTag];
rooms.splice(videoRoomIdxInTag, 1);
rooms.unshift(videoRoom);
this._cachedStickyRooms[updatedTag] = rooms; // re-set because references aren't always safe
const activeRoomIds = new Set([...CallStore.instance.activeCalls].map(call => call.roomId));
const activeRooms: Room[] = [];
const inactiveRooms: Room[] = [];
for (const room of rooms) {
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
}
// Stick rooms with active calls to the top
this._cachedStickyRooms[updatedTag] = [...activeRooms, ...inactiveRooms];
}
}
@@ -666,7 +664,7 @@ export class Algorithm extends EventEmitter {
algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
this.recalculateVideoRoom(rmTag);
this.recalculateActiveCallRooms(rmTag);
}
for (const addTag of diff.added) {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
@@ -742,7 +740,7 @@ export class Algorithm extends EventEmitter {
// Flag that we've done something
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tag);
this.recalculateActiveCallRooms(tag);
changed = true;
}
@@ -16,7 +16,7 @@
import { Room } from "matrix-js-sdk/src/models/room";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import VoipUserMapper from "../../../VoipUserMapper";
@@ -44,7 +44,7 @@ export class VisibilityProvider {
}
if (
CallHandler.instance.getSupportsVirtualRooms() &&
LegacyCallHandler.instance.getSupportsVirtualRooms() &&
VoipUserMapper.sharedInstance().isVirtualRoom(room)
) {
return false;
@@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallAnswerEventPreview implements IPreview {
export class LegacyCallAnswerEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {
@@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallHangupEvent implements IPreview {
export class LegacyCallHangupEvent implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {
@@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallInviteEventPreview implements IPreview {
export class LegacyCallInviteEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {
+5 -1
View File
@@ -1284,7 +1284,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
export default class SpaceStore {
private static internalInstance = new SpaceStoreClass();
private static readonly internalInstance = (() => {
const instance = new SpaceStoreClass();
instance.start();
return instance;
})();
public static get instance(): SpaceStoreClass {
return SpaceStore.internalInstance;
+1 -5
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020-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.
@@ -17,13 +17,9 @@
import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
WidgetReady = "io.element.widget_ready",
// All of these actions are currently specific to Jitsi
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
ForceHangupCall = "io.element.force_hangup",
CallParticipants = "io.element.participants",
MuteAudio = "io.element.mute_audio",
UnmuteAudio = "io.element.unmute_audio",
+4 -3
View File
@@ -114,10 +114,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
}
public static get instance(): WidgetLayoutStore {
if (!WidgetLayoutStore.internalInstance) {
WidgetLayoutStore.internalInstance = new WidgetLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new WidgetLayoutStore();
this.internalInstance.start();
}
return WidgetLayoutStore.internalInstance;
return this.internalInstance;
}
public static emissionForRoom(room: Room): string {
+7 -21
View File
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { ClientWidgetApi, Widget, IWidgetApiRequest } from "matrix-widget-api";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import { ElementWidgetActions } from "./ElementWidgetActions";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
@@ -26,7 +25,6 @@ import WidgetUtils from "../../utils/WidgetUtils";
export enum WidgetMessagingStoreEvent {
StoreMessaging = "store_messaging",
StopMessaging = "stop_messaging",
WidgetReady = "widget_ready",
}
/**
@@ -34,11 +32,14 @@ export enum WidgetMessagingStoreEvent {
* going to be merged with a more complete WidgetStore, but for now it's
* easiest to split this into a single place.
*/
export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
private static internalInstance = new WidgetMessagingStore();
export class WidgetMessagingStore extends AsyncStoreWithClient<{}> {
private static readonly internalInstance = (() => {
const instance = new WidgetMessagingStore();
instance.start();
return instance;
})();
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);
@@ -62,12 +63,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
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);
}
@@ -85,7 +80,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
*/
public stopMessagingByUid(widgetUid: string) {
this.widgetMap.remove(widgetUid)?.stop();
this.readyWidgets.delete(widgetUid);
this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid);
}
@@ -97,12 +91,4 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
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);
}
}