Properly handle persistent widgets when room is left (#7724)

This commit is contained in:
Michael Telatynski 2022-02-07 14:40:22 +00:00 committed by GitHub
parent d72469663d
commit ec92102fe3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 130 additions and 103 deletions

View file

@ -18,14 +18,13 @@ limitations under the License.
*/ */
import url from 'url'; import url from 'url';
import React, { createRef } from 'react'; import React, { ContextType, createRef } from 'react';
import classNames from 'classnames'; 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 { EventSubscription } from 'fbemitter';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AppPermission from './AppPermission'; import AppPermission from './AppPermission';
@ -49,12 +48,14 @@ import { OwnProfileStore } from '../../../stores/OwnProfileStore';
import { UPDATE_EVENT } from '../../../stores/AsyncStore'; import { UPDATE_EVENT } from '../../../stores/AsyncStore';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ActionPayload } from "../../../dispatcher/payloads";
interface IProps { interface IProps {
app: IApp; app: IApp;
// If room is not specified then it is an account level widget // If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user // which bypasses permission prompts as it was added explicitly by that user
room: Room; room?: Room;
threadId?: string | null; threadId?: string | null;
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false. // This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
@ -102,6 +103,9 @@ interface IState {
@replaceableComponent("views.elements.AppTile") @replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component<IProps, IState> { export default class AppTile extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
context: ContextType<typeof MatrixClientContext>;
public static defaultProps: Partial<IProps> = { public static defaultProps: Partial<IProps> = {
waitForIframeLoad: true, waitForIframeLoad: true,
showMenubar: true, showMenubar: true,
@ -128,10 +132,7 @@ export default class AppTile extends React.Component<IProps, IState> {
this.persistKey = getPersistKey(this.props.app.id); this.persistKey = getPersistKey(this.props.app.id);
try { try {
this.sgWidget = new StopGapWidget(this.props); this.sgWidget = new StopGapWidget(this.props);
this.sgWidget.on("preparing", this.onWidgetPreparing); this.setupSgListeners();
this.sgWidget.on("ready", this.onWidgetReady);
// emits when the capabilites have been setup or changed
this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
} catch (e) { } catch (e) {
logger.log("Failed to construct widget", e); logger.log("Failed to construct widget", e);
this.sgWidget = null; this.sgWidget = null;
@ -164,26 +165,42 @@ export default class AppTile extends React.Component<IProps, IState> {
}; };
private onWidgetLayoutChange = () => { private onWidgetLayoutChange = () => {
const room = MatrixClientPeg.get().getRoom(this.props.room.roomId); const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
const app = this.props.app; const isVisibleOnScreen = WidgetLayoutStore.instance.isVisibleOnScreen(this.props.room, this.props.app.id);
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(app.id);
const isVisibleOnScreen = WidgetLayoutStore.instance.isVisibleOnScreen(room, app.id);
if (!isVisibleOnScreen && !isActiveWidget) { if (!isVisibleOnScreen && !isActiveWidget) {
ActiveWidgetStore.instance.destroyPersistentWidget(app.id); this.endWidgetActions();
PersistedElement.destroyElement(this.persistKey);
this.sgWidget?.stopMessaging();
} }
}; };
private onRoomViewStoreUpdate = () => { private onRoomViewStoreUpdate = () => {
if (this.props.room.roomId == RoomViewStore.getRoomId()) return; if (this.props.room.roomId == RoomViewStore.getRoomId()) return;
const app = this.props.app; const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(app.id);
// Stop the widget if it's not the active (persistent) widget and it's not a user widget // Stop the widget if it's not the active (persistent) widget and it's not a user widget
if (!isActiveWidget && !this.props.userWidget) { if (!isActiveWidget && !this.props.userWidget) {
ActiveWidgetStore.instance.destroyPersistentWidget(app.id); this.endWidgetActions();
PersistedElement.destroyElement(this.persistKey); }
this.sgWidget?.stopMessaging(); };
private onUserLeftRoom() {
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id);
if (isActiveWidget) {
// We just left the room that the active widget was from.
if (RoomViewStore.getRoomId() !== this.props.room.roomId) {
// If we are not actively looking at the room then destroy this widget entirely.
this.endWidgetActions();
} else if (WidgetType.JITSI.matches(this.props.app.type)) {
// If this was a Jitsi then reload to end call.
this.reload();
} else {
// Otherwise just cancel its persistence.
ActiveWidgetStore.instance.destroyPersistentWidget(this.props.app.id);
}
}
}
private onMyMembership = (room: Room, membership: string): void => {
if (membership === "leave" && room.roomId === this.props.room.roomId) {
this.onUserLeftRoom();
} }
}; };
@ -247,6 +264,7 @@ export default class AppTile extends React.Component<IProps, IState> {
if (this.props.room) { if (this.props.room) {
const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room); const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room);
WidgetLayoutStore.instance.on(emitEvent, this.onWidgetLayoutChange); WidgetLayoutStore.instance.on(emitEvent, this.onWidgetLayoutChange);
this.context.on("Room.myMembership", this.onMyMembership);
} }
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
@ -262,6 +280,7 @@ export default class AppTile extends React.Component<IProps, IState> {
if (this.props.room) { if (this.props.room) {
const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room); const emitEvent = WidgetLayoutStore.emissionForRoom(this.props.room);
WidgetLayoutStore.instance.off(emitEvent, this.onWidgetLayoutChange); WidgetLayoutStore.instance.off(emitEvent, this.onWidgetLayoutChange);
this.context.off("Room.myMembership", this.onMyMembership);
} }
this.roomStoreToken?.remove(); this.roomStoreToken?.remove();
@ -269,12 +288,27 @@ export default class AppTile extends React.Component<IProps, IState> {
OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady); OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady);
} }
private resetWidget(newProps: IProps): void { private setupSgListeners() {
this.sgWidget?.stopMessaging();
try {
this.sgWidget = new StopGapWidget(newProps);
this.sgWidget.on("preparing", this.onWidgetPreparing); this.sgWidget.on("preparing", this.onWidgetPreparing);
this.sgWidget.on("ready", this.onWidgetReady); this.sgWidget.on("ready", this.onWidgetReady);
// emits when the capabilites have been setup or changed
this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
private stopSgListeners() {
if (!this.sgWidget) return;
this.sgWidget.off("preparing", this.onWidgetPreparing);
this.sgWidget.off("ready", this.onWidgetReady);
this.sgWidget.off("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
private resetWidget(newProps: IProps): void {
this.sgWidget?.stopMessaging();
this.stopSgListeners();
try {
this.sgWidget = new StopGapWidget(newProps);
this.setupSgListeners();
this.startWidget(); this.startWidget();
} catch (e) { } catch (e) {
logger.error("Failed to construct widget", e); logger.error("Failed to construct widget", e);
@ -288,14 +322,18 @@ export default class AppTile extends React.Component<IProps, IState> {
}); });
} }
private iframeRefChange = (ref: HTMLIFrameElement): void => { private startMessaging() {
this.iframe = ref;
if (ref) {
try { try {
this.sgWidget?.startMessaging(ref); this.sgWidget?.startMessaging(this.iframe);
} catch (e) { } catch (e) {
logger.error("Failed to start widget", e); logger.error("Failed to start widget", e);
} }
}
private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref;
if (ref) {
this.startMessaging();
} else { } else {
this.resetWidget(this.props); this.resetWidget(this.props);
} }
@ -364,11 +402,12 @@ export default class AppTile extends React.Component<IProps, IState> {
}); });
}; };
private onAction = (payload): void => { private onAction = (payload: ActionPayload): void => {
if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { if (payload.widgetId === this.props.app.id &&
this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)
) {
dis.dispatch({ dis.dispatch({
action: 'post_sticker_message', action: 'post_sticker_message',
data: { data: {
@ -381,7 +420,13 @@ export default class AppTile extends React.Component<IProps, IState> {
logger.warn('Ignoring sticker message. Invalid capability'); logger.warn('Ignoring sticker message. Invalid capability');
} }
break; break;
case "after_leave_room":
if (payload.room_id === this.props.room?.roomId) {
// call this before we get it echoed down /sync, so it doesn't hang around as long and look jarring
this.onUserLeftRoom();
} }
break;
} }
}; };
@ -436,18 +481,26 @@ export default class AppTile extends React.Component<IProps, IState> {
); );
} }
// TODO replace with full screen interactions private reload() {
private onPopoutWidgetClick = (): void => {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type)) {
this.endWidgetActions().then(() => { this.endWidgetActions().then(() => {
// reset messaging
this.resetWidget(this.props);
this.startMessaging();
if (this.iframe) { if (this.iframe) {
// Reload iframe // Reload iframe
this.iframe.src = this.sgWidget.embedUrl; this.iframe.src = this.sgWidget.embedUrl;
} }
}); });
} }
// TODO replace with full screen interactions
private onPopoutWidgetClick = (): void => {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type)) {
this.reload();
}
// Using Object.assign workaround as the following opens in a new window instead of a new tab. // Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), Object.assign(document.createElement('a'),
@ -507,7 +560,7 @@ export default class AppTile extends React.Component<IProps, IState> {
); );
} else if (!this.state.hasPermissionToLoad) { } else if (!this.state.hasPermissionToLoad) {
// only possible for room widgets, can assert this.props.room here // only possible for room widgets, can assert this.props.room here
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); const isEncrypted = this.context.isRoomEncrypted(this.props.room.roomId);
appTileBody = ( appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}> <div className={appTileBodyClass} style={appTileBodyStyles}>
<AppPermission <AppPermission

View file

@ -15,73 +15,51 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ContextType } from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import AppTile from "./AppTile"; import AppTile from "./AppTile";
import { IApp } from '../../../stores/WidgetStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps { interface IProps {
persistentWidgetId: string; persistentWidgetId: string;
pointerEvents?: string; pointerEvents?: string;
} }
interface IState {
roomId: string;
}
@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> {
constructor(props: IProps) { public static contextType = MatrixClientContext;
super(props); context: ContextType<typeof MatrixClientContext>;
this.state = { private get app(): IApp {
roomId: RoomViewStore.getRoomId(),
};
}
public componentDidMount(): void {
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
}
public componentWillUnmount(): void {
MatrixClientPeg.get().off("Room.myMembership", this.onMyMembership);
}
private onMyMembership = async (room: Room, membership: string): Promise<void> => {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId); const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId);
if (membership !== "join") { const persistentWidgetInRoom = this.context.getRoom(persistentWidgetInRoomId);
// we're not in the room anymore - delete
if (room.roomId === persistentWidgetInRoomId) {
ActiveWidgetStore.instance.destroyPersistentWidget(this.props.persistentWidgetId);
}
}
};
public render(): JSX.Element {
const wId = this.props.persistentWidgetId;
if (wId) {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId);
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// get the widget data // get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => { const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId(); return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId();
}); });
const app = WidgetUtils.makeAppConfig( return WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
persistentWidgetInRoomId, appEvent.getId(), persistentWidgetInRoomId, appEvent.getId(),
); );
}
public render(): JSX.Element {
const app = this.app;
if (app) {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId);
const persistentWidgetInRoom = this.context.getRoom(persistentWidgetInRoomId);
return <AppTile return <AppTile
key={app.id} key={app.id}
app={app} app={app}
fullWidth={true} fullWidth={true}
room={persistentWidgetInRoom} room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId} userId={this.context.credentials.userId}
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}

View file

@ -236,7 +236,6 @@ export default class PipView extends React.Component<IProps, IState> {
// Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId
public updateShowWidgetInPip(persistentWidgetId = this.state.persistentWidgetId) { public updateShowWidgetInPip(persistentWidgetId = this.state.persistentWidgetId) {
let userIsPartOfTheRoom = false;
let fromAnotherRoom = false; let fromAnotherRoom = false;
let notVisible = false; let notVisible = false;
if (persistentWidgetId) { if (persistentWidgetId) {
@ -248,16 +247,13 @@ export default class PipView extends React.Component<IProps, IState> {
if (persistentWidgetInRoom) { if (persistentWidgetInRoom) {
const wls = WidgetLayoutStore.instance; const wls = WidgetLayoutStore.instance;
notVisible = !wls.isVisibleOnScreen(persistentWidgetInRoom, persistentWidgetId); notVisible = !wls.isVisibleOnScreen(persistentWidgetInRoom, persistentWidgetId);
userIsPartOfTheRoom = persistentWidgetInRoom.getMyMembership() == "join";
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 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. // either, because we are viewing a different room OR because it is in none of the possible containers of the room view.
const showWidgetInPip = const showWidgetInPip = fromAnotherRoom || notVisible;
(fromAnotherRoom && userIsPartOfTheRoom) ||
(notVisible && userIsPartOfTheRoom);
this.setState({ showWidgetInPip, persistentWidgetId }); this.setState({ showWidgetInPip, persistentWidgetId });
} }

View file

@ -64,9 +64,8 @@ import { arrayFastClone } from "../../utils/arrays";
interface IAppTileProps { interface IAppTileProps {
// Note: these are only the props we care about // Note: these are only the props we care about
app: IWidget; app: IWidget;
room: Room; room?: Room; // without a room it is a user widget
userId: string; userId: string;
creatorUserId: string; creatorUserId: string;
waitForIframeLoad: boolean; waitForIframeLoad: boolean;
@ -423,6 +422,7 @@ export class StopGapWidget extends EventEmitter {
if (!this.started) return; if (!this.started) return;
WidgetMessagingStore.instance.stopMessaging(this.mockWidget); WidgetMessagingStore.instance.stopMessaging(this.mockWidget);
ActiveWidgetStore.instance.delRoomId(this.mockWidget.id); ActiveWidgetStore.instance.delRoomId(this.mockWidget.id);
this.messaging = null;
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().off('event', this.onEvent); MatrixClientPeg.get().off('event', this.onEvent);