diff --git a/res/css/_components.scss b/res/css/_components.scss index 8972cdb4b5..a6b9f9cc49 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -304,7 +304,6 @@ @import "./views/typography/_Heading.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/CallView/_CallViewButtons.scss"; -@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; @@ -313,4 +312,5 @@ @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; +@import "./views/voip/_PiPContainer.scss"; @import "./views/voip/_VideoFeed.scss"; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 73a6f0d31a..9c9548444e 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -20,14 +20,15 @@ limitations under the License. background-color: $dark-panel-bg-color; padding-left: 8px; padding-right: 8px; - // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place + // XXX: PiPContainer sets pointer-events: none - should probably be set back in a better place pointer-events: initial; } .mx_CallView_large { padding-bottom: 10px; margin: $container-gap-width; - margin-right: calc($container-gap-width / 2); // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. + // The left side gap is fully handled by this margin. To prohibit bleeding on webkit browser. + margin-right: calc($container-gap-width / 2); margin-bottom: 10px; display: flex; flex-direction: column; @@ -46,7 +47,7 @@ limitations under the License. width: 320px; padding-bottom: 8px; background-color: $system; - box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2); border-radius: 8px; .mx_CallView_video_hold, @@ -170,7 +171,7 @@ limitations under the License. background-position: center; filter: blur(20px); &::after { - content: ''; + content: ""; display: block; position: absolute; width: 100%; @@ -194,10 +195,10 @@ limitations under the License. display: block; margin-left: auto; margin-right: auto; - content: ''; + content: ""; width: 40px; height: 40px; - background-image: url('$(res)/img/voip/paused.svg'); + background-image: url("$(res)/img/voip/paused.svg"); background-position: center; background-size: cover; } diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_PiPContainer.scss similarity index 90% rename from res/css/views/voip/_CallContainer.scss rename to res/css/views/voip/_PiPContainer.scss index a0137b18e8..b013363ecc 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_PiPContainer.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CallContainer { +.mx_PiPContainer { position: absolute; right: 20px; bottom: 72px; @@ -25,8 +25,4 @@ limitations under the License. // sure the cursor hits the iframe for Jitsi which will be at a // different level. pointer-events: none; - - .mx_AppTile_persistedWrapper div { - min-width: 350px; - } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f270d6273e..decfac67ba 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -40,7 +40,7 @@ import { DefaultTagID } from "../../stores/room-list/models"; import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast"; import { Action } from "../../dispatcher/actions"; import LeftPanel from "./LeftPanel"; -import CallContainer from '../views/voip/CallContainer'; +import PipContainer from '../views/voip/PipContainer'; import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; @@ -674,7 +674,7 @@ class LoggedInView extends React.Component { - + { audioFeedArraysForCalls } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 56543bbf18..b2d5692456 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -508,8 +508,13 @@ export default class AppTile extends React.Component { // Also wrap the PersistedElement in a div to fix the height, otherwise // AppTile's border is in the wrong place + + // For persistent apps in PiP we want the zIndex to be higher then for other persistent apps (100) + // otherwise there are issues that the PiP view is drawn UNDER another widget (Persistent app) when dragged around. + const zIndexAboveOtherPersistentElements = 101; + appTileBody =
- + { appTileBody }
; @@ -545,15 +550,15 @@ export default class AppTile extends React.Component { if (!this.props.hideMaximiseButton) { const widgetIsMaximised = WidgetLayoutStore.instance. isInContainer(this.props.room, this.props.app, Container.Center); + const className = classNames({ + "mx_AppTileMenuBar_iconButton": true, + "mx_AppTileMenuBar_iconButton_minWidget": widgetIsMaximised, + "mx_AppTileMenuBar_iconButton_maxWidget": !widgetIsMaximised, + }); maxMinButton = ; diff --git a/src/components/views/elements/PersistedElement.tsx b/src/components/views/elements/PersistedElement.tsx index cd07864e22..97a197d2bf 100644 --- a/src/components/views/elements/PersistedElement.tsx +++ b/src/components/views/elements/PersistedElement.tsx @@ -184,7 +184,7 @@ export default class PersistedElement extends React.Component { width: parentRect.width + 'px', height: parentRect.height + 'px', }); - }, 100, { trailing: true, leading: true }); + }, 16, { trailing: true, leading: true }); public render(): JSX.Element { return
; diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx index aba42236bb..8c207cd518 100644 --- a/src/components/views/elements/PersistentApp.tsx +++ b/src/components/views/elements/PersistentApp.tsx @@ -16,141 +16,79 @@ limitations under the License. */ import React from 'react'; -import { EventSubscription } from 'fbemitter'; import { Room } from "matrix-js-sdk/src/models/room"; import RoomViewStore from '../../../stores/RoomViewStore'; -import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore'; +import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import WidgetUtils from '../../../utils/WidgetUtils'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import AppTile from "./AppTile"; -import { Container, WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore'; -import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; -import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; -import { UPDATE_EVENT } from '../../../stores/AsyncStore'; interface IProps { - // none + persistentWidgetId: string; + pointerEvents?: string; } interface IState { roomId: string; - persistentWidgetId: string; - rightPanelPhase?: RightPanelPhases; } @replaceableComponent("views.elements.PersistentApp") export default class PersistentApp extends React.Component { - private roomStoreToken: EventSubscription; - constructor(props: IProps) { super(props); this.state = { roomId: RoomViewStore.getRoomId(), - persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(), - rightPanelPhase: RightPanelStore.instance.currentCard.phase, }; } public componentDidMount(): void { - this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); - ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate); - RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership); } public componentWillUnmount(): void { - if (this.roomStoreToken) { - this.roomStoreToken.remove(); - } - ActiveWidgetStore.instance.removeListener(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate); - RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership); - } + MatrixClientPeg.get().off("Room.myMembership", this.onMyMembership); } - private onRoomViewStoreUpdate = (): void => { - if (RoomViewStore.getRoomId() === this.state.roomId) return; - this.setState({ - roomId: RoomViewStore.getRoomId(), - }); - }; - - private onRightPanelStoreUpdate = () => { - this.setState({ - rightPanelPhase: RightPanelStore.instance.currentCard.phase, - }); - }; - - private onActiveWidgetStoreUpdate = (): void => { - this.setState({ - persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(), - }); - }; - private onMyMembership = async (room: Room, membership: string): Promise => { - const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId); + const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId); if (membership !== "join") { // we're not in the room anymore - delete - if (room .roomId === persistentWidgetInRoomId) { - ActiveWidgetStore.instance.destroyPersistentWidget(this.state.persistentWidgetId); + if (room.roomId === persistentWidgetInRoomId) { + ActiveWidgetStore.instance.destroyPersistentWidget(this.props.persistentWidgetId); } } }; public render(): JSX.Element { - const wId = this.state.persistentWidgetId; + const wId = this.props.persistentWidgetId; if (wId) { const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId); - const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId); - // Sanity check the room - the widget may have been destroyed between render cycles, and - // thus no room is associated anymore. - if (!persistentWidgetInRoom) return null; - - const wls = WidgetLayoutStore.instance; - - const userIsPartOfTheRoom = persistentWidgetInRoom.getMyMembership() == "join"; - const fromAnotherRoom = this.state.roomId !== persistentWidgetInRoomId; - - const notInRightPanel = - !(this.state.rightPanelPhase == RightPanelPhases.Widget && - wId == RightPanelStore.instance.currentCard.state?.widgetId); - const notInCenterContainer = - !wls.getContainerWidgets(persistentWidgetInRoom, Container.Center).some((app) => app.id == wId); - const notInTopContainer = - !wls.getContainerWidgets(persistentWidgetInRoom, Container.Top).some(app => app.id == wId); - if ( - // the widget should only be shown as a persistent app (in a floating pip container) if it is not visible on screen - // either, because we are viewing a different room OR because it is in none of the possible containers of the room view. - (fromAnotherRoom && userIsPartOfTheRoom) || - (notInRightPanel && notInCenterContainer && notInTopContainer && userIsPartOfTheRoom) - ) { - // get the widget data - const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => { - return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId(); - }); - const app = WidgetUtils.makeAppConfig( - appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), - persistentWidgetInRoomId, appEvent.getId(), - ); - return ; - } + // get the widget data + const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => { + return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId(); + }); + const app = WidgetUtils.makeAppConfig( + appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), + persistentWidgetInRoomId, appEvent.getId(), + ); + return ; } return null; } diff --git a/src/components/views/rooms/Stickerpicker.tsx b/src/components/views/rooms/Stickerpicker.tsx index f46017959e..e327d64953 100644 --- a/src/components/views/rooms/Stickerpicker.tsx +++ b/src/components/views/rooms/Stickerpicker.tsx @@ -135,7 +135,7 @@ export default class Stickerpicker extends React.PureComponent { // Close the sticker picker when the window resizes window.addEventListener('resize', this.onResize); - this.dispatcherRef = dis.register(this.onWidgetAction); + this.dispatcherRef = dis.register(this.onAction); // Track updates to widget state in account data MatrixClientPeg.get().on('accountData', this.updateWidget); @@ -198,7 +198,7 @@ export default class Stickerpicker extends React.PureComponent { }); }; - private onWidgetAction = (payload: ActionPayload): void => { + private onAction = (payload: ActionPayload): void => { switch (payload.action) { case "user_widget_updated": this.forceUpdate(); diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx deleted file mode 100644 index dec63711a1..0000000000 --- a/src/components/views/voip/CallPreview.tsx +++ /dev/null @@ -1,217 +0,0 @@ -/* -Copyright 2017, 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; -import { EventSubscription } from 'fbemitter'; -import { logger } from "matrix-js-sdk/src/logger"; - -import CallView from "./CallView"; -import RoomViewStore from '../../../stores/RoomViewStore'; -import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; -import PersistentApp from "../elements/PersistentApp"; -import SettingsStore from "../../../settings/SettingsStore"; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import PictureInPictureDragger from './PictureInPictureDragger'; -import dis from '../../../dispatcher/dispatcher'; -import { Action } from "../../../dispatcher/actions"; -import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore'; - -const SHOW_CALL_IN_STATES = [ - CallState.Connected, - CallState.InviteSent, - CallState.Connecting, - CallState.CreateAnswer, - CallState.CreateOffer, - CallState.WaitLocalMedia, -]; - -interface IProps { -} - -interface IState { - roomId: string; - - // The main call that we are displaying (ie. not including the call in the room being viewed, if any) - primaryCall: MatrixCall; - - // 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; -} - -// Splits a list of calls into one 'primary' one and a list -// (which should be a single element) of other calls. -// The primary will be the one not on hold, or an arbitrary one -// if they're all on hold) -function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] { - const calls = CallHandler.instance.getAllActiveCallsForPip(roomId); - - let primary: MatrixCall = null; - let secondaries: MatrixCall[] = []; - - for (const call of calls) { - if (!SHOW_CALL_IN_STATES.includes(call.state)) continue; - - if (!call.isRemoteOnHold() && primary === null) { - primary = call; - } else { - secondaries.push(call); - } - } - - if (primary === null && secondaries.length > 0) { - primary = secondaries[0]; - secondaries = secondaries.slice(1); - } - - if (secondaries.length > 1) { - // We should never be in more than two calls so this shouldn't happen - logger.log("Found more than 1 secondary call! Other calls will not be shown."); - } - - return [primary, secondaries]; -} - -/** - * CallPreview 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.CallPreview") -export default class CallPreview extends React.Component { - private roomStoreToken: EventSubscription; - private dispatcherRef: string; - private settingsWatcherRef: string; - - constructor(props: IProps) { - super(props); - - const roomId = RoomViewStore.getRoomId(); - - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); - - this.state = { - roomId, - primaryCall: primaryCall, - secondaryCall: secondaryCalls[0], - }; - } - - public componentDidMount() { - CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); - CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCalls); - this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); - MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - const room = MatrixClientPeg.get()?.getRoom(this.state.roomId); - if (room) { - WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); - } - } - - public componentWillUnmount() { - CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); - CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCalls); - MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - if (this.roomStoreToken) { - this.roomStoreToken.remove(); - } - SettingsStore.unwatchSetting(this.settingsWatcherRef); - const room = MatrixClientPeg.get().getRoom(this.state.roomId); - if (room) { - WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); - } - } - - private onRoomViewStoreUpdate = () => { - const newRoomId = RoomViewStore.getRoomId(); - const oldRoomId = this.state.roomId; - if (newRoomId === oldRoomId) return; - // The WidgetLayoutStore observer always tracks the currently viewed Room, - // so we don't end up with multiple observers and know what observer to remove on unmount - const oldRoom = MatrixClientPeg.get()?.getRoom(oldRoomId); - if (oldRoom) { - WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(oldRoom), this.updateCalls); - } - const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId); - if (newRoom) { - WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(newRoom), this.updateCalls); - } - if (!newRoomId) return; - - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(newRoomId); - this.setState({ - roomId: newRoomId, - primaryCall: primaryCall, - secondaryCall: secondaryCalls[0], - }); - }; - - private updateCalls = (): void => { - if (!this.state.roomId) return; - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.roomId); - - this.setState({ - primaryCall: primaryCall, - secondaryCall: secondaryCalls[0], - }); - }; - - private onCallRemoteHold = () => { - if (!this.state.roomId) return; - const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.roomId); - - this.setState({ - primaryCall: primaryCall, - secondaryCall: secondaryCalls[0], - }); - }; - - private onDoubleClick = (): void => { - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.primaryCall.roomId, - }); - }; - - public render() { - const pipMode = true; - if (this.state.primaryCall) { - return ( - - { - ({ onStartMoving, onResize }) => - - } - - - ); - } - - return ; - } -} diff --git a/src/components/views/voip/CallView/CallViewHeader.tsx b/src/components/views/voip/CallView/CallViewHeader.tsx index 2596535aa6..cebba8c5dd 100644 --- a/src/components/views/voip/CallView/CallViewHeader.tsx +++ b/src/components/views/voip/CallView/CallViewHeader.tsx @@ -32,7 +32,7 @@ const callTypeTranslationByType: Record = { interface CallViewHeaderProps { pipMode: boolean; - type: CallType; + type?: CallType; callRooms?: Room[]; onPipMouseDown: (event: React.MouseEvent) => void; } @@ -93,9 +93,9 @@ const CallViewHeader: React.FC = ({ onPipMouseDown, }) => { const [callRoom, onHoldCallRoom] = callRooms; - const callTypeText = _t(callTypeTranslationByType[type]); - const callRoomName = callRoom.name; - const { roomId } = callRoom; + const callTypeText = type ? _t(callTypeTranslationByType[type]) : _t("Widget"); + const callRoomName = callRoom?.name; + const roomId = callRoom?.roomId; if (!pipMode) { return
diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/PipContainer.tsx similarity index 76% rename from src/components/views/voip/CallContainer.tsx rename to src/components/views/voip/PipContainer.tsx index 1bf3625f5a..1d3438aec6 100644 --- a/src/components/views/voip/CallContainer.tsx +++ b/src/components/views/voip/PipContainer.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; -import CallPreview from './CallPreview'; +import PipView from './PipView'; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { @@ -28,11 +28,11 @@ interface IState { } -@replaceableComponent("views.voip.CallContainer") -export default class CallContainer extends React.PureComponent { +@replaceableComponent("views.voip.PiPContainer") +export default class PiPContainer extends React.PureComponent { public render() { - return
- + return
+
; } } diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx new file mode 100644 index 0000000000..a4093d063a --- /dev/null +++ b/src/components/views/voip/PipView.tsx @@ -0,0 +1,330 @@ +/* +Copyright 2017 - 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import { EventSubscription } from 'fbemitter'; +import { logger } from "matrix-js-sdk/src/logger"; +import classNames from 'classnames'; + +import CallView from "./CallView"; +import RoomViewStore from '../../../stores/RoomViewStore'; +import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; +import PersistentApp from "../elements/PersistentApp"; +import SettingsStore from "../../../settings/SettingsStore"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import PictureInPictureDragger from './PictureInPictureDragger'; +import dis from '../../../dispatcher/dispatcher'; +import { Action } from "../../../dispatcher/actions"; +import { Container, WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore'; +import CallViewHeader from './CallView/CallViewHeader'; +import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore'; +import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; +import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; + +const SHOW_CALL_IN_STATES = [ + CallState.Connected, + CallState.InviteSent, + CallState.Connecting, + CallState.CreateAnswer, + CallState.CreateOffer, + CallState.WaitLocalMedia, +]; + +interface IProps { +} + +interface IState { + viewedRoomId: string; + + // The main call that we are displaying (ie. not including the call in the room being viewed, if any) + primaryCall: MatrixCall; + + // 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; + + // widget candidate to be displayed in the pip view. + persistentWidgetId: string; + showWidgetInPip: boolean; + rightPanelPhase: RightPanelPhases; + + moving: boolean; +} + +// Splits a list of calls into one 'primary' one and a list +// (which should be a single element) of other calls. +// The primary will be the one not on hold, or an arbitrary one +// if they're all on hold) +function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] { + const calls = CallHandler.instance.getAllActiveCallsForPip(roomId); + + let primary: MatrixCall = null; + let secondaries: MatrixCall[] = []; + + for (const call of calls) { + if (!SHOW_CALL_IN_STATES.includes(call.state)) continue; + + if (!call.isRemoteOnHold() && primary === null) { + primary = call; + } else { + secondaries.push(call); + } + } + + if (primary === null && secondaries.length > 0) { + primary = secondaries[0]; + secondaries = secondaries.slice(1); + } + + if (secondaries.length > 1) { + // We should never be in more than two calls so this shouldn't happen + logger.log("Found more than 1 secondary call! Other calls will not be shown."); + } + + return [primary, secondaries]; +} + +/** + * PipView shows a small version of the CallView or a sticky widget 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 + * and all widgets that are active but not shown in any other possible container. + */ + +@replaceableComponent("views.voip.PipView") +export default class PipView extends React.Component { + private roomStoreToken: EventSubscription; + private settingsWatcherRef: string; + + constructor(props: IProps) { + super(props); + + const roomId = RoomViewStore.getRoomId(); + + const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); + + this.state = { + moving: false, + viewedRoomId: roomId, + primaryCall: primaryCall, + secondaryCall: secondaryCalls[0], + persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(), + rightPanelPhase: RightPanelStore.instance.currentCard.phase, + showWidgetInPip: false, + }; + } + + public componentDidMount() { + CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); + CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCalls); + this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); + MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); + const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); + if (room) { + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); + } + RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate); + document.addEventListener("mouseup", this.onEndMoving.bind(this)); + } + + public componentWillUnmount() { + CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); + CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCalls); + MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); + this.roomStoreToken?.remove(); + SettingsStore.unwatchSetting(this.settingsWatcherRef); + const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); + if (room) { + WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); + } + RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate); + document.removeEventListener("mouseup", this.onEndMoving.bind(this)); + } + + private onStartMoving() { + this.setState({ moving: true }); + } + + private onEndMoving() { + this.setState({ moving: false }); + } + + private onRoomViewStoreUpdate = () => { + const newRoomId = RoomViewStore.getRoomId(); + const oldRoomId = this.state.viewedRoomId; + if (newRoomId === oldRoomId) return; + // The WidgetLayoutStore observer always tracks the currently viewed Room, + // so we don't end up with multiple observers and know what observer to remove on unmount + const oldRoom = MatrixClientPeg.get()?.getRoom(oldRoomId); + if (oldRoom) { + WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(oldRoom), this.updateCalls); + } + const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId); + if (newRoom) { + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(newRoom), this.updateCalls); + } + if (!newRoomId) return; + + const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(newRoomId); + this.setState({ + viewedRoomId: newRoomId, + primaryCall: primaryCall, + secondaryCall: secondaryCalls[0], + }); + this.updateShowWidgetInPip(); + }; + + private onRightPanelStoreUpdate = () => { + this.setState({ + rightPanelPhase: RightPanelStore.instance.currentCard.phase, + }); + this.updateShowWidgetInPip(); + }; + + private onActiveWidgetStoreUpdate = (): void => { + this.setState({ + persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(), + }); + this.updateShowWidgetInPip(); + }; + + private updateCalls = (): void => { + if (!this.state.viewedRoomId) return; + const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.viewedRoomId); + + this.setState({ + primaryCall: primaryCall, + secondaryCall: secondaryCalls[0], + }); + this.updateShowWidgetInPip(); + }; + + private onCallRemoteHold = () => { + if (!this.state.viewedRoomId) return; + const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.viewedRoomId); + + this.setState({ + primaryCall: primaryCall, + secondaryCall: secondaryCalls[0], + }); + }; + + private onDoubleClick = (): void => { + const callRoomId = this.state.primaryCall?.roomId; + const widgetRoomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId); + if (!!(callRoomId ?? widgetRoomId)) { + dis.dispatch({ + action: Action.ViewRoom, + room_id: callRoomId ?? widgetRoomId, + }); + } + }; + + public updateShowWidgetInPip() { + const wId = this.state.persistentWidgetId; + + let userIsPartOfTheRoom = false; + let fromAnotherRoom = false; + let notInRightPanel = false; + let notInCenterContainer = false; + let notInTopContainer = false; + if (wId) { + const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId); + const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId); + + // Sanity check the room - the widget may have been destroyed between render cycles, and + // thus no room is associated anymore. + if (!persistentWidgetInRoom) return null; + + const wls = WidgetLayoutStore.instance; + + userIsPartOfTheRoom = persistentWidgetInRoom.getMyMembership() == "join"; + fromAnotherRoom = this.state.viewedRoomId !== persistentWidgetInRoomId; + + notInRightPanel = + !(RightPanelStore.instance.currentCard.phase == RightPanelPhases.Widget && + wId == RightPanelStore.instance.currentCard.state?.widgetId); + notInCenterContainer = + !wls.getContainerWidgets(persistentWidgetInRoom, Container.Center).some((app) => app.id == wId); + notInTopContainer = + !wls.getContainerWidgets(persistentWidgetInRoom, Container.Top).some(app => app.id == wId); + } + + // The widget should only be shown as a persistent app (in a floating pip container) if it is not visible on screen + // either, because we are viewing a different room OR because it is in none of the possible containers of the room view. + const showWidgetInPip = + (fromAnotherRoom && userIsPartOfTheRoom) || + (notInRightPanel && notInCenterContainer && notInTopContainer && userIsPartOfTheRoom); + + this.setState({ showWidgetInPip }); + } + + public render() { + const pipMode = true; + let pipContent; + + if (this.state.primaryCall) { + pipContent = ({ onStartMoving, onResize }) => + ; + } + + if (this.state.showWidgetInPip) { + const pipViewClasses = classNames({ + mx_CallView: true, + mx_CallView_pip: pipMode, + mx_CallView_large: !pipMode, + }); + const roomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId); + const roomForWidget = MatrixClientPeg.get().getRoom(roomId); + + pipContent = ({ onStartMoving, _onResize }) => +
+ { onStartMoving(event); this.onStartMoving.bind(this)(); }} + pipMode={pipMode} + callRooms={[roomForWidget]} + /> + +
; + } + + if (!!pipContent) { + return + { pipContent } + ; + } + + return null; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cf68a55e67..9a2c328973 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1011,6 +1011,7 @@ "Fill Screen": "Fill Screen", "Return to call": "Return to call", "%(name)s on hold": "%(name)s on hold", + "Widget": "Widget", "The other party cancelled the verification.": "The other party cancelled the verification.", "Verified!": "Verified!", "You've successfully verified this user.": "You've successfully verified this user.",