Tear down AppTile using lifecycle tracking (#7833)
This commit is contained in:
parent
f697301298
commit
a939184e10
8 changed files with 295 additions and 79 deletions
|
@ -2,7 +2,7 @@
|
||||||
Copyright 2017 Vector Creations Ltd
|
Copyright 2017 Vector Creations Ltd
|
||||||
Copyright 2018 New Vector Ltd
|
Copyright 2018 New Vector Ltd
|
||||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
Copyright 2020 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -23,7 +23,6 @@ import classNames from 'classnames';
|
||||||
import { MatrixCapabilities } from "matrix-widget-api";
|
import { MatrixCapabilities } from "matrix-widget-api";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { EventSubscription } from 'fbemitter';
|
|
||||||
|
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton from './AccessibleButton';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
@ -118,17 +117,40 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
showLayoutButtons: true,
|
showLayoutButtons: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// We track a count of all "live" `AppTile`s for a given widget ID.
|
||||||
|
// For this purpose, an `AppTile` is considered live from the time it is
|
||||||
|
// constructed until it is unmounted. This is used to aid logic around when
|
||||||
|
// to tear down the widget iframe. See `componentWillUnmount` for details.
|
||||||
|
private static liveTilesById: { [key: string]: number } = {};
|
||||||
|
|
||||||
|
public static addLiveTile(widgetId: string): void {
|
||||||
|
const refs = this.liveTilesById[widgetId] || 0;
|
||||||
|
this.liveTilesById[widgetId] = refs + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static removeLiveTile(widgetId: string): void {
|
||||||
|
const refs = this.liveTilesById[widgetId] || 0;
|
||||||
|
this.liveTilesById[widgetId] = refs - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isLive(widgetId: string): boolean {
|
||||||
|
const refs = this.liveTilesById[widgetId] || 0;
|
||||||
|
return refs > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private contextMenuButton = createRef<any>();
|
private contextMenuButton = createRef<any>();
|
||||||
private iframe: HTMLIFrameElement; // ref to the iframe (callback style)
|
private iframe: HTMLIFrameElement; // ref to the iframe (callback style)
|
||||||
private allowedWidgetsWatchRef: string;
|
private allowedWidgetsWatchRef: string;
|
||||||
private persistKey: string;
|
private persistKey: string;
|
||||||
private sgWidget: StopGapWidget;
|
private sgWidget: StopGapWidget;
|
||||||
private dispatcherRef: string;
|
private dispatcherRef: string;
|
||||||
private roomStoreToken: EventSubscription;
|
private unmounted: boolean;
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
AppTile.addLiveTile(this.props.app.id);
|
||||||
|
|
||||||
// The key used for PersistedElement
|
// The key used for PersistedElement
|
||||||
this.persistKey = getPersistKey(this.props.app.id);
|
this.persistKey = getPersistKey(this.props.app.id);
|
||||||
try {
|
try {
|
||||||
|
@ -165,23 +187,6 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
return !!currentlyAllowedWidgets[props.app.eventId];
|
return !!currentlyAllowedWidgets[props.app.eventId];
|
||||||
};
|
};
|
||||||
|
|
||||||
private onWidgetLayoutChange = () => {
|
|
||||||
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
|
|
||||||
const isVisibleOnScreen = WidgetLayoutStore.instance.isVisibleOnScreen(this.props.room, this.props.app.id);
|
|
||||||
if (!isVisibleOnScreen && !isActiveWidget) {
|
|
||||||
this.endWidgetActions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRoomViewStoreUpdate = () => {
|
|
||||||
if (this.props.room?.roomId === RoomViewStore.getRoomId()) return;
|
|
||||||
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
|
|
||||||
// Stop the widget if it's not the active (persistent) widget and it's not a user widget
|
|
||||||
if (!isActiveWidget && !this.props.userWidget) {
|
|
||||||
this.endWidgetActions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUserLeftRoom() {
|
private onUserLeftRoom() {
|
||||||
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
|
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
|
||||||
if (isActiveWidget) {
|
if (isActiveWidget) {
|
||||||
|
@ -263,28 +268,46 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
this.watchUserReady();
|
this.watchUserReady();
|
||||||
|
|
||||||
if (this.props.room) {
|
if (this.props.room) {
|
||||||
const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room);
|
|
||||||
WidgetLayoutStore.instance.on(emitEvent, this.onWidgetLayoutChange);
|
|
||||||
this.context.on("Room.myMembership", this.onMyMembership);
|
this.context.on("Room.myMembership", this.onMyMembership);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
|
||||||
this.allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
|
this.allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
|
||||||
// Widget action listeners
|
// Widget action listeners
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
|
this.unmounted = true;
|
||||||
|
|
||||||
|
// It might seem simplest to always tear down the widget itself here,
|
||||||
|
// and indeed that would be a bit easier to reason about... however, we
|
||||||
|
// support moving widgets between containers (e.g. top <-> center).
|
||||||
|
// During such a move, this component will unmount from the old
|
||||||
|
// container and remount in the new container. By keeping the widget
|
||||||
|
// iframe loaded across this transition, the widget doesn't notice that
|
||||||
|
// anything happened, which improves overall widget UX. During this kind
|
||||||
|
// of movement between containers, the new `AppTile` for the new
|
||||||
|
// container is constructed before the old one unmounts. By counting the
|
||||||
|
// mounted `AppTile`s for each widget, we know to only tear down the
|
||||||
|
// widget iframe when the last the `AppTile` unmounts.
|
||||||
|
AppTile.removeLiveTile(this.props.app.id);
|
||||||
|
|
||||||
|
// We also support a separate "persistence" mode where a single widget
|
||||||
|
// can request to be "sticky" and follow you across rooms in a PIP
|
||||||
|
// container.
|
||||||
|
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
|
||||||
|
|
||||||
|
if (!AppTile.isLive(this.props.app.id) && !isActiveWidget) {
|
||||||
|
this.endWidgetActions();
|
||||||
|
}
|
||||||
|
|
||||||
// Widget action listeners
|
// Widget action listeners
|
||||||
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
|
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
|
||||||
|
|
||||||
if (this.props.room) {
|
if (this.props.room) {
|
||||||
const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room);
|
|
||||||
WidgetLayoutStore.instance.off(emitEvent, this.onWidgetLayoutChange);
|
|
||||||
this.context.off("Room.myMembership", this.onMyMembership);
|
this.context.off("Room.myMembership", this.onMyMembership);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.roomStoreToken?.remove();
|
|
||||||
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
|
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
|
||||||
OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady);
|
OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady);
|
||||||
}
|
}
|
||||||
|
@ -319,6 +342,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
private startWidget(): void {
|
private startWidget(): void {
|
||||||
this.sgWidget.prepare().then(() => {
|
this.sgWidget.prepare().then(() => {
|
||||||
|
if (this.unmounted) return;
|
||||||
this.setState({ initialising: false });
|
this.setState({ initialising: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -333,6 +357,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
private iframeRefChange = (ref: HTMLIFrameElement): void => {
|
private iframeRefChange = (ref: HTMLIFrameElement): void => {
|
||||||
this.iframe = ref;
|
this.iframe = ref;
|
||||||
|
if (this.unmounted) return;
|
||||||
if (ref) {
|
if (ref) {
|
||||||
this.startMessaging();
|
this.startMessaging();
|
||||||
} else {
|
} else {
|
||||||
|
@ -668,6 +693,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
"mx_AppTileMenuBar_iconButton_maximise": !isMaximised,
|
"mx_AppTileMenuBar_iconButton_maximise": !isMaximised,
|
||||||
});
|
});
|
||||||
layoutButtons.push(<AccessibleButton
|
layoutButtons.push(<AccessibleButton
|
||||||
|
key="toggleMaximised"
|
||||||
className={maximisedClasses}
|
className={maximisedClasses}
|
||||||
title={
|
title={
|
||||||
isMaximised ? _t("Close") : _t("Maximise")
|
isMaximised ? _t("Close") : _t("Maximise")
|
||||||
|
@ -683,6 +709,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
"mx_AppTileMenuBar_iconButton_pin": !isPinned,
|
"mx_AppTileMenuBar_iconButton_pin": !isPinned,
|
||||||
});
|
});
|
||||||
layoutButtons.push(<AccessibleButton
|
layoutButtons.push(<AccessibleButton
|
||||||
|
key="togglePinned"
|
||||||
className={pinnedClasses}
|
className={pinnedClasses}
|
||||||
title={
|
title={
|
||||||
isPinned ? _t("Unpin") : _t("Pin")
|
isPinned ? _t("Unpin") : _t("Pin")
|
||||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Component, CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -58,7 +58,6 @@ export interface ITooltipProps {
|
||||||
@replaceableComponent("views.elements.Tooltip")
|
@replaceableComponent("views.elements.Tooltip")
|
||||||
export default class Tooltip extends React.Component<ITooltipProps> {
|
export default class Tooltip extends React.Component<ITooltipProps> {
|
||||||
private tooltipContainer: HTMLElement;
|
private tooltipContainer: HTMLElement;
|
||||||
private tooltip: void | Element | Component<Element, any, any>;
|
|
||||||
private parent: Element;
|
private parent: Element;
|
||||||
|
|
||||||
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
|
// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
|
||||||
|
@ -178,7 +177,7 @@ export default class Tooltip extends React.Component<ITooltipProps> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render the tooltip manually, as we wish it not to be rendered within the parent
|
// Render the tooltip manually, as we wish it not to be rendered within the parent
|
||||||
this.tooltip = ReactDOM.render<Element>(tooltip, this.tooltipContainer);
|
ReactDOM.render<Element>(tooltip, this.tooltipContainer);
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
|
|
@ -33,10 +33,8 @@ import { Action } from "../../../dispatcher/actions";
|
||||||
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
|
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
|
||||||
import CallViewHeader from './CallView/CallViewHeader';
|
import CallViewHeader from './CallView/CallViewHeader';
|
||||||
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore';
|
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';
|
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import AppTile from '../elements/AppTile';
|
||||||
|
|
||||||
const SHOW_CALL_IN_STATES = [
|
const SHOW_CALL_IN_STATES = [
|
||||||
CallState.Connected,
|
CallState.Connected,
|
||||||
|
@ -63,7 +61,6 @@ interface IState {
|
||||||
// widget candidate to be displayed in the pip view.
|
// widget candidate to be displayed in the pip view.
|
||||||
persistentWidgetId: string;
|
persistentWidgetId: string;
|
||||||
showWidgetInPip: boolean;
|
showWidgetInPip: boolean;
|
||||||
rightPanelPhase: RightPanelPhases;
|
|
||||||
|
|
||||||
moving: boolean;
|
moving: boolean;
|
||||||
}
|
}
|
||||||
|
@ -125,7 +122,6 @@ export default class PipView extends React.Component<IProps, IState> {
|
||||||
primaryCall: primaryCall,
|
primaryCall: primaryCall,
|
||||||
secondaryCall: secondaryCalls[0],
|
secondaryCall: secondaryCalls[0],
|
||||||
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
|
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
|
||||||
rightPanelPhase: RightPanelStore.instance.currentCard.phase,
|
|
||||||
showWidgetInPip: false,
|
showWidgetInPip: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -139,7 +135,6 @@ export default class PipView extends React.Component<IProps, IState> {
|
||||||
if (room) {
|
if (room) {
|
||||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
||||||
}
|
}
|
||||||
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
|
||||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
|
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
|
||||||
document.addEventListener("mouseup", this.onEndMoving.bind(this));
|
document.addEventListener("mouseup", this.onEndMoving.bind(this));
|
||||||
}
|
}
|
||||||
|
@ -154,7 +149,6 @@ export default class PipView extends React.Component<IProps, IState> {
|
||||||
if (room) {
|
if (room) {
|
||||||
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
||||||
}
|
}
|
||||||
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
|
||||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
|
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetStoreUpdate);
|
||||||
document.removeEventListener("mouseup", this.onEndMoving.bind(this));
|
document.removeEventListener("mouseup", this.onEndMoving.bind(this));
|
||||||
}
|
}
|
||||||
|
@ -192,13 +186,6 @@ export default class PipView extends React.Component<IProps, IState> {
|
||||||
this.updateShowWidgetInPip();
|
this.updateShowWidgetInPip();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onRightPanelStoreUpdate = () => {
|
|
||||||
this.setState({
|
|
||||||
rightPanelPhase: RightPanelStore.instance.currentCard.phase,
|
|
||||||
});
|
|
||||||
this.updateShowWidgetInPip();
|
|
||||||
};
|
|
||||||
|
|
||||||
private onActiveWidgetStoreUpdate = (): void => {
|
private onActiveWidgetStoreUpdate = (): void => {
|
||||||
this.updateShowWidgetInPip(ActiveWidgetStore.instance.getPersistentWidgetId());
|
this.updateShowWidgetInPip(ActiveWidgetStore.instance.getPersistentWidgetId());
|
||||||
};
|
};
|
||||||
|
@ -247,14 +234,15 @@ export default class PipView extends React.Component<IProps, IState> {
|
||||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||||
// thus no room is associated anymore.
|
// thus no room is associated anymore.
|
||||||
if (persistentWidgetInRoom) {
|
if (persistentWidgetInRoom) {
|
||||||
const wls = WidgetLayoutStore.instance;
|
notVisible = !AppTile.isLive(persistentWidgetId);
|
||||||
notVisible = !wls.isVisibleOnScreen(persistentWidgetInRoom, persistentWidgetId);
|
|
||||||
fromAnotherRoom = this.state.viewedRoomId !== persistentWidgetInRoomId;
|
fromAnotherRoom = this.state.viewedRoomId !== persistentWidgetInRoomId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The widget should only be shown as a persistent app (in a floating pip container) if it is not visible on screen
|
// The widget should only be shown as a persistent app (in a floating
|
||||||
// either, because we are viewing a different room OR because it is in none of the possible containers of the room view.
|
// 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 || notVisible;
|
const showWidgetInPip = fromAnotherRoom || notVisible;
|
||||||
|
|
||||||
this.setState({ showWidgetInPip, persistentWidgetId });
|
this.setState({ showWidgetInPip, persistentWidgetId });
|
||||||
|
|
|
@ -31,7 +31,6 @@ import {
|
||||||
convertToStorePanel,
|
convertToStorePanel,
|
||||||
IRightPanelForRoom,
|
IRightPanelForRoom,
|
||||||
} from './RightPanelStoreIPanelState';
|
} from './RightPanelStoreIPanelState';
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
|
||||||
import RoomViewStore from '../RoomViewStore';
|
import RoomViewStore from '../RoomViewStore';
|
||||||
|
|
||||||
const GROUP_PHASES = [
|
const GROUP_PHASES = [
|
||||||
|
@ -71,7 +70,7 @@ export default class RightPanelStore extends ReadyWatchingStore {
|
||||||
protected async onReady(): Promise<any> {
|
protected async onReady(): Promise<any> {
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||||
MatrixClientPeg.get().on("crypto.verification.request", this.onVerificationRequestUpdate);
|
this.matrixClient.on("crypto.verification.request", this.onVerificationRequestUpdate);
|
||||||
this.viewedRoomId = RoomViewStore.getRoomId();
|
this.viewedRoomId = RoomViewStore.getRoomId();
|
||||||
this.loadCacheFromSettings();
|
this.loadCacheFromSettings();
|
||||||
this.emitAndUpdateSettings();
|
this.emitAndUpdateSettings();
|
||||||
|
@ -85,7 +84,7 @@ export default class RightPanelStore extends ReadyWatchingStore {
|
||||||
|
|
||||||
protected async onNotReady(): Promise<any> {
|
protected async onNotReady(): Promise<any> {
|
||||||
this.isReady = false;
|
this.isReady = false;
|
||||||
MatrixClientPeg.get().off("crypto.verification.request", this.onVerificationRequestUpdate);
|
this.matrixClient.off("crypto.verification.request", this.onVerificationRequestUpdate);
|
||||||
this.roomStoreToken.remove();
|
this.roomStoreToken.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2021 The Matrix.org Foundation C.I.C.
|
* Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
@ -28,8 +28,6 @@ import { SettingLevel } from "../../settings/SettingLevel";
|
||||||
import { arrayFastClone } from "../../utils/arrays";
|
import { arrayFastClone } from "../../utils/arrays";
|
||||||
import { UPDATE_EVENT } from "../AsyncStore";
|
import { UPDATE_EVENT } from "../AsyncStore";
|
||||||
import { compare } from "../../utils/strings";
|
import { compare } from "../../utils/strings";
|
||||||
import RightPanelStore from "../right-panel/RightPanelStore";
|
|
||||||
import { RightPanelPhases } from "../right-panel/RightPanelStorePhases";
|
|
||||||
|
|
||||||
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
|
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
|
||||||
|
|
||||||
|
@ -354,22 +352,6 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||||
return this.getContainerWidgets(room, container).some(w => w.id === widget.id);
|
return this.getContainerWidgets(room, container).some(w => w.id === widget.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isVisibleOnScreen(room: Optional<Room>, widgetId: string) {
|
|
||||||
const wId = widgetId;
|
|
||||||
const inRightPanel =
|
|
||||||
(RightPanelStore.instance.currentCard.phase == RightPanelPhases.Widget &&
|
|
||||||
wId == RightPanelStore.instance.currentCard.state?.widgetId);
|
|
||||||
const inCenterContainer =
|
|
||||||
this.getContainerWidgets(room, Container.Center).some((app) => app.id == wId);
|
|
||||||
const inTopContainer =
|
|
||||||
this.getContainerWidgets(room, 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 isVisibleOnScreen = (inRightPanel || inCenterContainer || inTopContainer);
|
|
||||||
return isVisibleOnScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
public canAddToContainer(room: Room, container: Container): boolean {
|
public canAddToContainer(room: Room, container: Container): boolean {
|
||||||
switch (container) {
|
switch (container) {
|
||||||
case Container.Top: return this.getContainerWidgets(room, container).length < MAX_PINNED;
|
case Container.Top: return this.getContainerWidgets(room, container).length < MAX_PINNED;
|
||||||
|
|
|
@ -18,7 +18,6 @@ import React from "react";
|
||||||
import TestRenderer from "react-test-renderer";
|
import TestRenderer from "react-test-renderer";
|
||||||
import { jest } from "@jest/globals";
|
import { jest } from "@jest/globals";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
|
||||||
|
|
||||||
// We can't use the usual `skinned-sdk`, as it stubs out the RightPanel
|
// We can't use the usual `skinned-sdk`, as it stubs out the RightPanel
|
||||||
import "../../minimal-sdk";
|
import "../../minimal-sdk";
|
||||||
|
@ -34,6 +33,7 @@ import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
|
||||||
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
|
||||||
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
||||||
|
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||||
|
|
||||||
describe("RightPanel", () => {
|
describe("RightPanel", () => {
|
||||||
it("renders info from only one room during room changes", async () => {
|
it("renders info from only one room during room changes", async () => {
|
||||||
|
@ -72,13 +72,13 @@ describe("RightPanel", () => {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wake up any stores waiting for a client
|
// Wake up various stores we rely on
|
||||||
dis.dispatch({
|
WidgetLayoutStore.instance.useUnitTestClient(cli);
|
||||||
action: "MatrixActions.sync",
|
// @ts-ignore
|
||||||
prevState: SyncState.Prepared,
|
await WidgetLayoutStore.instance.onReady();
|
||||||
state: SyncState.Syncing,
|
RightPanelStore.instance.useUnitTestClient(cli);
|
||||||
matrixClient: cli,
|
// @ts-ignore
|
||||||
});
|
await RightPanelStore.instance.onReady();
|
||||||
|
|
||||||
const resizeNotifier = new ResizeNotifier();
|
const resizeNotifier = new ResizeNotifier();
|
||||||
|
|
||||||
|
@ -139,7 +139,11 @@ describe("RightPanel", () => {
|
||||||
await rendered;
|
await rendered;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
await WidgetLayoutStore.instance.onNotReady();
|
||||||
|
// @ts-ignore
|
||||||
|
await RightPanelStore.instance.onNotReady();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
216
test/components/views/elements/AppTile-test.tsx
Normal file
216
test/components/views/elements/AppTile-test.tsx
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
/*
|
||||||
|
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 from "react";
|
||||||
|
import TestRenderer from "react-test-renderer";
|
||||||
|
import { jest } from "@jest/globals";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { MatrixWidgetType } from "matrix-widget-api";
|
||||||
|
|
||||||
|
// We can't use the usual `skinned-sdk`, as it stubs out the RightPanel
|
||||||
|
import "../../../minimal-sdk";
|
||||||
|
import RightPanel from "../../../../src/components/structures/RightPanel";
|
||||||
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
|
import ResizeNotifier from "../../../../src/utils/ResizeNotifier";
|
||||||
|
import { stubClient } from "../../../test-utils";
|
||||||
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
|
import dis from "../../../../src/dispatcher/dispatcher";
|
||||||
|
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||||
|
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||||
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
|
import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||||
|
import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore";
|
||||||
|
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
|
||||||
|
import WidgetStore, { IApp } from "../../../../src/stores/WidgetStore";
|
||||||
|
import AppTile from "../../../../src/components/views/elements/AppTile";
|
||||||
|
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
|
||||||
|
import AppsDrawer from "../../../../src/components/views/rooms/AppsDrawer";
|
||||||
|
|
||||||
|
describe("AppTile", () => {
|
||||||
|
let cli;
|
||||||
|
let r1;
|
||||||
|
let r2;
|
||||||
|
const resizeNotifier = new ResizeNotifier();
|
||||||
|
let app: IApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
stubClient();
|
||||||
|
cli = MatrixClientPeg.get();
|
||||||
|
cli.hasLazyLoadMembersEnabled = () => false;
|
||||||
|
|
||||||
|
// Init misc. startup deps
|
||||||
|
DMRoomMap.makeShared();
|
||||||
|
|
||||||
|
r1 = new Room("r1", cli, "@name:example.com");
|
||||||
|
r2 = new Room("r2", cli, "@name:example.com");
|
||||||
|
|
||||||
|
jest.spyOn(cli, "getRoom").mockImplementation(roomId => {
|
||||||
|
if (roomId === "r1") return r1;
|
||||||
|
if (roomId === "r2") return r2;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
jest.spyOn(cli, "getVisibleRooms").mockImplementation(() => {
|
||||||
|
return [r1, r2];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust various widget stores to add a mock app
|
||||||
|
app = {
|
||||||
|
id: "1",
|
||||||
|
eventId: "1",
|
||||||
|
roomId: "r1",
|
||||||
|
type: MatrixWidgetType.Custom,
|
||||||
|
url: "https://example.com",
|
||||||
|
name: "Example",
|
||||||
|
creatorUserId: cli.getUserId(),
|
||||||
|
avatar_url: null,
|
||||||
|
};
|
||||||
|
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([app]);
|
||||||
|
|
||||||
|
// Wake up various stores we rely on
|
||||||
|
WidgetLayoutStore.instance.useUnitTestClient(cli);
|
||||||
|
// @ts-ignore
|
||||||
|
await WidgetLayoutStore.instance.onReady();
|
||||||
|
RightPanelStore.instance.useUnitTestClient(cli);
|
||||||
|
// @ts-ignore
|
||||||
|
await RightPanelStore.instance.onReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys non-persisted right panel widget on room change", async () => {
|
||||||
|
// Set up right panel state
|
||||||
|
const realGetValue = SettingsStore.getValue;
|
||||||
|
const mockSettings = jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => {
|
||||||
|
if (name !== "RightPanel.phases") return realGetValue(name, roomId);
|
||||||
|
if (roomId === "r1") {
|
||||||
|
return {
|
||||||
|
history: [{
|
||||||
|
phase: RightPanelPhases.Widget,
|
||||||
|
state: {
|
||||||
|
widgetId: "1",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run initial render with room 1, and also running lifecycle methods
|
||||||
|
const renderer = TestRenderer.create(<MatrixClientContext.Provider value={cli}>
|
||||||
|
<RightPanel
|
||||||
|
room={r1}
|
||||||
|
resizeNotifier={resizeNotifier}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>);
|
||||||
|
// Wait for RPS room 1 updates to fire
|
||||||
|
const rpsUpdated = new Promise<void>(resolve => {
|
||||||
|
const update = () => {
|
||||||
|
if (
|
||||||
|
RightPanelStore.instance.currentCardForRoom("r1").phase !==
|
||||||
|
RightPanelPhases.Widget
|
||||||
|
) return;
|
||||||
|
RightPanelStore.instance.off(UPDATE_EVENT, update);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
RightPanelStore.instance.on(UPDATE_EVENT, update);
|
||||||
|
});
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
room_id: "r1",
|
||||||
|
});
|
||||||
|
await rpsUpdated;
|
||||||
|
|
||||||
|
expect(AppTile.isLive("1")).toBe(true);
|
||||||
|
|
||||||
|
// We want to verify that as we change to room 2, we should close the
|
||||||
|
// right panel and destroy the widget.
|
||||||
|
const instance = renderer.root.findByType(AppTile).instance;
|
||||||
|
const endWidgetActions = jest.spyOn(instance, "endWidgetActions");
|
||||||
|
|
||||||
|
console.log("Switch to room 2");
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
room_id: "r2",
|
||||||
|
});
|
||||||
|
renderer.update(<MatrixClientContext.Provider value={cli}>
|
||||||
|
<RightPanel
|
||||||
|
room={r2}
|
||||||
|
resizeNotifier={resizeNotifier}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>);
|
||||||
|
|
||||||
|
expect(endWidgetActions.mock.calls.length).toBe(1);
|
||||||
|
expect(AppTile.isLive("1")).toBe(false);
|
||||||
|
|
||||||
|
mockSettings.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-persisted widget on container move", async () => {
|
||||||
|
// Set up widget in top container
|
||||||
|
const realGetValue = SettingsStore.getValue;
|
||||||
|
const mockSettings = jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => {
|
||||||
|
if (name !== "Widgets.layout") return realGetValue(name, roomId);
|
||||||
|
if (roomId === "r1") {
|
||||||
|
return {
|
||||||
|
widgets: {
|
||||||
|
1: {
|
||||||
|
container: Container.Top,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
TestRenderer.act(() => {
|
||||||
|
WidgetLayoutStore.instance.recalculateRoom(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run initial render with room 1, and also running lifecycle methods
|
||||||
|
const renderer = TestRenderer.create(<MatrixClientContext.Provider value={cli}>
|
||||||
|
<AppsDrawer
|
||||||
|
userId={cli.getUserId()}
|
||||||
|
room={r1}
|
||||||
|
resizeNotifier={resizeNotifier}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>);
|
||||||
|
|
||||||
|
expect(AppTile.isLive("1")).toBe(true);
|
||||||
|
|
||||||
|
// We want to verify that as we move the widget to the center container,
|
||||||
|
// the widget frame remains running.
|
||||||
|
const instance = renderer.root.findByType(AppTile).instance;
|
||||||
|
const endWidgetActions = jest.spyOn(instance, "endWidgetActions");
|
||||||
|
|
||||||
|
console.log("Move widget to center");
|
||||||
|
|
||||||
|
// Stop mocking settings so that the widget move can take effect
|
||||||
|
mockSettings.mockRestore();
|
||||||
|
TestRenderer.act(() => {
|
||||||
|
WidgetLayoutStore.instance.moveToContainer(r1, app, Container.Center);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(endWidgetActions.mock.calls.length).toBe(0);
|
||||||
|
expect(AppTile.isLive("1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
await WidgetLayoutStore.instance.onNotReady();
|
||||||
|
// @ts-ignore
|
||||||
|
await RightPanelStore.instance.onNotReady();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
|
@ -56,8 +56,9 @@ export function createTestClient() {
|
||||||
getGroups: jest.fn().mockReturnValue([]),
|
getGroups: jest.fn().mockReturnValue([]),
|
||||||
loginFlows: jest.fn(),
|
loginFlows: jest.fn(),
|
||||||
on: eventEmitter.on.bind(eventEmitter),
|
on: eventEmitter.on.bind(eventEmitter),
|
||||||
emit: eventEmitter.emit.bind(eventEmitter),
|
off: eventEmitter.off.bind(eventEmitter),
|
||||||
removeListener: eventEmitter.removeListener.bind(eventEmitter),
|
removeListener: eventEmitter.removeListener.bind(eventEmitter),
|
||||||
|
emit: eventEmitter.emit.bind(eventEmitter),
|
||||||
isRoomEncrypted: jest.fn().mockReturnValue(false),
|
isRoomEncrypted: jest.fn().mockReturnValue(false),
|
||||||
peekInRoom: jest.fn().mockResolvedValue(mkStubRoom()),
|
peekInRoom: jest.fn().mockResolvedValue(mkStubRoom()),
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue