Render Jitsi (and other sticky widgets) in PiP container, so it can be dragged and the "jump to room functionality" is provided (#7450)

Co-authored-by: J. Ryan Stinnett <jryans@gmail.com>
This commit is contained in:
Timo 2022-01-13 12:10:41 +01:00 committed by GitHub
parent 8b01b68fa3
commit ef95644e23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 396 additions and 342 deletions

View File

@ -304,7 +304,6 @@
@import "./views/typography/_Heading.scss"; @import "./views/typography/_Heading.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/CallView/_CallViewButtons.scss"; @import "./views/voip/CallView/_CallViewButtons.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";
@ -313,4 +312,5 @@
@import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_DialPadModal.scss";
@import "./views/voip/_PiPContainer.scss";
@import "./views/voip/_VideoFeed.scss"; @import "./views/voip/_VideoFeed.scss";

View File

@ -20,14 +20,15 @@ limitations under the License.
background-color: $dark-panel-bg-color; background-color: $dark-panel-bg-color;
padding-left: 8px; padding-left: 8px;
padding-right: 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; pointer-events: initial;
} }
.mx_CallView_large { .mx_CallView_large {
padding-bottom: 10px; padding-bottom: 10px;
margin: $container-gap-width; 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; margin-bottom: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -46,7 +47,7 @@ limitations under the License.
width: 320px; width: 320px;
padding-bottom: 8px; padding-bottom: 8px;
background-color: $system; 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; border-radius: 8px;
.mx_CallView_video_hold, .mx_CallView_video_hold,
@ -170,7 +171,7 @@ limitations under the License.
background-position: center; background-position: center;
filter: blur(20px); filter: blur(20px);
&::after { &::after {
content: ''; content: "";
display: block; display: block;
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -194,10 +195,10 @@ limitations under the License.
display: block; display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
content: ''; content: "";
width: 40px; width: 40px;
height: 40px; height: 40px;
background-image: url('$(res)/img/voip/paused.svg'); background-image: url("$(res)/img/voip/paused.svg");
background-position: center; background-position: center;
background-size: cover; background-size: cover;
} }

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_CallContainer { .mx_PiPContainer {
position: absolute; position: absolute;
right: 20px; right: 20px;
bottom: 72px; bottom: 72px;
@ -25,8 +25,4 @@ limitations under the License.
// sure the cursor hits the iframe for Jitsi which will be at a // sure the cursor hits the iframe for Jitsi which will be at a
// different level. // different level.
pointer-events: none; pointer-events: none;
.mx_AppTile_persistedWrapper div {
min-width: 350px;
}
} }

View File

@ -40,7 +40,7 @@ import { DefaultTagID } from "../../stores/room-list/models";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast"; import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel"; import LeftPanel from "./LeftPanel";
import CallContainer from '../views/voip/CallContainer'; import PipContainer from '../views/voip/PipContainer';
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import RoomListStore from "../../stores/room-list/RoomListStore"; import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer"; import NonUrgentToastContainer from "./NonUrgentToastContainer";
@ -674,7 +674,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div> </div>
</div> </div>
</div> </div>
<CallContainer /> <PipContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />
<HostSignupContainer /> <HostSignupContainer />
{ audioFeedArraysForCalls } { audioFeedArraysForCalls }

View File

@ -508,8 +508,13 @@ export default class AppTile extends React.Component<IProps, IState> {
// Also wrap the PersistedElement in a div to fix the height, otherwise // Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place // 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 = <div className="mx_AppTile_persistedWrapper"> appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement zIndex={this.props.miniMode ? 10 : 9}persistKey={this.persistKey}> <PersistedElement zIndex={this.props.miniMode ? zIndexAboveOtherPersistentElements : 9} persistKey={this.persistKey}>
{ appTileBody } { appTileBody }
</PersistedElement> </PersistedElement>
</div>; </div>;
@ -545,15 +550,15 @@ export default class AppTile extends React.Component<IProps, IState> {
if (!this.props.hideMaximiseButton) { if (!this.props.hideMaximiseButton) {
const widgetIsMaximised = WidgetLayoutStore.instance. const widgetIsMaximised = WidgetLayoutStore.instance.
isInContainer(this.props.room, this.props.app, Container.Center); 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 = <AccessibleButton maxMinButton = <AccessibleButton
className={ className={className}
"mx_AppTileMenuBar_iconButton"
+ (widgetIsMaximised
? " mx_AppTileMenuBar_iconButton_minWidget"
: " mx_AppTileMenuBar_iconButton_maxWidget")
}
title={ title={
widgetIsMaximised ? _t('Close'): _t('Maximise widget') widgetIsMaximised ? _t('Close') : _t('Maximise widget')
} }
onClick={this.onMaxMinWidgetClick} onClick={this.onMaxMinWidgetClick}
/>; />;

View File

@ -184,7 +184,7 @@ export default class PersistedElement extends React.Component<IProps> {
width: parentRect.width + 'px', width: parentRect.width + 'px',
height: parentRect.height + 'px', height: parentRect.height + 'px',
}); });
}, 100, { trailing: true, leading: true }); }, 16, { trailing: true, leading: true });
public render(): JSX.Element { public render(): JSX.Element {
return <div ref={this.collectChildContainer} />; return <div ref={this.collectChildContainer} />;

View File

@ -16,141 +16,79 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { EventSubscription } from 'fbemitter';
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AppTile from "./AppTile"; 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 { interface IProps {
// none persistentWidgetId: string;
pointerEvents?: string;
} }
interface IState { interface IState {
roomId: string; roomId: string;
persistentWidgetId: string;
rightPanelPhase?: RightPanelPhases;
} }
@replaceableComponent("views.elements.PersistentApp") @replaceableComponent("views.elements.PersistentApp")
export default class PersistentApp extends React.Component<IProps, IState> { export default class PersistentApp extends React.Component<IProps, IState> {
private roomStoreToken: EventSubscription;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
roomId: RoomViewStore.getRoomId(), roomId: RoomViewStore.getRoomId(),
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
rightPanelPhase: RightPanelStore.instance.currentCard.phase,
}; };
} }
public componentDidMount(): void { 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); MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
if (this.roomStoreToken) { MatrixClientPeg.get().off("Room.myMembership", this.onMyMembership);
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);
}
} }
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<void> => { private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.state.persistentWidgetId); const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId);
if (membership !== "join") { if (membership !== "join") {
// we're not in the room anymore - delete // we're not in the room anymore - delete
if (room .roomId === persistentWidgetInRoomId) { if (room.roomId === persistentWidgetInRoomId) {
ActiveWidgetStore.instance.destroyPersistentWidget(this.state.persistentWidgetId); ActiveWidgetStore.instance.destroyPersistentWidget(this.props.persistentWidgetId);
} }
} }
}; };
public render(): JSX.Element { public render(): JSX.Element {
const wId = this.state.persistentWidgetId; const wId = this.props.persistentWidgetId;
if (wId) { if (wId) {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId); const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId);
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId); const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// Sanity check the room - the widget may have been destroyed between render cycles, and // get the widget data
// thus no room is associated anymore. const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
if (!persistentWidgetInRoom) return null; return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId();
});
const wls = WidgetLayoutStore.instance; const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
const userIsPartOfTheRoom = persistentWidgetInRoom.getMyMembership() == "join"; persistentWidgetInRoomId, appEvent.getId(),
const fromAnotherRoom = this.state.roomId !== persistentWidgetInRoomId; );
return <AppTile
const notInRightPanel = key={app.id}
!(this.state.rightPanelPhase == RightPanelPhases.Widget && app={app}
wId == RightPanelStore.instance.currentCard.state?.widgetId); fullWidth={true}
const notInCenterContainer = room={persistentWidgetInRoom}
!wls.getContainerWidgets(persistentWidgetInRoom, Container.Center).some((app) => app.id == wId); userId={MatrixClientPeg.get().credentials.userId}
const notInTopContainer = creatorUserId={app.creatorUserId}
!wls.getContainerWidgets(persistentWidgetInRoom, Container.Top).some(app => app.id == wId); widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
if ( waitForIframeLoad={app.waitForIframeLoad}
// the widget should only be shown as a persistent app (in a floating pip container) if it is not visible on screen miniMode={true}
// either, because we are viewing a different room OR because it is in none of the possible containers of the room view. showMenubar={false}
(fromAnotherRoom && userIsPartOfTheRoom) || pointerEvents={this.props.pointerEvents}
(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 <AppTile
key={app.id}
app={app}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
miniMode={true}
showMenubar={false}
/>;
}
} }
return null; return null;
} }

View File

@ -135,7 +135,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
// Close the sticker picker when the window resizes // Close the sticker picker when the window resizes
window.addEventListener('resize', this.onResize); 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 // Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this.updateWidget); MatrixClientPeg.get().on('accountData', this.updateWidget);
@ -198,7 +198,7 @@ export default class Stickerpicker extends React.PureComponent<IProps, IState> {
}); });
}; };
private onWidgetAction = (payload: ActionPayload): void => { private onAction = (payload: ActionPayload): void => {
switch (payload.action) { switch (payload.action) {
case "user_widget_updated": case "user_widget_updated":
this.forceUpdate(); this.forceUpdate();

View File

@ -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<IProps, IState> {
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 (
<PictureInPictureDragger
className="mx_CallPreview"
draggable={pipMode}
onDoubleClick={this.onDoubleClick}
>
{
({ onStartMoving, onResize }) =>
<CallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
pipMode={pipMode}
onResize={onResize}
/>
}
</PictureInPictureDragger>
);
}
return <PersistentApp />;
}
}

View File

@ -32,7 +32,7 @@ const callTypeTranslationByType: Record<CallType, string> = {
interface CallViewHeaderProps { interface CallViewHeaderProps {
pipMode: boolean; pipMode: boolean;
type: CallType; type?: CallType;
callRooms?: Room[]; callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void; onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
} }
@ -93,9 +93,9 @@ const CallViewHeader: React.FC<CallViewHeaderProps> = ({
onPipMouseDown, onPipMouseDown,
}) => { }) => {
const [callRoom, onHoldCallRoom] = callRooms; const [callRoom, onHoldCallRoom] = callRooms;
const callTypeText = _t(callTypeTranslationByType[type]); const callTypeText = type ? _t(callTypeTranslationByType[type]) : _t("Widget");
const callRoomName = callRoom.name; const callRoomName = callRoom?.name;
const { roomId } = callRoom; const roomId = callRoom?.roomId;
if (!pipMode) { if (!pipMode) {
return <div className="mx_CallViewHeader"> return <div className="mx_CallViewHeader">

View File

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import CallPreview from './CallPreview'; import PipView from './PipView';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
@ -28,11 +28,11 @@ interface IState {
} }
@replaceableComponent("views.voip.CallContainer") @replaceableComponent("views.voip.PiPContainer")
export default class CallContainer extends React.PureComponent<IProps, IState> { export default class PiPContainer extends React.PureComponent<IProps, IState> {
public render() { public render() {
return <div className="mx_CallContainer"> return <div className="mx_PiPContainer">
<CallPreview /> <PipView />
</div>; </div>;
} }
} }

View File

@ -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<IProps, IState> {
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 }) =>
<CallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
pipMode={pipMode}
onResize={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 }) =>
<div className={pipViewClasses}>
<CallViewHeader
type={undefined}
onPipMouseDown={(event) => { onStartMoving(event); this.onStartMoving.bind(this)(); }}
pipMode={pipMode}
callRooms={[roomForWidget]}
/>
<PersistentApp
persistentWidgetId={this.state.persistentWidgetId}
pointerEvents={this.state.moving ? 'none' : undefined}
/>
</div>;
}
if (!!pipContent) {
return <PictureInPictureDragger
className="mx_CallPreview"
draggable={pipMode}
onDoubleClick={this.onDoubleClick}
>
{ pipContent }
</PictureInPictureDragger>;
}
return null;
}
}

View File

@ -1011,6 +1011,7 @@
"Fill Screen": "Fill Screen", "Fill Screen": "Fill Screen",
"Return to call": "Return to call", "Return to call": "Return to call",
"%(name)s on hold": "%(name)s on hold", "%(name)s on hold": "%(name)s on hold",
"Widget": "Widget",
"The other party cancelled the verification.": "The other party cancelled the verification.", "The other party cancelled the verification.": "The other party cancelled the verification.",
"Verified!": "Verified!", "Verified!": "Verified!",
"You've successfully verified this user.": "You've successfully verified this user.", "You've successfully verified this user.": "You've successfully verified this user.",