Merge remote-tracking branch 'upstream/develop' into feature/incoming-call-toast
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
commit
d479373af9
182 changed files with 5872 additions and 2200 deletions
|
@ -23,9 +23,21 @@ interface IProps {
|
|||
feed: CallFeed;
|
||||
}
|
||||
|
||||
export default class AudioFeed extends React.Component<IProps> {
|
||||
interface IState {
|
||||
audioMuted: boolean;
|
||||
}
|
||||
|
||||
export default class AudioFeed extends React.Component<IProps, IState> {
|
||||
private element = createRef<HTMLAudioElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
MediaDeviceHandler.instance.addListener(
|
||||
MediaDeviceHandlerEvent.AudioOutputChanged,
|
||||
|
@ -62,6 +74,7 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
|
||||
private playMedia() {
|
||||
const element = this.element.current;
|
||||
if (!element) return;
|
||||
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
|
||||
element.muted = false;
|
||||
element.srcObject = this.props.feed.stream;
|
||||
|
@ -85,6 +98,7 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
|
||||
private stopMedia() {
|
||||
const element = this.element.current;
|
||||
if (!element) return;
|
||||
|
||||
element.pause();
|
||||
element.src = null;
|
||||
|
@ -96,10 +110,16 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
private onNewStream = () => {
|
||||
this.setState({
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
});
|
||||
this.playMedia();
|
||||
};
|
||||
|
||||
render() {
|
||||
// Do not render the audio element if there is no audio track
|
||||
if (this.state.audioMuted) return null;
|
||||
|
||||
return (
|
||||
<audio ref={this.element} />
|
||||
);
|
||||
|
|
|
@ -146,7 +146,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
document.addEventListener("mousemove", this.onMoving);
|
||||
document.addEventListener("mouseup", this.onEndMoving);
|
||||
window.addEventListener("resize", this.snap);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
}
|
||||
|
@ -156,7 +156,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
document.removeEventListener("mousemove", this.onMoving);
|
||||
document.removeEventListener("mouseup", this.onEndMoving);
|
||||
window.removeEventListener("resize", this.snap);
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
if (this.roomStoreToken) {
|
||||
this.roomStoreToken.remove();
|
||||
}
|
||||
|
@ -164,6 +164,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
SettingsStore.unwatchSetting(this.settingsWatcherRef);
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
this.snap(false);
|
||||
};
|
||||
|
||||
private animationCallback = () => {
|
||||
// If the PiP isn't being dragged and there is only a tiny difference in
|
||||
// the desiredTranslation and translation, quit the animationCallback
|
||||
|
@ -207,7 +211,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
private snap = () => {
|
||||
private snap(animate?: boolean): void {
|
||||
const translationX = this.desiredTranslationX;
|
||||
const translationY = this.desiredTranslationY;
|
||||
// We subtract the PiP size from the window size in order to calculate
|
||||
|
@ -236,10 +240,17 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
this.desiredTranslationY = PADDING.top;
|
||||
}
|
||||
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
if (animate) {
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
} else {
|
||||
this.setState({
|
||||
translationX: this.desiredTranslationX,
|
||||
translationY: this.desiredTranslationY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomViewStoreUpdate = () => {
|
||||
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
||||
|
@ -310,7 +321,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
|
||||
private onEndMoving = () => {
|
||||
this.moving = false;
|
||||
this.snap();
|
||||
this.snap(true);
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
@ -333,6 +344,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
secondaryCall={this.state.secondaryCall}
|
||||
pipMode={true}
|
||||
onMouseDownOnHeader={this.onStartMoving}
|
||||
onResize={this.onResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019, 2020 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.
|
||||
|
@ -32,6 +33,10 @@ import { avatarUrlForMember } from '../../../Avatar';
|
|||
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
|
||||
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
|
||||
import Modal from '../../../Modal';
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
import CallViewSidebar from './CallViewSidebar';
|
||||
|
||||
interface IProps {
|
||||
// The call for us to display
|
||||
|
@ -59,11 +64,15 @@ interface IState {
|
|||
isRemoteOnHold: boolean;
|
||||
micMuted: boolean;
|
||||
vidMuted: boolean;
|
||||
screensharing: boolean;
|
||||
callState: CallState;
|
||||
controlsVisible: boolean;
|
||||
hoveringControls: boolean;
|
||||
showMoreMenu: boolean;
|
||||
showDialpad: boolean;
|
||||
feeds: CallFeed[];
|
||||
primaryFeed: CallFeed;
|
||||
secondaryFeeds: Array<CallFeed>;
|
||||
sidebarShown: boolean;
|
||||
}
|
||||
|
||||
function getFullScreenElement() {
|
||||
|
@ -94,7 +103,7 @@ function exitFullscreen() {
|
|||
if (exitMethod) exitMethod.call(document);
|
||||
}
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 1000;
|
||||
const CONTROLS_HIDE_DELAY = 2000;
|
||||
// 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)
|
||||
|
@ -106,20 +115,27 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
private controlsHideTimer: number = null;
|
||||
private dialpadButton = createRef<HTMLDivElement>();
|
||||
private contextMenuButton = createRef<HTMLDivElement>();
|
||||
private contextMenu = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const { primary, secondary } = this.getOrderedFeeds(this.props.call.getFeeds());
|
||||
|
||||
this.state = {
|
||||
isLocalOnHold: this.props.call.isLocalOnHold(),
|
||||
isRemoteOnHold: this.props.call.isRemoteOnHold(),
|
||||
micMuted: this.props.call.isMicrophoneMuted(),
|
||||
vidMuted: this.props.call.isLocalVideoMuted(),
|
||||
screensharing: this.props.call.isScreensharing(),
|
||||
callState: this.props.call.state,
|
||||
controlsVisible: true,
|
||||
hoveringControls: false,
|
||||
showMoreMenu: false,
|
||||
showDialpad: false,
|
||||
feeds: this.props.call.getFeeds(),
|
||||
primaryFeed: primary,
|
||||
secondaryFeeds: secondary,
|
||||
sidebarShown: true,
|
||||
};
|
||||
|
||||
this.updateCallListeners(null, this.props.call);
|
||||
|
@ -194,7 +210,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
|
||||
this.setState({ feeds: newFeeds });
|
||||
const { primary, secondary } = this.getOrderedFeeds(newFeeds);
|
||||
this.setState({
|
||||
primaryFeed: primary,
|
||||
secondaryFeeds: secondary,
|
||||
});
|
||||
};
|
||||
|
||||
private onCallLocalHoldUnhold = () => {
|
||||
|
@ -227,6 +247,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onControlsHideTimer = () => {
|
||||
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
|
||||
this.controlsHideTimer = null;
|
||||
this.setState({
|
||||
controlsVisible: false,
|
||||
|
@ -237,7 +258,30 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.showControls();
|
||||
};
|
||||
|
||||
private showControls() {
|
||||
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
|
||||
let primary;
|
||||
|
||||
// Try to use a screensharing as primary, a remote one if possible
|
||||
const screensharingFeeds = feeds.filter((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
|
||||
primary = screensharingFeeds.find((feed) => !feed.isLocal()) || screensharingFeeds[0];
|
||||
// If we didn't find remote screen-sharing stream, try to find any remote stream
|
||||
if (!primary) {
|
||||
primary = feeds.find((feed) => !feed.isLocal());
|
||||
}
|
||||
|
||||
const secondary = [...feeds];
|
||||
// Remove the primary feed from the array
|
||||
if (primary) secondary.splice(secondary.indexOf(primary), 1);
|
||||
secondary.sort((a, b) => {
|
||||
if (a.isLocal() && !b.isLocal()) return -1;
|
||||
if (!a.isLocal() && b.isLocal()) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
private showControls(): void {
|
||||
if (this.state.showMoreMenu || this.state.showDialpad) return;
|
||||
|
||||
if (!this.state.controlsVisible) {
|
||||
|
@ -251,73 +295,62 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
private onDialpadClick = () => {
|
||||
private onDialpadClick = (): void => {
|
||||
if (!this.state.showDialpad) {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
showDialpad: true,
|
||||
controlsVisible: true,
|
||||
});
|
||||
this.setState({ showDialpad: true });
|
||||
this.showControls();
|
||||
} else {
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
|
||||
this.setState({
|
||||
showDialpad: false,
|
||||
});
|
||||
this.setState({ showDialpad: false });
|
||||
}
|
||||
};
|
||||
|
||||
private onMicMuteClick = () => {
|
||||
private onMicMuteClick = (): void => {
|
||||
const newVal = !this.state.micMuted;
|
||||
|
||||
this.props.call.setMicrophoneMuted(newVal);
|
||||
this.setState({ micMuted: newVal });
|
||||
};
|
||||
|
||||
private onVidMuteClick = () => {
|
||||
private onVidMuteClick = (): void => {
|
||||
const newVal = !this.state.vidMuted;
|
||||
|
||||
this.props.call.setLocalVideoMuted(newVal);
|
||||
this.setState({ vidMuted: newVal });
|
||||
};
|
||||
|
||||
private onMoreClick = () => {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
private onScreenshareClick = async (): Promise<void> => {
|
||||
const isScreensharing = await this.props.call.setScreensharingEnabled(
|
||||
!this.state.screensharing,
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
},
|
||||
);
|
||||
|
||||
this.setState({
|
||||
showMoreMenu: true,
|
||||
controlsVisible: true,
|
||||
sidebarShown: true,
|
||||
screensharing: isScreensharing,
|
||||
});
|
||||
};
|
||||
|
||||
private closeDialpad = () => {
|
||||
this.setState({
|
||||
showDialpad: false,
|
||||
});
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
private onMoreClick = (): void => {
|
||||
this.setState({ showMoreMenu: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private closeContextMenu = () => {
|
||||
this.setState({
|
||||
showMoreMenu: false,
|
||||
});
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
private closeDialpad = (): void => {
|
||||
this.setState({ showDialpad: false });
|
||||
};
|
||||
|
||||
private closeContextMenu = (): void => {
|
||||
this.setState({ showMoreMenu: false });
|
||||
};
|
||||
|
||||
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
|
||||
// Note that this assumes we always have a CallView on screen at any given time
|
||||
// CallHandler would probably be a better place for this
|
||||
private onNativeKeyDown = ev => {
|
||||
private onNativeKeyDown = (ev): void => {
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
|
||||
|
@ -347,7 +380,16 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = () => {
|
||||
private onCallControlsMouseEnter = (): void => {
|
||||
this.setState({ hoveringControls: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private onCallControlsMouseLeave = (): void => {
|
||||
this.setState({ hoveringControls: false });
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
|
@ -355,7 +397,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onSecondaryRoomAvatarClick = () => {
|
||||
private onSecondaryRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
|
||||
dis.dispatch({
|
||||
|
@ -364,50 +406,30 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onCallResumeClick = () => {
|
||||
private onCallResumeClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
||||
};
|
||||
|
||||
private onTransferClick = () => {
|
||||
private onTransferClick = (): void => {
|
||||
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||
this.props.call.transferToCall(transfereeCall);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
const callRoom = client.getRoom(callRoomId);
|
||||
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
|
||||
private onHangupClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
});
|
||||
};
|
||||
|
||||
let dialPad;
|
||||
let contextMenu;
|
||||
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
private onToggleSidebar = (): void => {
|
||||
this.setState({
|
||||
sidebarShown: !this.state.sidebarShown,
|
||||
});
|
||||
};
|
||||
|
||||
private renderCallControls(): JSX.Element {
|
||||
const micClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: !this.state.micMuted,
|
||||
|
@ -420,6 +442,18 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
|
||||
});
|
||||
|
||||
const screensharingClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
|
||||
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
|
||||
});
|
||||
|
||||
const sidebarButtonClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
|
||||
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
|
||||
});
|
||||
|
||||
// Put the other states of the mic/video icons in the document to make sure they're cached
|
||||
// (otherwise the icon disappears briefly when toggled)
|
||||
const micCacheClasses = classNames({
|
||||
|
@ -441,59 +475,151 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_callControls_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
|
||||
const vidMuteButton = this.props.call.type === CallType.Video ? <AccessibleButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/> : null;
|
||||
// We don't support call upgrades (yet) so hide the video mute button in voice calls
|
||||
let vidMuteButton;
|
||||
if (this.props.call.type === CallType.Video) {
|
||||
vidMuteButton = (
|
||||
<AccessibleButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Screensharing is possible, if we can send a second stream and
|
||||
// identify it using SDPStreamMetadata or if we can replace the already
|
||||
// existing usermedia track by a screensharing track. We also need to be
|
||||
// connected to know the state of the other side
|
||||
let screensharingButton;
|
||||
if (
|
||||
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
|
||||
this.props.call.state === CallState.Connected
|
||||
) {
|
||||
screensharingButton = (
|
||||
<AccessibleButton
|
||||
className={screensharingClasses}
|
||||
onClick={this.onScreenshareClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// To show the sidebar we need secondary feeds, if we don't have them,
|
||||
// we can hide this button. If we are in PiP, sidebar is also hidden, so
|
||||
// we can hide the button too
|
||||
let sidebarButton;
|
||||
if (
|
||||
!this.props.pipMode &&
|
||||
(
|
||||
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
||||
this.props.call.isScreensharing()
|
||||
)
|
||||
) {
|
||||
sidebarButton = (
|
||||
<AccessibleButton
|
||||
className={sidebarButtonClasses}
|
||||
onClick={this.onToggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The dial pad & 'more' button actions are only relevant in a connected call
|
||||
// When not connected, we have to put something there to make the flexbox alignment correct
|
||||
const dialpadButton = this.state.callState === CallState.Connected ? <ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_dialpad_hidden" />;
|
||||
let contextMenuButton;
|
||||
if (this.state.callState === CallState.Connected) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let dialpadButton;
|
||||
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
|
||||
dialpadButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={true}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
|
||||
// because something needs to have margin-right: auto to make the alignment correct.
|
||||
const callControls = <div className={callControlsClasses}>
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: callRoomId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ contextMenuButton }
|
||||
</div>;
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={true}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={callControlsClasses}
|
||||
onMouseEnter={this.onCallControlsMouseEnter}
|
||||
onMouseLeave={this.onCallControlsMouseLeave}
|
||||
>
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ screensharingButton }
|
||||
{ sidebarButton }
|
||||
{ contextMenuButton }
|
||||
<AccessibleButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={this.onHangupClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
const callRoom = client.getRoom(callRoomId);
|
||||
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
|
||||
const avatarSize = this.props.pipMode ? 76 : 160;
|
||||
|
||||
// The 'content' for the call, ie. the videos for a video call and profile picture
|
||||
// for voice calls (fills the bg)
|
||||
let contentView: React.ReactNode;
|
||||
|
||||
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
|
||||
const isScreensharing = this.props.call.isScreensharing();
|
||||
const sidebarShown = this.state.sidebarShown;
|
||||
const someoneIsScreensharing = this.props.call.getFeeds().some((feed) => {
|
||||
return feed.purpose === SDPStreamMetadataPurpose.Screenshare;
|
||||
});
|
||||
const isVideoCall = this.props.call.type === CallType.Video;
|
||||
|
||||
let contentView: React.ReactNode;
|
||||
let holdTransferContent;
|
||||
|
||||
if (transfereeCall) {
|
||||
const transferTargetRoom = MatrixClientPeg.get().getRoom(
|
||||
CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
|
@ -539,9 +665,25 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
</div>;
|
||||
}
|
||||
|
||||
let sidebar;
|
||||
if (
|
||||
!isOnHold &&
|
||||
!transfereeCall &&
|
||||
sidebarShown &&
|
||||
(isVideoCall || someoneIsScreensharing)
|
||||
) {
|
||||
sidebar = (
|
||||
<CallViewSidebar
|
||||
feeds={this.state.secondaryFeeds}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// This is a bit messy. I can't see a reason to have two onHold/transfer screens
|
||||
if (isOnHold || transfereeCall) {
|
||||
if (this.props.call.type === CallType.Video) {
|
||||
if (isVideoCall) {
|
||||
const containerClasses = classNames({
|
||||
mx_CallView_content: true,
|
||||
mx_CallView_video: true,
|
||||
|
@ -560,7 +702,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
|
||||
{ onHoldBackground }
|
||||
{ holdTransferContent }
|
||||
{ callControls }
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
@ -585,7 +727,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
</div>
|
||||
{ holdTransferContent }
|
||||
{ callControls }
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -599,77 +741,91 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_voice: true,
|
||||
});
|
||||
|
||||
const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
|
||||
// Here we check to hide local audio feeds to achieve the same UI/UX
|
||||
// as before. But once again this might be subject to change
|
||||
if (feed.isVideoMuted()) return;
|
||||
return (
|
||||
<VideoFeed
|
||||
key={i}
|
||||
feed={feed}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
onResize={this.props.onResize}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Saying "Connecting" here isn't really true, but the best thing
|
||||
// I can come up with, but this might be subject to change as well
|
||||
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
|
||||
{ feeds }
|
||||
<div className="mx_CallView_voice_avatarsContainer">
|
||||
<div className="mx_CallView_voice_avatarContainer" style={{ width: avatarSize, height: avatarSize }}>
|
||||
<RoomAvatar
|
||||
room={callRoom}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
contentView = (
|
||||
<div
|
||||
className={classes}
|
||||
onMouseMove={this.onMouseMove}
|
||||
>
|
||||
{ sidebar }
|
||||
<div className="mx_CallView_voice_avatarsContainer">
|
||||
<div
|
||||
className="mx_CallView_voice_avatarContainer"
|
||||
style={{ width: avatarSize, height: avatarSize }}
|
||||
>
|
||||
<RoomAvatar
|
||||
room={callRoom}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
|
||||
{ callControls }
|
||||
</div>;
|
||||
);
|
||||
} else {
|
||||
const containerClasses = classNames({
|
||||
mx_CallView_content: true,
|
||||
mx_CallView_video: true,
|
||||
});
|
||||
|
||||
// TODO: Later the CallView should probably be reworked to support
|
||||
// any number of feeds but now we can always expect there to be two
|
||||
// feeds. This is because the js-sdk ignores any new incoming streams
|
||||
const feeds = this.state.feeds.map((feed, i) => {
|
||||
// Here we check to hide local audio feeds to achieve the same UI/UX
|
||||
// as before. But once again this might be subject to change
|
||||
if (feed.isVideoMuted() && feed.isLocal()) return;
|
||||
return (
|
||||
let toast;
|
||||
if (someoneIsScreensharing) {
|
||||
const presentingClasses = classNames({
|
||||
mx_CallView_presenting: true,
|
||||
mx_CallView_presenting_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
const sharerName = this.state.primaryFeed.getMember().name;
|
||||
let text = isScreensharing
|
||||
? _t("You are presenting")
|
||||
: _t('%(sharerName)s is presenting', { sharerName });
|
||||
if (!this.state.sidebarShown && isVideoCall) {
|
||||
text += " • " + (this.props.call.isLocalVideoMuted()
|
||||
? _t("Your camera is turned off")
|
||||
: _t("Your camera is still enabled"));
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={presentingClasses}>
|
||||
{ text }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
contentView = (
|
||||
<div
|
||||
className={containerClasses}
|
||||
ref={this.contentRef}
|
||||
onMouseMove={this.onMouseMove}
|
||||
>
|
||||
{ toast }
|
||||
{ sidebar }
|
||||
<VideoFeed
|
||||
key={i}
|
||||
feed={feed}
|
||||
feed={this.state.primaryFeed}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
onResize={this.props.onResize}
|
||||
primary={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
|
||||
{ feeds }
|
||||
{ callControls }
|
||||
</div>;
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const callTypeText = this.props.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
|
||||
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
|
||||
let myClassName;
|
||||
|
||||
let fullScreenButton;
|
||||
if (this.props.call.type === CallType.Video && !this.props.pipMode) {
|
||||
fullScreenButton = <div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/>;
|
||||
if (!this.props.pipMode) {
|
||||
fullScreenButton = (
|
||||
<div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let expandButton;
|
||||
|
@ -686,10 +842,15 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
{ expandButton }
|
||||
</div>;
|
||||
|
||||
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
|
||||
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
|
||||
"mx_CallView_header_callTypeIcon_video": isVideoCall,
|
||||
});
|
||||
|
||||
let header: React.ReactNode;
|
||||
if (!this.props.pipMode) {
|
||||
header = <div className="mx_CallView_header">
|
||||
<div className="mx_CallView_header_phoneIcon" />
|
||||
<div className={callTypeIconClassName} />
|
||||
<span className="mx_CallView_header_callType">{ callTypeText }</span>
|
||||
{ headerControls }
|
||||
</div>;
|
||||
|
@ -731,8 +892,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
return <div className={"mx_CallView " + myClassName}>
|
||||
{ header }
|
||||
{ contentView }
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
53
src/components/views/voip/CallViewSidebar.tsx
Normal file
53
src/components/views/voip/CallViewSidebar.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 from "react";
|
||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||
import VideoFeed from "./VideoFeed";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface IProps {
|
||||
feeds: Array<CallFeed>;
|
||||
call: MatrixCall;
|
||||
pipMode: boolean;
|
||||
}
|
||||
|
||||
export default class CallViewSidebar extends React.Component<IProps> {
|
||||
render() {
|
||||
const feeds = this.props.feeds.map((feed) => {
|
||||
return (
|
||||
<VideoFeed
|
||||
key={feed.stream.id}
|
||||
feed={feed}
|
||||
call={this.props.call}
|
||||
primary={false}
|
||||
pipMode={this.props.pipMode}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const className = classNames("mx_CallViewSidebar", {
|
||||
mx_CallViewSidebar_pipMode: this.props.pipMode,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{ feeds }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -16,12 +16,13 @@ limitations under the License.
|
|||
|
||||
import classnames from 'classnames';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import React, { createRef } from 'react';
|
||||
import React from 'react';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
|
||||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
|
||||
interface IProps {
|
||||
call: MatrixCall;
|
||||
|
@ -37,6 +38,8 @@ interface IProps {
|
|||
// a callback which is called when the video element is resized
|
||||
// due to a change in video metadata
|
||||
onResize?: (e: Event) => void;
|
||||
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -45,8 +48,8 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.voip.VideoFeed")
|
||||
export default class VideoFeed extends React.Component<IProps, IState> {
|
||||
private element = createRef<HTMLVideoElement>();
|
||||
export default class VideoFeed extends React.PureComponent<IProps, IState> {
|
||||
private element: HTMLVideoElement;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -58,18 +61,59 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.updateFeed(null, this.props.feed);
|
||||
this.playMedia();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.element.current?.removeEventListener('resize', this.onResize);
|
||||
this.stopMedia();
|
||||
this.updateFeed(this.props.feed, null);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps, prevState: IState) {
|
||||
this.updateFeed(prevProps.feed, this.props.feed);
|
||||
// If the mutes state has changed, we try to playMedia()
|
||||
if (
|
||||
prevState.videoMuted !== this.state.videoMuted ||
|
||||
prevProps.feed.stream !== this.props.feed.stream
|
||||
) {
|
||||
this.playMedia();
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: IProps) {
|
||||
return {
|
||||
audioMuted: props.feed.isAudioMuted(),
|
||||
videoMuted: props.feed.isVideoMuted(),
|
||||
};
|
||||
}
|
||||
|
||||
private setElementRef = (element: HTMLVideoElement): void => {
|
||||
if (!element) {
|
||||
this.element?.removeEventListener('resize', this.onResize);
|
||||
return;
|
||||
}
|
||||
|
||||
this.element = element;
|
||||
element.addEventListener('resize', this.onResize);
|
||||
};
|
||||
|
||||
private updateFeed(oldFeed: CallFeed, newFeed: CallFeed) {
|
||||
if (oldFeed === newFeed) return;
|
||||
|
||||
if (oldFeed) {
|
||||
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
|
||||
this.stopMedia();
|
||||
}
|
||||
if (newFeed) {
|
||||
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
|
||||
this.playMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private playMedia() {
|
||||
const element = this.element.current;
|
||||
const element = this.element;
|
||||
if (!element) return;
|
||||
// We play audio in AudioFeed, not here
|
||||
element.muted = true;
|
||||
|
@ -92,7 +136,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private stopMedia() {
|
||||
const element = this.element.current;
|
||||
const element = this.element;
|
||||
if (!element) return;
|
||||
|
||||
element.pause();
|
||||
|
@ -109,7 +153,13 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
videoMuted: this.props.feed.isVideoMuted(),
|
||||
});
|
||||
this.playMedia();
|
||||
};
|
||||
|
||||
private onMuteStateChanged = () => {
|
||||
this.setState({
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
videoMuted: this.props.feed.isVideoMuted(),
|
||||
});
|
||||
};
|
||||
|
||||
private onResize = (e) => {
|
||||
|
@ -119,35 +169,58 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const videoClasses = {
|
||||
mx_VideoFeed: true,
|
||||
mx_VideoFeed_local: this.props.feed.isLocal(),
|
||||
mx_VideoFeed_remote: !this.props.feed.isLocal(),
|
||||
const { pipMode, primary, feed } = this.props;
|
||||
|
||||
const wrapperClasses = classnames("mx_VideoFeed", {
|
||||
mx_VideoFeed_voice: this.state.videoMuted,
|
||||
mx_VideoFeed_video: !this.state.videoMuted,
|
||||
mx_VideoFeed_mirror: (
|
||||
this.props.feed.isLocal() &&
|
||||
SettingsStore.getValue('VideoView.flipVideoHorizontally')
|
||||
),
|
||||
};
|
||||
});
|
||||
const micIconClasses = classnames("mx_VideoFeed_mic", {
|
||||
mx_VideoFeed_mic_muted: this.state.audioMuted,
|
||||
mx_VideoFeed_mic_unmuted: !this.state.audioMuted,
|
||||
});
|
||||
|
||||
if (this.state.videoMuted) {
|
||||
const member = this.props.feed.getMember();
|
||||
const avatarSize = this.props.pipMode ? 76 : 160;
|
||||
|
||||
return (
|
||||
<div className={classnames(videoClasses)}>
|
||||
<MemberAvatar
|
||||
member={member}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<video className={classnames(videoClasses)} ref={this.element} />
|
||||
let micIcon;
|
||||
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
|
||||
micIcon = (
|
||||
<div className={micIconClasses} />
|
||||
);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (this.state.videoMuted) {
|
||||
const member = this.props.feed.getMember();
|
||||
|
||||
let avatarSize;
|
||||
if (pipMode && primary) avatarSize = 76;
|
||||
else if (pipMode && !primary) avatarSize = 16;
|
||||
else if (!pipMode && primary) avatarSize = 160;
|
||||
else; // TBD
|
||||
|
||||
content =(
|
||||
<MemberAvatar
|
||||
member={member}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const videoClasses = classnames("mx_VideoFeed_video", {
|
||||
mx_VideoFeed_video_mirror: (
|
||||
this.props.feed.isLocal() &&
|
||||
SettingsStore.getValue('VideoView.flipVideoHorizontally')
|
||||
),
|
||||
});
|
||||
|
||||
content= (
|
||||
<video className={videoClasses} ref={this.setElementRef} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
{ micIcon }
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue