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

@ -0,0 +1,315 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 React, { createRef, useState } from "react";
import classNames from "classnames";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import LegacyCallContextMenu from "../../context_menus/LegacyCallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import { Alignment } from "../../elements/Tooltip";
import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
useContextMenu,
} from '../../../structures/ContextMenu';
import { _t } from "../../../../languageHandler";
import DeviceContextMenu from "../../context_menus/DeviceContextMenu";
import { MediaDeviceKindEnum } from "../../../../MediaDeviceHandler";
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
const CONTROLS_HIDE_DELAY = 2000;
interface IButtonProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title"> {
state: boolean;
className: string;
onLabel?: string;
offLabel?: string;
onClick: (event: React.MouseEvent) => void;
}
const LegacyCallViewToggleButton: React.FC<IButtonProps> = ({
children,
state: isOn,
className,
onLabel,
offLabel,
...props
}) => {
const classes = classNames("mx_LegacyCallViewButtons_button", className, {
mx_LegacyCallViewButtons_button_on: isOn,
mx_LegacyCallViewButtons_button_off: !isOn,
});
return (
<AccessibleTooltipButton
className={classes}
title={isOn ? onLabel : offLabel}
alignment={Alignment.Top}
{...props}
>
{ children }
</AccessibleTooltipButton>
);
};
interface IDropdownButtonProps extends IButtonProps {
deviceKinds: MediaDeviceKindEnum[];
}
const LegacyCallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceKinds, ...props }) => {
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
const [hoveringDropdown, setHoveringDropdown] = useState(false);
const classes = classNames("mx_LegacyCallViewButtons_button", "mx_LegacyCallViewButtons_dropdownButton", {
mx_LegacyCallViewButtons_dropdownButton_collapsed: !menuDisplayed,
});
const onClick = (event: React.MouseEvent): void => {
event.stopPropagation();
openMenu();
};
return (
<LegacyCallViewToggleButton inputRef={buttonRef} forceHide={menuDisplayed || hoveringDropdown} state={state} {...props}>
<LegacyCallViewToggleButton
className={classes}
onClick={onClick}
onHover={(hovering) => setHoveringDropdown(hovering)}
state={state}
/>
{ menuDisplayed && <DeviceContextMenu
{...alwaysAboveRightOf(buttonRef.current?.getBoundingClientRect())}
onFinished={closeMenu}
deviceKinds={deviceKinds}
/> }
</LegacyCallViewToggleButton>
);
};
interface IProps {
call: MatrixCall;
pipMode: boolean;
handlers: {
onHangupClick: () => void;
onScreenshareClick: () => void;
onToggleSidebarClick: () => void;
onMicMuteClick: () => void;
onVidMuteClick: () => void;
};
buttonsState: {
micMuted: boolean;
vidMuted: boolean;
sidebarShown: boolean;
screensharing: boolean;
};
buttonsVisibility: {
screensharing: boolean;
vidMute: boolean;
sidebar: boolean;
dialpad: boolean;
contextMenu: boolean;
};
}
interface IState {
visible: boolean;
showDialpad: boolean;
hoveringControls: boolean;
showMoreMenu: boolean;
}
export default class LegacyCallViewButtons extends React.Component<IProps, IState> {
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) {
super(props);
this.state = {
showDialpad: false,
hoveringControls: false,
showMoreMenu: false,
visible: true,
};
}
public componentDidMount(): void {
this.showControls();
}
public showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.visible) {
this.setState({
visible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onControlsHideTimer = (): void => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({ visible: false });
};
private onMouseEnter = (): void => {
this.setState({ hoveringControls: true });
};
private onMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
private closeDialpad = (): void => {
this.setState({ showDialpad: false });
};
private closeContextMenu = (): void => {
this.setState({ showMoreMenu: false });
};
public render(): JSX.Element {
const callControlsClasses = classNames("mx_LegacyCallViewButtons", {
mx_LegacyCallViewButtons_hidden: !this.state.visible,
});
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveLeftOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
// We mount the context menus as a as a child typically in order to include the
// context menus when fullscreening the call content.
// However, this does not work as well when the call is embedded in a
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
mountAsChild={!this.props.pipMode}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <LegacyCallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
mountAsChild={!this.props.pipMode}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return (
<div
className={callControlsClasses}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{ dialPad }
{ contextMenu }
{ this.props.buttonsVisibility.dialpad && <ContextMenuTooltipButton
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
/> }
<LegacyCallViewDropdownButton
state={!this.props.buttonsState.micMuted}
className="mx_LegacyCallViewButtons_button_mic"
onLabel={_t("Mute the microphone")}
offLabel={_t("Unmute the microphone")}
onClick={this.props.handlers.onMicMuteClick}
deviceKinds={[MediaDeviceKindEnum.AudioInput, MediaDeviceKindEnum.AudioOutput]}
/>
{ this.props.buttonsVisibility.vidMute && <LegacyCallViewDropdownButton
state={!this.props.buttonsState.vidMuted}
className="mx_LegacyCallViewButtons_button_vid"
onLabel={_t("Stop the camera")}
offLabel={_t("Start the camera")}
onClick={this.props.handlers.onVidMuteClick}
deviceKinds={[MediaDeviceKindEnum.VideoInput]}
/> }
{ this.props.buttonsVisibility.screensharing && <LegacyCallViewToggleButton
state={this.props.buttonsState.screensharing}
className="mx_LegacyCallViewButtons_button_screensharing"
onLabel={_t("Stop sharing your screen")}
offLabel={_t("Start sharing your screen")}
onClick={this.props.handlers.onScreenshareClick}
/> }
{ this.props.buttonsVisibility.sidebar && <LegacyCallViewToggleButton
state={this.props.buttonsState.sidebarShown}
className="mx_LegacyCallViewButtons_button_sidebar"
onLabel={_t("Hide sidebar")}
offLabel={_t("Show sidebar")}
onClick={this.props.handlers.onToggleSidebarClick}
/> }
{ this.props.buttonsVisibility.contextMenu && <ContextMenuTooltipButton
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
/> }
<AccessibleTooltipButton
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_hangup"
onClick={this.props.handlers.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
/>
</div>
);
}
}

View file

@ -0,0 +1,105 @@
/*
Copyright 2021 New Vector Ltd
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 { Room } from 'matrix-js-sdk/src/models/room';
import React from 'react';
import { _t } from '../../../../languageHandler';
import RoomAvatar from '../../avatars/RoomAvatar';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
interface LegacyCallControlsProps {
onExpand?: () => void;
onPin?: () => void;
onMaximize?: () => void;
}
const LegacyCallViewHeaderControls: React.FC<LegacyCallControlsProps> = ({ onExpand, onPin, onMaximize }) => {
return <div className="mx_LegacyCallViewHeader_controls">
{ onMaximize && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_fullscreen"
onClick={onMaximize}
title={_t("Fill Screen")}
/> }
{ onPin && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_pin"
onClick={onPin}
title={_t("Pin")}
/> }
{ onExpand && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_expand"
onClick={onExpand}
title={_t("Return to call")}
/> }
</div>;
};
interface ISecondaryCallInfoProps {
callRoom: Room;
}
const SecondaryCallInfo: React.FC<ISecondaryCallInfoProps> = ({ callRoom }) => {
return <span className="mx_LegacyCallViewHeader_secondaryCallInfo">
<RoomAvatar room={callRoom} height={16} width={16} />
<span className="mx_LegacyCallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: callRoom.name }) }
</span>
</span>;
};
interface LegacyCallViewHeaderProps {
pipMode: boolean;
callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
onExpand?: () => void;
onPin?: () => void;
onMaximize?: () => void;
}
const LegacyCallViewHeader: React.FC<LegacyCallViewHeaderProps> = ({
pipMode = false,
callRooms = [],
onPipMouseDown,
onExpand,
onPin,
onMaximize,
}) => {
const [callRoom, onHoldCallRoom] = callRooms;
const callRoomName = callRoom.name;
if (!pipMode) {
return <div className="mx_LegacyCallViewHeader">
<div className="mx_LegacyCallViewHeader_icon" />
<span className="mx_LegacyCallViewHeader_text">{ _t("Call") }</span>
<LegacyCallViewHeaderControls onMaximize={onMaximize} />
</div>;
}
return (
<div
className="mx_LegacyCallViewHeader mx_LegacyCallViewHeader_pip"
onMouseDown={onPipMouseDown}
>
<RoomAvatar room={callRoom} height={32} width={32} />
<div className="mx_LegacyCallViewHeader_callInfo">
<div className="mx_LegacyCallViewHeader_roomName">{ callRoomName }</div>
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
</div>
<LegacyCallViewHeaderControls onExpand={onExpand} onPin={onPin} onMaximize={onMaximize} />
</div>
);
};
export default LegacyCallViewHeader;