From ba0f262f46e3f6aa51a9300f900e6c3767090245 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 19 Dec 2022 21:38:59 -0500 Subject: [PATCH] Redesign the picture-in-picture window --- res/css/_components.pcss | 1 + res/css/components/views/pips/_WidgetPip.pcss | 71 +++++++++ res/css/views/messages/_LegacyCallEvent.pcss | 2 +- res/css/views/rooms/_AppsDrawer.pcss | 2 +- .../_LegacyCallViewButtons.pcss | 2 +- res/css/views/voip/_CallView.pcss | 24 +-- res/css/views/voip/_LegacyCallPreview.pcss | 2 +- res/img/element-icons/call/hangup.svg | 3 +- res/img/voip/call-view/hangup.svg | 3 - res/img/voip/declined-voice.svg | 4 - res/themes/dark/css/_dark.pcss | 7 +- res/themes/legacy-dark/css/_legacy-dark.pcss | 7 +- .../legacy-light/css/_legacy-light.pcss | 7 +- res/themes/light/css/_light.pcss | 8 +- .../structures/PictureInPictureDragger.tsx | 50 ++++-- src/components/structures/PipContainer.tsx | 132 ++------------- src/components/views/elements/AppTile.tsx | 2 +- .../views/elements/PersistedElement.tsx | 35 ++-- .../views/elements/PersistentApp.tsx | 2 +- src/components/views/pips/WidgetPip.tsx | 150 ++++++++++++++++++ src/hooks/useCall.ts | 5 + .../structures/PipContainer-test.tsx | 128 +++++++++++---- 22 files changed, 427 insertions(+), 220 deletions(-) create mode 100644 res/css/components/views/pips/_WidgetPip.pcss delete mode 100644 res/img/voip/call-view/hangup.svg delete mode 100644 res/img/voip/declined-voice.svg create mode 100644 src/components/views/pips/WidgetPip.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e66d434742be..96e7cdbe405f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -30,6 +30,7 @@ @import "./components/views/location/_ZoomButtons.pcss"; @import "./components/views/messages/_MBeaconBody.pcss"; @import "./components/views/messages/shared/_MediaProcessingError.pcss"; +@import "./components/views/pips/_WidgetPip.pcss"; @import "./components/views/settings/devices/_DeviceDetailHeading.pcss"; @import "./components/views/settings/devices/_DeviceDetails.pcss"; @import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss"; diff --git a/res/css/components/views/pips/_WidgetPip.pcss b/res/css/components/views/pips/_WidgetPip.pcss new file mode 100644 index 000000000000..e77c882eaa0b --- /dev/null +++ b/res/css/components/views/pips/_WidgetPip.pcss @@ -0,0 +1,71 @@ +/* +Copyright 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. +*/ + +.mx_WidgetPip { + position: fixed; + left: 0; + top: 0; + /* Display above the widget element */ + z-index: 102; +} + +.mx_WidgetPip_content { + width: 320px; + height: 220px; + border-radius: 8px; + contain: paint; + color: $call-primary-content; + cursor: pointer; +} + +.mx_WidgetPip_header, +.mx_WidgetPip_footer { + position: absolute; + left: 0; + height: 60px; + width: 100%; + box-sizing: border-box; +} + +.mx_WidgetPip_header { + top: 0; + padding: $spacing-12; + display: flex; + font-size: $font-12px; + font-weight: $font-semi-bold; + background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0)); +} + +.mx_WidgetPip_backButton { + height: $spacing-24; + display: flex; + align-items: center; + gap: $spacing-12; + + > .mx_Icon { + color: $call-light-quaternary-content; + padding: 0; + } +} + +.mx_WidgetPip_footer { + bottom: 0; + padding: $spacing-12 $spacing-8; + display: flex; + justify-content: flex-end; + align-items: flex-end; + background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.9)); +} diff --git a/res/css/views/messages/_LegacyCallEvent.pcss b/res/css/views/messages/_LegacyCallEvent.pcss index c51b486e669c..873f5d4b5b19 100644 --- a/res/css/views/messages/_LegacyCallEvent.pcss +++ b/res/css/views/messages/_LegacyCallEvent.pcss @@ -68,7 +68,7 @@ limitations under the License. &.mx_LegacyCallEvent_rejected, &.mx_LegacyCallEvent_noAnswer { .mx_LegacyCallEvent_type_icon::before { - mask-image: url("$(res)/img/voip/declined-voice.svg"); + mask-image: url("$(res)/img/element-icons/call/hangup.svg"); } } } diff --git a/res/css/views/rooms/_AppsDrawer.pcss b/res/css/views/rooms/_AppsDrawer.pcss index 16e19149fda7..91b84ef445a1 100644 --- a/res/css/views/rooms/_AppsDrawer.pcss +++ b/res/css/views/rooms/_AppsDrawer.pcss @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -$MiniAppTileHeight: 200px; +$MiniAppTileHeight: 220px; /* TODO this should be 300px but that's too large */ $MinWidth: 240px; diff --git a/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss b/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss index 8127c163fef9..3963160f2a36 100644 --- a/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss +++ b/res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss @@ -159,7 +159,7 @@ limitations under the License. background-color: $alert; &::before { - mask-image: url("$(res)/img/voip/call-view/hangup.svg"); + mask-image: url("$(res)/img/element-icons/call/hangup.svg"); background-color: white; /* Same on both themes */ } } diff --git a/res/css/views/voip/_CallView.pcss b/res/css/views/voip/_CallView.pcss index fcdbce7351a4..1de69dc85420 100644 --- a/res/css/views/voip/_CallView.pcss +++ b/res/css/views/voip/_CallView.pcss @@ -32,7 +32,7 @@ limitations under the License. height: 100%; border: none; border-radius: inherit; - background-color: $call-lobby-background; + background-color: $call-background; } /* While the lobby is shown, the widget needs to stay loaded but hidden in the background */ @@ -44,8 +44,8 @@ limitations under the License. min-height: 0; flex-grow: 1; padding: $spacing-12; - color: $call-lobby-primary-content; - background-color: $call-lobby-background; + color: $call-primary-content; + background-color: $call-background; border-radius: 8px; display: flex; @@ -59,7 +59,7 @@ limitations under the License. margin: $spacing-8 auto 0; .mx_FacePile_faces .mx_BaseAvatar_image { - border-color: $call-lobby-background; + border-color: $call-background; } } @@ -68,7 +68,7 @@ limitations under the License. width: 100%; max-width: 800px; aspect-ratio: 1.5; - background-color: $call-lobby-system; + background-color: $call-system; border-radius: 20px; overflow: hidden; @@ -106,7 +106,7 @@ limitations under the License. left: 0; right: 0; - background-color: rgba($call-lobby-background, 0.9); + background-color: rgba($call-background, 0.9); display: flex; justify-content: center; @@ -122,7 +122,7 @@ limitations under the License. width: $size; height: $size; - background-color: $call-lobby-system; + background-color: $call-system; border-radius: calc($size / 2); &::before { @@ -131,7 +131,7 @@ limitations under the License. mask-repeat: no-repeat; mask-size: 20px; mask-position: center; - background-color: $call-lobby-primary-content; + background-color: $call-primary-content; height: 100%; width: 100%; } @@ -154,7 +154,7 @@ limitations under the License. width: $size; height: $size; - background-color: $call-lobby-system; + background-color: $call-system; border-radius: calc($size / 2); &::before { @@ -163,7 +163,7 @@ limitations under the License. mask-image: url("$(res)/img/feather-customised/chevron-down.svg"); mask-size: $size; mask-position: center; - background-color: $call-lobby-primary-content; + background-color: $call-primary-content; height: 100%; width: 100%; } @@ -172,10 +172,10 @@ limitations under the License. &.mx_CallView_deviceButtonWrapper_muted { .mx_CallView_deviceButton, .mx_CallView_deviceListButton { - background-color: $call-lobby-primary-content; + background-color: $call-primary-content; &::before { - background-color: $call-lobby-system; + background-color: $call-system; } } diff --git a/res/css/views/voip/_LegacyCallPreview.pcss b/res/css/views/voip/_LegacyCallPreview.pcss index 9cf571c96010..43b339996aec 100644 --- a/res/css/views/voip/_LegacyCallPreview.pcss +++ b/res/css/views/voip/_LegacyCallPreview.pcss @@ -27,4 +27,4 @@ limitations under the License. border-radius: 8px; overflow: hidden; } -} \ No newline at end of file +} diff --git a/res/img/element-icons/call/hangup.svg b/res/img/element-icons/call/hangup.svg index 1a1b82a1d7ff..173677db2dd5 100644 --- a/res/img/element-icons/call/hangup.svg +++ b/res/img/element-icons/call/hangup.svg @@ -1,3 +1,4 @@ - + + diff --git a/res/img/voip/call-view/hangup.svg b/res/img/voip/call-view/hangup.svg deleted file mode 100644 index 255433abdc5b..000000000000 --- a/res/img/voip/call-view/hangup.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/voip/declined-voice.svg b/res/img/voip/declined-voice.svg deleted file mode 100644 index 78e8d90cdfc0..000000000000 --- a/res/img/voip/declined-voice.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 9293d51d4581..6fd88d63a97e 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -188,9 +188,10 @@ $call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; -$call-lobby-system: $system; -$call-lobby-background: $background; -$call-lobby-primary-content: $primary-content; +$call-system: $system; +$call-background: $background; +$call-primary-content: $primary-content; +$call-light-quaternary-content: #c1c6cd; /* ******************** */ /* Location sharing */ diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 2680b366ccde..ed1ff5793b43 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -119,9 +119,10 @@ $call-view-content-background: $quinary-content; $video-feed-secondary-background: $system; -$call-lobby-system: $system; -$call-lobby-background: $background; -$call-lobby-primary-content: $primary-content; +$call-system: $system; +$call-background: $background; +$call-primary-content: $primary-content; +$call-light-quaternary-content: #c1c6cd; $roomlist-filter-active-bg-color: $panel-actions; $roomlist-bg-color: $header-panel-bg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 32ad2a86a01e..d4fdec5fc4fc 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -182,9 +182,10 @@ $call-view-content-background: #21262c; $video-feed-secondary-background: #394049; /* XXX: Color from dark theme */ /* All of these are from dark theme */ -$call-lobby-system: #21262c; -$call-lobby-background: #15191e; -$call-lobby-primary-content: #ffffff; +$call-system: #21262c; +$call-background: #15191e; +$call-primary-content: #ffffff; +$call-light-quaternary-content: #c1c6cd; $username-variant1-color: #368bd6; $username-variant2-color: #ac3ba8; diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index e7bfa1e32800..67a3530853e2 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -273,9 +273,11 @@ $video-feed-secondary-background: #394049; /* XXX: Color from dark theme */ $voipcall-plinth-color: $system; /* All of these are from dark theme */ -$call-lobby-system: #21262c; -$call-lobby-background: #15191e; -$call-lobby-primary-content: #ffffff; +$call-system: #21262c; +$call-background: #15191e; +$call-primary-content: #ffffff; +/* This one is from light theme */ +$call-light-quaternary-content: #c1c6cd; /* ******************** */ /* One-off colors */ diff --git a/src/components/structures/PictureInPictureDragger.tsx b/src/components/structures/PictureInPictureDragger.tsx index adc68f4b36b9..56acbc10190b 100644 --- a/src/components/structures/PictureInPictureDragger.tsx +++ b/src/components/structures/PictureInPictureDragger.tsx @@ -65,12 +65,20 @@ export default class PictureInPictureDragger extends React.Component { private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT; private translationX = this.desiredTranslationX; private translationY = this.desiredTranslationY; - private moving = false; - private scheduledUpdate = new MarkedExecution( + private mouseHeld = false; + private scheduledUpdate: MarkedExecution = new MarkedExecution( () => this.animationCallback(), () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), ); + private _moving = false; + public get moving(): boolean { + return this._moving; + } + private set moving(value: boolean) { + this._moving = value; + } + public componentDidMount() { document.addEventListener("mousemove", this.onMoving); document.addEventListener("mouseup", this.onEndMoving); @@ -179,26 +187,47 @@ export default class PictureInPictureDragger extends React.Component { event.preventDefault(); event.stopPropagation(); - this.moving = true; - this.initX = event.pageX - this.desiredTranslationX; - this.initY = event.pageY - this.desiredTranslationY; - this.scheduledUpdate.mark(); + this.mouseHeld = true; }; - private onMoving = (event: React.MouseEvent | MouseEvent) => { - if (!this.moving) return; + private onMoving = (event: MouseEvent) => { + if (!this.mouseHeld) return; event.preventDefault(); event.stopPropagation(); + if (!this.moving) { + this.moving = true; + this.initX = event.pageX - this.desiredTranslationX; + this.initY = event.pageY - this.desiredTranslationY; + this.scheduledUpdate.mark(); + } + this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); }; - private onEndMoving = () => { - this.moving = false; + private onEndMoving = (event: MouseEvent) => { + if (!this.mouseHeld) return; + + event.preventDefault(); + event.stopPropagation(); + + this.mouseHeld = false; + // Delaying this to the next event loop tick is necessary for click + // event cancellation to work + setImmediate(() => (this.moving = false)); this.snap(true); }; + private onClickCapture = (event: React.MouseEvent) => { + // To prevent mouse up events during dragging from being double-counted + // as clicks, we cancel clicks before they ever reach the target + if (this.moving) { + event.preventDefault(); + event.stopPropagation(); + } + }; + public render() { const style = { transform: `translateX(${this.translationX}px) translateY(${this.translationY}px)`, @@ -209,6 +238,7 @@ export default class PictureInPictureDragger extends React.Component { className={this.props.className} style={style} ref={this.callViewWrapper} + onClickCapture={this.onClickCapture} onDoubleClick={this.props.onDoubleClick} > {this.props.children({ diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index b517d365e78b..c3e59a8e3a07 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -14,28 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, useContext } from "react"; +import React, { useContext } from "react"; import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; -import classNames from "classnames"; -import { Room } from "matrix-js-sdk/src/models/room"; import { Optional } from "matrix-events-sdk"; import LegacyCallView from "../views/voip/LegacyCallView"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; -import PersistentApp from "../views/elements/PersistentApp"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import PictureInPictureDragger, { CreatePipChildren } from "./PictureInPictureDragger"; import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; -import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; -import LegacyCallViewHeader from "../views/voip/LegacyCallView/LegacyCallViewHeader"; +import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore"; -import WidgetStore, { IApp } from "../../stores/WidgetStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { SDKContext, SdkContextClass } from "../../contexts/SDKContext"; -import { CallStore } from "../../stores/CallStore"; import { useCurrentVoiceBroadcastPreRecording, useCurrentVoiceBroadcastRecording, @@ -48,6 +42,7 @@ import { VoiceBroadcastSmallPlaybackBody, } from "../../voice-broadcast"; import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback"; +import { WidgetPip } from "../views/pips/WidgetPip"; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -78,20 +73,8 @@ interface IState { persistentWidgetId: string; persistentRoomId: string; showWidgetInPip: boolean; - - moving: boolean; } -const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room | null, IApp | null] => { - if (!widgetId) return [null, null]; - if (!roomId) return [null, null]; - - const room = MatrixClientPeg.get().getRoom(roomId); - const app = WidgetStore.instance.getApps(roomId).find((app) => app.id === widgetId); - - return [room, app || null]; -}; - // 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 @@ -134,10 +117,6 @@ function getPrimarySecondaryCallsForPip(roomId: Optional): [MatrixCall | */ class PipContainerInner extends React.Component { - // The cast is not so great, but solves the typing issue for the moment. - // Proper solution: use useRef (requires the component to be refactored to a functional component). - private movePersistedElement = createRef<() => void>() as React.MutableRefObject<() => void>; - public constructor(props: IProps) { super(props); @@ -146,7 +125,6 @@ class PipContainerInner extends React.Component { const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); this.state = { - moving: false, viewedRoomId: roomId || undefined, primaryCall: primaryCall || null, secondaryCall: secondaryCalls[0], @@ -168,7 +146,6 @@ class PipContainerInner extends React.Component { ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges); - document.addEventListener("mouseup", this.onEndMoving.bind(this)); } public componentWillUnmount() { @@ -184,19 +161,8 @@ class PipContainerInner extends React.Component { ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges); - document.removeEventListener("mouseup", this.onEndMoving.bind(this)); } - private onStartMoving() { - this.setState({ moving: true }); - } - - private onEndMoving() { - this.setState({ moving: false }); - } - - private onMove = () => this.movePersistedElement.current?.(); - private onRoomViewStoreUpdate = () => { const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const oldRoomId = this.state.viewedRoomId; @@ -265,53 +231,6 @@ class PipContainerInner extends React.Component { } }; - private onMaximize = (): void => { - const widgetId = this.state.persistentWidgetId; - const roomId = this.state.persistentRoomId; - - if (this.state.showWidgetInPip && widgetId && roomId) { - const [room, app] = getRoomAndAppForWidget(widgetId, roomId); - - if (room && app) { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); - return; - } - } - - dis.dispatch({ - action: "video_fullscreen", - fullscreen: true, - }); - }; - - private onPin = (): void => { - if (!this.state.showWidgetInPip) return; - - const [room, app] = getRoomAndAppForWidget(this.state.persistentWidgetId, this.state.persistentRoomId); - - if (room && app) { - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); - } - }; - - private onExpand = (): void => { - const widgetId = this.state.persistentWidgetId; - if (!widgetId || !this.state.showWidgetInPip) return; - - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.persistentRoomId, - }); - }; - - private onViewCall = (): void => - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.persistentRoomId, - view_call: true, - metricsTrigger: undefined, - }); - // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId public updateShowWidgetInPip( persistentWidgetId = this.state.persistentWidgetId, @@ -401,53 +320,28 @@ class PipContainerInner extends React.Component { ); } - if (this.state.showWidgetInPip) { - const pipViewClasses = classNames({ - mx_LegacyCallView: true, - mx_LegacyCallView_pip: pipMode, - mx_LegacyCallView_large: !pipMode, - }); - const roomId = this.state.persistentRoomId; - const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!; - const viewingCallRoom = this.state.viewedRoomId === roomId; - const isCall = CallStore.instance.getActiveCall(roomId) !== null; - - pipContent = ({ onStartMoving }) => ( -
- { - onStartMoving?.(event); - this.onStartMoving.bind(this)(); - }} - pipMode={pipMode} - callRooms={[roomForWidget]} - onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined} - onPin={!isCall && viewingCallRoom ? this.onPin : undefined} - onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined} - /> - -
- ); - } - if (!!pipContent) { return ( {pipContent} ); } + if (this.state.showWidgetInPip) { + return ( + + ); + } + return null; } } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index bb44a2940e3d..5a60ce52a692 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -85,7 +85,7 @@ interface IProps { widgetPageTitle?: string; showLayoutButtons?: boolean; // Handle to manually notify the PersistedElement that it needs to move - movePersistedElement?: MutableRefObject<() => void>; + movePersistedElement?: MutableRefObject<(() => void) | undefined>; } interface IState { diff --git a/src/components/views/elements/PersistedElement.tsx b/src/components/views/elements/PersistedElement.tsx index cfe5f24f96cd..2bb4037df19d 100644 --- a/src/components/views/elements/PersistedElement.tsx +++ b/src/components/views/elements/PersistedElement.tsx @@ -16,7 +16,6 @@ limitations under the License. import React, { MutableRefObject } from "react"; import ReactDOM from "react-dom"; -import { throttle } from "lodash"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import dis from "../../../dispatcher/dispatcher"; @@ -58,7 +57,7 @@ interface IProps { style?: React.StyleHTMLAttributes; // Handle to manually notify this PersistedElement that it needs to move - moveRef?: MutableRefObject<() => void>; + moveRef?: MutableRefObject<(() => void) | undefined>; } /** @@ -177,24 +176,20 @@ export default class PersistedElement extends React.Component { child.style.display = visible ? "block" : "none"; } - private updateChildPosition = throttle( - (child: HTMLDivElement, parent: HTMLDivElement): void => { - if (!child || !parent) return; - - const parentRect = parent.getBoundingClientRect(); - Object.assign(child.style, { - zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex, - position: "absolute", - top: "0", - left: "0", - transform: `translateX(${parentRect.left}px) translateY(${parentRect.top}px)`, - width: parentRect.width + "px", - height: parentRect.height + "px", - }); - }, - 16, - { trailing: true, leading: true }, - ); + private updateChildPosition(child: HTMLDivElement, parent: HTMLDivElement): void { + if (!child || !parent) return; + + const parentRect = parent.getBoundingClientRect(); + Object.assign(child.style, { + zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex, + position: "absolute", + top: "0", + left: "0", + transform: `translateX(${parentRect.left}px) translateY(${parentRect.top}px)`, + width: parentRect.width + "px", + height: parentRect.height + "px", + }); + } public render(): JSX.Element { return
; diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx index fc24b2b8c68d..f692f74aa88c 100644 --- a/src/components/views/elements/PersistentApp.tsx +++ b/src/components/views/elements/PersistentApp.tsx @@ -27,7 +27,7 @@ interface IProps { persistentWidgetId: string; persistentRoomId: string; pointerEvents?: string; - movePersistedElement: MutableRefObject<() => void>; + movePersistedElement: MutableRefObject<(() => void) | undefined>; } export default class PersistentApp extends React.Component { diff --git a/src/components/views/pips/WidgetPip.tsx b/src/components/views/pips/WidgetPip.tsx new file mode 100644 index 000000000000..2152695d9e17 --- /dev/null +++ b/src/components/views/pips/WidgetPip.tsx @@ -0,0 +1,150 @@ +/* +Copyright 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, { FC, useCallback, useMemo, useRef } from "react"; +import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; + +import PersistentApp from "../elements/PersistentApp"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { useCallForWidget } from "../../../hooks/useCall"; +import WidgetStore from "../../../stores/WidgetStore"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; +import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; +import Toolbar from "../../../accessibility/Toolbar"; +import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import PictureInPictureDragger, { CreatePipChildren } from "../../structures/PictureInPictureDragger"; +import { Icon as BackIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as HangupIcon } from "../../../../res/img/element-icons/call/hangup.svg"; +import { _t } from "../../../languageHandler"; +import { WidgetType } from "../../../widgets/WidgetType"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions"; +import { Alignment } from "../elements/Tooltip"; + +interface Props { + widgetId: string; + room: Room; + viewingRoom: boolean; +} + +/** + * A picture-in-picture view for a widget. + */ +export const WidgetPip: FC = ({ widgetId, room, viewingRoom }) => { + const widget = useMemo( + () => WidgetStore.instance.getApps(room.roomId).find((app) => app.id === widgetId)!, + [room, widgetId], + ); + + const roomName = useTypedEventEmitterState( + room, + RoomEvent.Name, + useCallback(() => room.name, [room]), + ); + + const call = useCallForWidget(widgetId, room.roomId); + + const onBackClick = useCallback( + (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (call !== null) { + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + } else if (viewingRoom) { + WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Center); + } else { + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + metricsTrigger: undefined, + }); + } + }, + [room, call, widget, viewingRoom], + ); + + const movePersistedElement = useRef<() => void>(); + const onMove = useCallback(() => movePersistedElement.current?.(), [movePersistedElement]); + + const onLeaveClick = useCallback( + (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (call !== null) { + call.disconnect().catch((e) => console.error("Failed to leave call", e)); + } else { + // Assumed to be a Jitsi widget + WidgetMessagingStore.instance + .getMessagingForUid(WidgetUtils.getWidgetUid(widget)) + ?.transport.send(ElementWidgetActions.HangupCall, {}) + .catch((e) => console.error("Failed to leave Jitsi", e)); + } + }, + [call, widget], + ); + + const content: CreatePipChildren = useCallback( + ({ onStartMoving }) => ( +
+ + + + {roomName} + + + + {(call !== null || WidgetType.JITSI.matches(widget.type)) && ( + + + + + + )} +
+ ), + [onBackClick, roomName, widgetId, room, movePersistedElement, call, onLeaveClick, widget], + ); + + return ( + + {content} + + ); +}; diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index b90533f0ac3d..03bee56b9cc9 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -33,6 +33,11 @@ export const useCall = (roomId: string): Call | null => { return call; }; +export const useCallForWidget = (widgetId: string, roomId: string): Call | null => { + const call = useCall(roomId); + return call?.widget.id === widgetId ? call : null; +}; + export const useConnectionState = (call: Call): ConnectionState => useTypedEventEmitterState( call, diff --git a/test/components/structures/PipContainer-test.tsx b/test/components/structures/PipContainer-test.tsx index c4bfaa23feae..132cc0ed1f0d 100644 --- a/test/components/structures/PipContainer-test.tsx +++ b/test/components/structures/PipContainer-test.tsx @@ -16,12 +16,14 @@ limitations under the License. import React from "react"; import { mocked, Mocked } from "jest-mock"; -import { screen, render, act, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { screen, render, act, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { Widget, ClientWidgetApi } from "matrix-widget-api"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { @@ -34,6 +36,7 @@ import { wrapInMatrixClientContext, wrapInSdkContext, mkRoomCreateEvent, + mockPlatformPeg, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { CallStore } from "../../../src/stores/CallStore"; @@ -56,11 +59,17 @@ import { import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils"; import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import { IRoomStateEventsActionPayload } from "../../../src/actions/MatrixActionCreators"; +import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore"; +import WidgetStore from "../../../src/stores/WidgetStore"; +import { WidgetType } from "../../../src/widgets/WidgetType"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; +import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions"; describe("PipContainer", () => { useMockedCalls(); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); + let user: UserEvent; let sdkContext: TestSdkContext; let client: Mocked; let room: Room; @@ -71,6 +80,8 @@ describe("PipContainer", () => { let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; beforeEach(async () => { + user = userEvent.setup(); + stubClient(); client = mocked(MatrixClientPeg.get()); DMRoomMap.makeShared(); @@ -103,6 +114,8 @@ describe("PipContainer", () => { ); sdkContext = new TestSdkContext(); + // @ts-ignore PipContainer uses SDKContext in the constructor + SdkContextClass.instance = sdkContext; voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(); @@ -124,7 +137,7 @@ describe("PipContainer", () => { render(); }; - const viewRoom = (roomId: string) => + const viewRoom = (roomId: string) => { defaultDispatcher.dispatch( { action: Action.ViewRoom, @@ -133,8 +146,9 @@ describe("PipContainer", () => { }, true, ); + }; - const withCall = async (fn: () => Promise): Promise => { + const withCall = async (fn: (call: MockedCall) => Promise): Promise => { MockedCall.create(room, "1"); const call = CallStore.instance.getCall(room.roomId); if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); @@ -149,16 +163,16 @@ describe("PipContainer", () => { ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); - await fn(); + await fn(call); cleanup(); call.destroy(); ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); }; - const withWidget = (fn: () => void): void => { + const withWidget = async (fn: () => Promise): Promise => { act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true)); - fn(); + await fn(); cleanup(); ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); }; @@ -190,7 +204,7 @@ describe("PipContainer", () => { }; const setUpRoomViewStore = () => { - new RoomViewStore(defaultDispatcher, sdkContext); + sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext); }; const mkVoiceBroadcast = (room: Room): MatrixEvent => { @@ -213,54 +227,102 @@ describe("PipContainer", () => { expect(screen.queryByRole("complementary")).toBeNull(); }); - it("shows an active call with a maximise button", async () => { + it("shows an active call with return and leave buttons", async () => { renderPip(); - await withCall(async () => { + await withCall(async (call) => { screen.getByRole("complementary"); - screen.getByText(room.roomId); - expect(screen.queryByRole("button", { name: "Pin" })).toBeNull(); - expect(screen.queryByRole("button", { name: /return/i })).toBeNull(); - // The maximise button should jump to the call + // The return button should jump to the call const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); - fireEvent.click(screen.getByRole("button", { name: "Fill screen" })); - await waitFor(() => - expect(dispatcherSpy).toHaveBeenCalledWith({ - action: Action.ViewRoom, - room_id: room.roomId, - view_call: true, - }), - ); + await user.click(screen.getByRole("button", { name: "Return" })); + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + }); defaultDispatcher.unregister(dispatcherRef); + + // The leave button should disconnect from the call + const disconnectSpy = jest.spyOn(call, "disconnect"); + await user.click(screen.getByRole("button", { name: "Leave" })); + expect(disconnectSpy).toHaveBeenCalled(); }); }); - it("shows a persistent widget with pin and maximise buttons when viewing the room", () => { + it("shows a persistent widget with return button when viewing the room", async () => { + setUpRoomViewStore(); viewRoom(room.roomId); + const widget = WidgetStore.instance.addVirtualWidget( + { + id: "1", + creatorUserId: "@alice:exaxmple.org", + type: WidgetType.CUSTOM.preferred, + url: "https://example.org", + name: "Example widget", + }, + room.roomId, + ); renderPip(); - withWidget(() => { + await withWidget(async () => { screen.getByRole("complementary"); - screen.getByText(room.roomId); - screen.getByRole("button", { name: "Pin" }); - screen.getByRole("button", { name: "Fill screen" }); - expect(screen.queryByRole("button", { name: /return/i })).toBeNull(); + + // The return button should maximize the widget + const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + await user.click(screen.getByRole("button", { name: "Return" })); + expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center); + + expect(screen.queryByRole("button", { name: "Leave" })).toBeNull(); }); + + WidgetStore.instance.removeVirtualWidget("1", room.roomId); }); - it("shows a persistent widget with a return button when not viewing the room", () => { + it("shows a persistent Jitsi widget with return and leave buttons when not viewing the room", async () => { + mockPlatformPeg({ supportsJitsiScreensharing: () => true }); + setUpRoomViewStore(); viewRoom(room2.roomId); + const widget = WidgetStore.instance.addVirtualWidget( + { + id: "1", + creatorUserId: "@alice:exaxmple.org", + type: WidgetType.JITSI.preferred, + url: "https://meet.example.org", + name: "Jitsi example", + }, + room.roomId, + ); renderPip(); - withWidget(() => { + await withWidget(async () => { screen.getByRole("complementary"); - screen.getByText(room.roomId); - expect(screen.queryByRole("button", { name: "Pin" })).toBeNull(); - expect(screen.queryByRole("button", { name: "Fill screen" })).toBeNull(); - screen.getByRole("button", { name: /return/i }); + + // The return button should view the room + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + await user.click(screen.getByRole("button", { name: "Return" })); + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + }); + defaultDispatcher.unregister(dispatcherRef); + + // The leave button should hangup the call + const sendSpy = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({}); + const mockMessaging = { transport: { send: sendSpy }, stop: () => {} } as unknown as ClientWidgetApi; + WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging); + await user.click(screen.getByRole("button", { name: "Leave" })); + expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {}); }); + + WidgetStore.instance.removeVirtualWidget("1", room.roomId); }); describe("when there is a voice broadcast recording and pre-recording", () => {