diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 6261b9965f..565e4657b9 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React from 'react'; import CallView from "./CallView"; import RoomViewStore from '../../../stores/RoomViewStore'; @@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore"; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import UIStore from '../../../stores/UIStore'; -import { lerp } from '../../../utils/AnimationUtils'; -import { MarkedExecution } from '../../../utils/MarkedExecution'; import { EventSubscription } from 'fbemitter'; - -const PIP_VIEW_WIDTH = 336; -const PIP_VIEW_HEIGHT = 232; - -const MOVING_AMT = 0.2; -const SNAPPING_AMT = 0.05; - -const PADDING = { - top: 58, - bottom: 58, - left: 76, - right: 8, -}; +import { PictureInPictureDragger } from './PictureInPictureDragger'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -66,10 +51,6 @@ interface IState { // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // they belong to secondaryCall: MatrixCall; - - // Position of the CallPreview - translationX: number; - translationY: number; } // Splits a list of calls into one 'primary' one and a list @@ -112,16 +93,6 @@ export default class CallPreview extends React.Component { private roomStoreToken: EventSubscription; private dispatcherRef: string; private settingsWatcherRef: string; - private callViewWrapper = createRef(); - private initX = 0; - private initY = 0; - private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH; - private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH; - private moving = false; - private scheduledUpdate = new MarkedExecution( - () => this.animationCallback(), - () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), - ); constructor(props: IProps) { super(props); @@ -136,17 +107,12 @@ export default class CallPreview extends React.Component { roomId, primaryCall: primaryCall, secondaryCall: secondaryCalls[0], - translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, - translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH, }; } public componentDidMount() { CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); - document.addEventListener("mousemove", this.onMoving); - document.addEventListener("mouseup", this.onEndMoving); - window.addEventListener("resize", this.onResize); this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); } @@ -154,9 +120,6 @@ export default class CallPreview extends React.Component { public componentWillUnmount() { CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - document.removeEventListener("mousemove", this.onMoving); - document.removeEventListener("mouseup", this.onEndMoving); - window.removeEventListener("resize", this.onResize); if (this.roomStoreToken) { this.roomStoreToken.remove(); } @@ -164,94 +127,6 @@ export default class CallPreview extends React.Component { 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 - // loop. If that is the case, it means the PiP has snapped into its - // position and there is nothing to do. Not doing this would cause an - // infinite loop - if ( - !this.moving && - Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 && - Math.abs(this.state.translationY - this.desiredTranslationY) <= 1 - ) return; - - const amt = this.moving ? MOVING_AMT : SNAPPING_AMT; - this.setState({ - translationX: lerp(this.state.translationX, this.desiredTranslationX, amt), - translationY: lerp(this.state.translationY, this.desiredTranslationY, amt), - }); - this.scheduledUpdate.mark(); - }; - - private setTranslation(inTranslationX: number, inTranslationY: number) { - const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH; - const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT; - - // Avoid overflow on the x axis - if (inTranslationX + width >= UIStore.instance.windowWidth) { - this.desiredTranslationX = UIStore.instance.windowWidth - width; - } else if (inTranslationX <= 0) { - this.desiredTranslationX = 0; - } else { - this.desiredTranslationX = inTranslationX; - } - - // Avoid overflow on the y axis - if (inTranslationY + height >= UIStore.instance.windowHeight) { - this.desiredTranslationY = UIStore.instance.windowHeight - height; - } else if (inTranslationY <= 0) { - this.desiredTranslationY = 0; - } else { - this.desiredTranslationY = inTranslationY; - } - } - - 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 - // the position to snap to from the PiP center and not its top-left - // corner - const windowWidth = ( - UIStore.instance.windowWidth - - (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH) - ); - const windowHeight = ( - UIStore.instance.windowHeight - - (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT) - ); - - if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) { - this.desiredTranslationX = windowWidth - PADDING.right; - this.desiredTranslationY = windowHeight - PADDING.bottom; - } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) { - this.desiredTranslationX = windowWidth - PADDING.right; - this.desiredTranslationY = PADDING.top; - } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) { - this.desiredTranslationX = PADDING.left; - this.desiredTranslationY = windowHeight - PADDING.bottom; - } else { - this.desiredTranslationX = PADDING.left; - this.desiredTranslationY = PADDING.top; - } - - 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; @@ -300,53 +175,22 @@ export default class CallPreview extends React.Component { }); }; - private onStartMoving = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - this.moving = true; - this.initX = event.pageX - this.desiredTranslationX; - this.initY = event.pageY - this.desiredTranslationY; - this.scheduledUpdate.mark(); - }; - - private onMoving = (event: React.MouseEvent | MouseEvent) => { - if (!this.moving) return; - - event.preventDefault(); - event.stopPropagation(); - - this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); - }; - - private onEndMoving = () => { - this.moving = false; - this.snap(true); - }; - public render() { + const pipMode = true; if (this.state.primaryCall) { - const translatePixelsX = this.state.translationX + "px"; - const translatePixelsY = this.state.translationY + "px"; - const style = { - transform: `translateX(${translatePixelsX}) - translateY(${translatePixelsY})`, - }; - return ( -
- -
+ pipMode={pipMode} + /> } + + ); } diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 356e642d65..096cd8a6a9 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -37,6 +37,7 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker import Modal from '../../../Modal'; import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; import CallViewSidebar from './CallViewSidebar'; +import { CallViewHeader } from './CallView/CallViewHeader'; interface IProps { // The call for us to display @@ -56,7 +57,7 @@ interface IProps { pipMode?: boolean; // Used for dragging the PiP CallView - onMouseDownOnHeader?: (event: React.MouseEvent) => void; + onMouseDownOnHeader?: (event: React.MouseEvent) => void; } interface IState { @@ -115,7 +116,6 @@ export default class CallView extends React.Component { private controlsHideTimer: number = null; private dialpadButton = createRef(); private contextMenuButton = createRef(); - private contextMenu = createRef(); constructor(props: IProps) { super(props); @@ -231,21 +231,6 @@ export default class CallView extends React.Component { }); }; - private onFullscreenClick = () => { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }); - }; - - private onExpandClick = () => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - private onControlsHideTimer = () => { if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return; this.controlsHideTimer = null; @@ -389,23 +374,6 @@ export default class CallView extends React.Component { this.setState({ hoveringControls: false }); }; - private onRoomAvatarClick = (): void => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - - private onSecondaryRoomAvatarClick = (): void => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); - - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - private onCallResumeClick = (): void => { const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); @@ -814,83 +782,15 @@ export default class CallView extends React.Component { ); } - const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call"); - let myClassName; - - let fullScreenButton; - if (!this.props.pipMode) { - fullScreenButton = ( -
- ); - } - - let expandButton; - if (this.props.pipMode) { - expandButton =
; - } - - const headerControls =
- { fullScreenButton } - { expandButton } -
; - - 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 =
-
- { callTypeText } - { headerControls } -
; - myClassName = 'mx_CallView_large'; - } else { - let secondaryCallInfo; - if (this.props.secondaryCall) { - secondaryCallInfo = - - - - { _t("%(name)s on hold", { name: secCallRoom.name }) } - - - ; - } - - header = ( -
- - - -
-
{ callRoom.name }
-
- { callTypeText } - { secondaryCallInfo } -
-
- { headerControls } -
- ); - myClassName = 'mx_CallView_pip'; - } + const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large'; return
- { header } + { contentView }
; } diff --git a/src/components/views/voip/CallView/CallViewHeader.tsx b/src/components/views/voip/CallView/CallViewHeader.tsx new file mode 100644 index 0000000000..5df45c90a0 --- /dev/null +++ b/src/components/views/voip/CallView/CallViewHeader.tsx @@ -0,0 +1,135 @@ +/* +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 { CallType } from 'matrix-js-sdk/src/webrtc/call'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import React from 'react'; +import { isUndefined } from 'lodash'; +import { _t } from '../../../../languageHandler'; +import RoomAvatar from '../../avatars/RoomAvatar'; +import AccessibleButton from '../../elements/AccessibleButton'; +import dis from '../../../../dispatcher/dispatcher'; +import WidgetAvatar from '../../avatars/WidgetAvatar'; +import { IApp } from '../../../../stores/WidgetStore'; +import WidgetUtils from '../../../../utils/WidgetUtils'; + +const callTypeTranslationByType: Record string> = { + [CallType.Video]: () => _t("Video Call"), + [CallType.Voice]: () => _t("Audio Call"), + 'widget': (app: IApp) => WidgetUtils.getWidgetName(app), +}; + +interface CallViewHeaderProps { + pipMode: boolean; + type: CallType | 'widget'; + callRooms?: Room[]; + app?: IApp; + onPipMouseDown: (event: React.MouseEvent) => void; +} + +const onRoomAvatarClick = (roomId: string) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); +}; + +const onFullscreenClick = () => { + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); +}; + +const onExpandClick = (roomId: string) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); +}; + +type CallControlsProps = Pick & { + roomId: string; +}; +function CallControls({ pipMode = false, type, roomId }: CallControlsProps) { + return
+ { (pipMode && type === CallType.Video) && +
} + { pipMode &&
onExpandClick(roomId)} + title={_t("Return to call")} + /> } +
; +} +function SecondaryCallInfo({ callRoom }: {callRoom: Room}) { + return + onRoomAvatarClick(callRoom.roomId)}> + + + { _t("%(name)s on hold", { name: callRoom.name }) } + + + ; +} + +function getAvatarBasedOnRoomType(roomOrWidget: Room | IApp) { + if (roomOrWidget instanceof Room) { + return ; + } else if (!isUndefined(roomOrWidget)) { + return ; + } + return null; +} + +export function CallViewHeader({ + type, + pipMode = false, + callRooms = [], + app, + onPipMouseDown, +}: CallViewHeaderProps) { + const [callRoom, onHoldCallRoom] = callRooms; + const callTypeText = callTypeTranslationByType[type](app); + const avatar = getAvatarBasedOnRoomType(callRoom ?? app); + const callRoomName = type === 'widget' ? callTypeText : callRoom.name; + const roomId = app ? app.roomId : callRoom.roomId; + if (!pipMode) { + return
+
+ { callTypeText } + +
; + } + return (
+ onRoomAvatarClick(roomId)}> + { avatar } + +
+
{ callRoomName }
+
+ { callTypeText } + { onHoldCallRoom && } +
+
+ +
+ ); +} diff --git a/src/components/views/voip/PictureInPictureDragger.tsx b/src/components/views/voip/PictureInPictureDragger.tsx new file mode 100644 index 0000000000..14a2a88939 --- /dev/null +++ b/src/components/views/voip/PictureInPictureDragger.tsx @@ -0,0 +1,208 @@ +/* +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 React, { createRef } from 'react'; +import UIStore from '../../../stores/UIStore'; +import { IApp } from '../../../stores/WidgetStore'; +import { lerp } from '../../../utils/AnimationUtils'; +import { MarkedExecution } from '../../../utils/MarkedExecution'; +import { replaceableComponent } from '../../../utils/replaceableComponent'; + +const PIP_VIEW_WIDTH = 336; +const PIP_VIEW_HEIGHT = 232; + +const MOVING_AMT = 0.2; +const SNAPPING_AMT = 0.05; + +const PADDING = { + top: 58, + bottom: 58, + left: 76, + right: 8, +}; + +interface IProps { + className?: string; + children: (event: MouseEvent) => React.ReactNode; + draggable: boolean; + app?: IApp; +} + +interface IState { + // Position of the PictureInPictureDragger + translationX: number; + translationY: number; +} + +/** + * PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture' + * (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing. + */ +@replaceableComponent("views.voip.PictureInPictureDragger") +export class PictureInPictureDragger extends React.Component { + private callViewWrapper = createRef(); + private initX = 0; + private initY = 0; + private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH; + private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH; + private moving = false; + private scheduledUpdate = new MarkedExecution( + () => this.animationCallback(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); + + constructor(props: IProps) { + super(props); + + this.state = { + translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, + translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH, + }; + } + + public componentDidMount() { + document.addEventListener("mousemove", this.onMoving); + document.addEventListener("mouseup", this.onEndMoving); + window.addEventListener("resize", this.snap); + } + + public componentWillUnmount() { + document.removeEventListener("mousemove", this.onMoving); + document.removeEventListener("mouseup", this.onEndMoving); + window.removeEventListener("resize", this.snap); + } + + private animationCallback = () => { + // If the PiP isn't being dragged and there is only a tiny difference in + // the desiredTranslation and translation, quit the animationCallback + // loop. If that is the case, it means the PiP has snapped into its + // position and there is nothing to do. Not doing this would cause an + // infinite loop + if ( + !this.moving && + Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 && + Math.abs(this.state.translationY - this.desiredTranslationY) <= 1 + ) return; + + const amt = this.moving ? MOVING_AMT : SNAPPING_AMT; + this.setState({ + translationX: lerp(this.state.translationX, this.desiredTranslationX, amt), + translationY: lerp(this.state.translationY, this.desiredTranslationY, amt), + }); + this.scheduledUpdate.mark(); + }; + + private setTranslation(inTranslationX: number, inTranslationY: number) { + const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH; + const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT; + + // Avoid overflow on the x axis + if (inTranslationX + width >= UIStore.instance.windowWidth) { + this.desiredTranslationX = UIStore.instance.windowWidth - width; + } else if (inTranslationX <= 0) { + this.desiredTranslationX = 0; + } else { + this.desiredTranslationX = inTranslationX; + } + + // Avoid overflow on the y axis + if (inTranslationY + height >= UIStore.instance.windowHeight) { + this.desiredTranslationY = UIStore.instance.windowHeight - height; + } else if (inTranslationY <= 0) { + this.desiredTranslationY = 0; + } else { + this.desiredTranslationY = inTranslationY; + } + } + + private snap = () => { + const translationX = this.desiredTranslationX; + const translationY = this.desiredTranslationY; + // We subtract the PiP size from the window size in order to calculate + // the position to snap to from the PiP center and not its top-left + // corner + const windowWidth = ( + UIStore.instance.windowWidth - + (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH) + ); + const windowHeight = ( + UIStore.instance.windowHeight - + (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT) + ); + + if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = PADDING.top; + } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = PADDING.top; + } + + // We start animating here because we want the PiP to move when we're + // resizing the window + this.scheduledUpdate.mark(); + }; + + private onStartMoving = (event: React.MouseEvent | MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.moving = true; + this.initX = event.pageX - this.desiredTranslationX; + this.initY = event.pageY - this.desiredTranslationY; + this.scheduledUpdate.mark(); + }; + + private onMoving = (event: React.MouseEvent | MouseEvent) => { + if (!this.moving) return; + + event.preventDefault(); + event.stopPropagation(); + + this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); + }; + + private onEndMoving = () => { + this.moving = false; + this.snap(); + }; + + public render() { + const translatePixelsX = this.state.translationX + "px"; + const translatePixelsY = this.state.translationY + "px"; + const style = { + transform: `translateX(${translatePixelsX}) + translateY(${translatePixelsY})`, + }; + return ( +
+ <> + { this.props.children(this.onStartMoving) } + +
+ ); + } +} +