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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2573 additions and 2157 deletions

View file

@ -21,7 +21,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
@ -325,7 +325,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.instance.getSupportsPstnProtocol()) {
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}

View file

@ -19,10 +19,10 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { EventEmitter } from 'events';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
export enum CallEventGrouperEvent {
export enum LegacyCallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
@ -44,10 +44,10 @@ export enum CustomCallState {
Missed = "missed",
}
export function buildCallEventGroupers(
callEventGroupers: Map<string, CallEventGrouper>,
export function buildLegacyCallEventGroupers(
callEventGroupers: Map<string, LegacyCallEventGrouper>,
events?: MatrixEvent[],
): Map<string, CallEventGrouper> {
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) {
@ -57,10 +57,10 @@ export function buildCallEventGroupers(
const callId = ev.getContent().call_id;
if (!newCallEventGroupers.has(callId)) {
if (callEventGroupers.has(callId)) {
// reuse the CallEventGrouper object where possible
// reuse the LegacyCallEventGrouper object where possible
newCallEventGroupers.set(callId, callEventGroupers.get(callId));
} else {
newCallEventGroupers.set(callId, new CallEventGrouper());
newCallEventGroupers.set(callId, new LegacyCallEventGrouper());
}
}
newCallEventGroupers.get(callId).add(ev);
@ -68,7 +68,7 @@ export function buildCallEventGroupers(
return newCallEventGroupers;
}
export default class CallEventGrouper extends EventEmitter {
export default class LegacyCallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
@ -76,8 +76,10 @@ export default class CallEventGrouper extends EventEmitter {
constructor() {
super();
CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
}
private get invite(): MatrixEvent {
@ -138,31 +140,31 @@ export default class CallEventGrouper extends EventEmitter {
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.instance.isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
const newState = LegacyCallHandler.instance.isCallSilenced(this.callId);
this.emit(LegacyCallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
this.emit(LegacyCallEventGrouperEvent.LengthChanged, length);
};
public answerCall = (): void => {
CallHandler.instance.answerCall(this.roomId);
LegacyCallHandler.instance.answerCall(this.roomId);
};
public rejectCall = (): void => {
CallHandler.instance.hangupOrReject(this.roomId, true);
LegacyCallHandler.instance.hangupOrReject(this.roomId, true);
};
public callBack = (): void => {
CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
LegacyCallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
const silenced = CallHandler.instance.isCallSilenced(this.callId);
const silenced = LegacyCallHandler.instance.isCallSilenced(this.callId);
silenced ?
CallHandler.instance.unSilenceCall(this.callId) :
CallHandler.instance.silenceCall(this.callId);
LegacyCallHandler.instance.unSilenceCall(this.callId) :
LegacyCallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
@ -182,13 +184,13 @@ export default class CallEventGrouper extends EventEmitter {
else if (this.hangup) this.state = CallState.Ended;
else if (this.invite && this.call) this.state = CallState.Connecting;
}
this.emit(CallEventGrouperEvent.StateChanged, this.state);
this.emit(LegacyCallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
this.call = CallHandler.instance.getCallById(this.callId);
this.call = LegacyCallHandler.instance.getCallById(this.callId);
this.setCallListeners();
this.setState();
};

View file

@ -51,8 +51,8 @@ import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import AudioFeedArrayForLegacyCall from '../views/voip/AudioFeedArrayForLegacyCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
@ -146,7 +146,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.instance.getAllActiveCalls(),
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -163,7 +163,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -195,7 +195,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
this._matrixClient.removeListener(ClientEvent.Sync, this.onSync);
this._matrixClient.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
@ -207,7 +207,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
private onCallState = (): void => {
const activeCalls = CallHandler.instance.getAllActiveCalls();
const activeCalls = LegacyCallHandler.instance.getAllActiveCalls();
if (activeCalls === this.state.activeCalls) return;
this.setState({ activeCalls });
};
@ -658,7 +658,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
<AudioFeedArrayForLegacyCall call={call} key={call.callId} />
);
});

View file

@ -114,7 +114,7 @@ import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { showSpaceInvite } from "../../utils/space";
import AccessibleButton from "../views/elements/AccessibleButton";
import { ActionPayload } from "../../dispatcher/payloads";
@ -128,7 +128,7 @@ import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStart
import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
import VideoChannelStore from "../../stores/VideoChannelStore";
import { CallStore } from "../../stores/CallStore";
import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
@ -576,9 +576,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
CallHandler.instance.hangupAllCalls();
if (VideoChannelStore.instance.connected) VideoChannelStore.instance.setDisconnected();
Lifecycle.logout();
LegacyCallHandler.instance.hangupAllCalls();
Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()))
.finally(() => Lifecycle.logout());
break;
case 'require_registration':
startAnyRegistrationFlow(payload as any);

View file

@ -40,7 +40,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import HistoryTile from "../views/rooms/HistoryTile";
import defaultDispatcher from '../../dispatcher/dispatcher';
import CallEventGrouper from "./CallEventGrouper";
import LegacyCallEventGrouper from "./LegacyCallEventGrouper";
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import GenericEventListSummary from '../views/elements/GenericEventListSummary';
@ -188,7 +188,7 @@ interface IProps {
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
callEventGroupers: Map<string, CallEventGrouper>;
callEventGroupers: Map<string, LegacyCallEventGrouper>;
}
interface IState {

View file

@ -45,7 +45,7 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import dis, { defaultDispatcher } from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
@ -78,7 +78,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import WidgetStore from "../../stores/WidgetStore";
import VideoRoomView from "./VideoRoomView";
import { VideoRoomView } from "./VideoRoomView";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
@ -810,7 +810,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
callState: callState,
});
CallHandler.instance.on(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState);
window.addEventListener('beforeunload', this.onPageUnload);
}
@ -847,7 +847,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
// update the scroll map before we get unmounted
if (this.state.roomId) {
@ -896,7 +896,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
}
CallHandler.instance.off(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState);
// cancel any pending calls to the throttled updated
this.updateRoomMembers.cancel();
@ -1655,7 +1655,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onCallPlaced = (type: CallType): void => {
CallHandler.instance.placeCall(this.state.room?.roomId, type);
LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => {
@ -1872,7 +1872,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) {
return null;
}
return CallHandler.instance.getCallForRoom(this.state.room.roomId);
return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId);
}
// this has to be a proper method rather than an unnamed function,

View file

@ -52,7 +52,7 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import CallEventGrouper, { buildCallEventGroupers } from "./CallEventGrouper";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
@ -240,8 +240,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readReceiptActivityTimer: Timer;
private readMarkerActivityTimer: Timer;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
constructor(props, context) {
super(props, context);
@ -493,7 +493,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.timelineWindow.unpaginate(count, backwards);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
events,
liveEvents,
@ -555,7 +555,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
debuglog("paginate complete backwards:"+backwards+"; success:"+r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
[paginatingKey]: false,
[canPaginateKey]: r,
@ -686,7 +686,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) { return; }
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
@ -855,7 +855,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// TODO: We should restrict this to only events in our timeline,
// but possibly the event tile itself should just update when this
// happens to save us re-rendering the whole timeline.
this.buildCallEventGroupers(this.state.events);
this.buildLegacyCallEventGroupers(this.state.events);
this.forceUpdate();
};
@ -1405,7 +1405,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
onLoaded();
} else {
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
this.buildCallEventGroupers();
this.buildLegacyCallEventGroupers();
this.setState({
events: [],
liveEvents: [],
@ -1426,7 +1426,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return;
const state = this.getEvents();
this.buildCallEventGroupers(state.events);
this.buildLegacyCallEventGroupers(state.events);
this.setState(state);
}
@ -1707,8 +1707,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
eventType: EventType | string,
) => this.props.timelineSet.relations?.getChildEventsForEvent(eventId, relationType, eventType);
private buildCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildCallEventGroupers(this.callEventGroupers, events);
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
render() {

View file

@ -14,79 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useContext, useState, useMemo, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { FC, useContext, useEffect } from "react";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { Call } from "../../models/Call";
import { useCall, useConnectionState } from "../../hooks/useCall";
import { isConnected } from "../../models/Call";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import WidgetUtils from "../../utils/WidgetUtils";
import { addVideoChannel, getVideoChannel, fixStuckDevices } from "../../utils/VideoChannelUtils";
import WidgetStore, { IApp } from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
import AppTile from "../views/elements/AppTile";
import VideoLobby from "../views/voip/VideoLobby";
import { CallLobby } from "../views/voip/CallLobby";
interface IProps {
interface Props {
room: Room;
resizing: boolean;
}
const VideoRoomView: FC<IProps> = ({ room, resizing }) => {
const LoadedVideoRoomView: FC<Props & { call: Call }> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext);
const store = VideoChannelStore.instance;
const connected = isConnected(useConnectionState(call));
// In case we mount before the WidgetStore knows about our Jitsi widget
const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient));
const [widgetLoaded, setWidgetLoaded] = useState(false);
useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => {
if (roomId === null) setWidgetStoreReady(true);
if (roomId === null || roomId === room.roomId) {
setWidgetLoaded(Boolean(getVideoChannel(room.roomId)));
}
});
// We'll take this opportunity to tidy up our room state
useEffect(() => { call?.clean(); }, [call]);
const app: IApp = useMemo(() => {
if (widgetStoreReady) {
const app = getVideoChannel(room.roomId);
if (!app) {
logger.warn(`No video channel for room ${room.roomId}`);
// Since widgets in video rooms are mutable, we'll take this opportunity to
// reinstate the Jitsi widget in case another client removed it
if (WidgetUtils.canUserModifyWidgets(room.roomId)) {
addVideoChannel(room.roomId, room.name);
}
}
return app;
}
}, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// We'll also take this opportunity to fix any stuck devices.
// The linter thinks that store.connected should be a dependency, but we explicitly
// *only* want this to happen at mount to avoid racing with normal device updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fixStuckDevices(room, store.connected); }, [room]);
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));
useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false));
if (!app) return null;
if (!call) return null;
return <div className="mx_VideoRoomView">
{ connected ? null : <VideoLobby room={room} /> }
{ connected ? null : <CallLobby room={room} call={call} /> }
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
<AppTile
app={app}
app={call.widget}
room={room}
userId={cli.credentials.userId}
creatorUserId={app.creatorUserId}
waitForIframeLoad={app.waitForIframeLoad}
creatorUserId={call.widget.creatorUserId}
waitForIframeLoad={call.widget.waitForIframeLoad}
showMenubar={false}
pointerEvents={resizing ? "none" : null}
pointerEvents={resizing ? "none" : undefined}
/>
</div>;
};
export default VideoRoomView;
export const VideoRoomView: FC<Props> = ({ room, resizing }) => {
const call = useCall(room.roomId);
return call ? <LoadedVideoRoomView room={room} resizing={resizing} call={call} /> : null;
};