From ec92102fe35e3ecfe3d1a8647241fbf4b5664fa3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 7 Feb 2022 14:40:22 +0000 Subject: [PATCH] Properly handle persistent widgets when room is left (#7724) --- src/components/views/elements/AppTile.tsx | 155 ++++++++++++------ .../views/elements/PersistentApp.tsx | 68 +++----- src/components/views/voip/PipView.tsx | 6 +- src/stores/widgets/StopGapWidget.ts | 4 +- 4 files changed, 130 insertions(+), 103 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index de5cd67301..ae66bcab00 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -18,14 +18,13 @@ limitations under the License. */ import url from 'url'; -import React, { createRef } from 'react'; +import React, { ContextType, createRef } from 'react'; import classNames from 'classnames'; import { MatrixCapabilities } from "matrix-widget-api"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; import { EventSubscription } from 'fbemitter'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import AccessibleButton from './AccessibleButton'; import { _t } from '../../../languageHandler'; import AppPermission from './AppPermission'; @@ -49,12 +48,14 @@ import { OwnProfileStore } from '../../../stores/OwnProfileStore'; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; import RoomViewStore from '../../../stores/RoomViewStore'; import WidgetUtils from '../../../utils/WidgetUtils'; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ActionPayload } from "../../../dispatcher/payloads"; interface IProps { app: IApp; // If room is not specified then it is an account level widget // which bypasses permission prompts as it was added explicitly by that user - room: Room; + room?: Room; threadId?: string | null; // 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. @@ -102,6 +103,9 @@ interface IState { @replaceableComponent("views.elements.AppTile") export default class AppTile extends React.Component { + public static contextType = MatrixClientContext; + context: ContextType; + public static defaultProps: Partial = { waitForIframeLoad: true, showMenubar: true, @@ -128,10 +132,7 @@ export default class AppTile extends React.Component { this.persistKey = getPersistKey(this.props.app.id); try { this.sgWidget = new StopGapWidget(this.props); - this.sgWidget.on("preparing", this.onWidgetPreparing); - this.sgWidget.on("ready", this.onWidgetReady); - // emits when the capabilites have been setup or changed - this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified); + this.setupSgListeners(); } catch (e) { logger.log("Failed to construct widget", e); this.sgWidget = null; @@ -164,26 +165,42 @@ export default class AppTile extends React.Component { }; private onWidgetLayoutChange = () => { - const room = MatrixClientPeg.get().getRoom(this.props.room.roomId); - const app = this.props.app; - const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(app.id); - const isVisibleOnScreen = WidgetLayoutStore.instance.isVisibleOnScreen(room, app.id); + const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(this.props.app.id); + const isVisibleOnScreen = WidgetLayoutStore.instance.isVisibleOnScreen(this.props.room, this.props.app.id); if (!isVisibleOnScreen && !isActiveWidget) { - ActiveWidgetStore.instance.destroyPersistentWidget(app.id); - PersistedElement.destroyElement(this.persistKey); - this.sgWidget?.stopMessaging(); + this.endWidgetActions(); } }; private onRoomViewStoreUpdate = () => { if (this.props.room.roomId == RoomViewStore.getRoomId()) return; - const app = this.props.app; - const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(app.id); + 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) { - ActiveWidgetStore.instance.destroyPersistentWidget(app.id); - PersistedElement.destroyElement(this.persistKey); - this.sgWidget?.stopMessaging(); + this.endWidgetActions(); + } + }; + + 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 { 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.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); @@ -262,6 +280,7 @@ export default class AppTile extends React.Component { 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.roomStoreToken?.remove(); @@ -269,12 +288,27 @@ export default class AppTile extends React.Component { OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady); } + private setupSgListeners() { + this.sgWidget.on("preparing", this.onWidgetPreparing); + 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.sgWidget.on("preparing", this.onWidgetPreparing); - this.sgWidget.on("ready", this.onWidgetReady); + this.setupSgListeners(); this.startWidget(); } catch (e) { logger.error("Failed to construct widget", e); @@ -288,14 +322,18 @@ export default class AppTile extends React.Component { }); } + private startMessaging() { + try { + this.sgWidget?.startMessaging(this.iframe); + } catch (e) { + logger.error("Failed to start widget", e); + } + } + private iframeRefChange = (ref: HTMLIFrameElement): void => { this.iframe = ref; if (ref) { - try { - this.sgWidget?.startMessaging(ref); - } catch (e) { - logger.error("Failed to start widget", e); - } + this.startMessaging(); } else { this.resetWidget(this.props); } @@ -364,24 +402,31 @@ export default class AppTile extends React.Component { }); }; - private onAction = (payload): void => { - if (payload.widgetId === this.props.app.id) { - switch (payload.action) { - case 'm.sticker': - if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { - dis.dispatch({ - action: 'post_sticker_message', - data: { - ...payload.data, - threadId: this.props.threadId, - }, - }); - dis.dispatch({ action: 'stickerpicker_close' }); - } else { - logger.warn('Ignoring sticker message. Invalid capability'); - } - break; - } + private onAction = (payload: ActionPayload): void => { + switch (payload.action) { + case 'm.sticker': + if (payload.widgetId === this.props.app.id && + this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending) + ) { + dis.dispatch({ + action: 'post_sticker_message', + data: { + ...payload.data, + threadId: this.props.threadId, + }, + }); + dis.dispatch({ action: 'stickerpicker_close' }); + } else { + logger.warn('Ignoring sticker message. Invalid capability'); + } + 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,17 +481,25 @@ export default class AppTile extends React.Component { ); } + private reload() { + this.endWidgetActions().then(() => { + // reset messaging + this.resetWidget(this.props); + this.startMessaging(); + + if (this.iframe) { + // Reload iframe + 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.endWidgetActions().then(() => { - if (this.iframe) { - // Reload iframe - this.iframe.src = this.sgWidget.embedUrl; - } - }); + this.reload(); } // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); @@ -507,7 +560,7 @@ export default class AppTile extends React.Component { ); } else if (!this.state.hasPermissionToLoad) { // 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 = (
{ - constructor(props: IProps) { - super(props); +export default class PersistentApp extends React.Component { + public static contextType = MatrixClientContext; + context: ContextType; - this.state = { - 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 => { + private get app(): IApp { const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId); - if (membership !== "join") { - // we're not in the room anymore - delete - if (room.roomId === persistentWidgetInRoomId) { - ActiveWidgetStore.instance.destroyPersistentWidget(this.props.persistentWidgetId); - } - } - }; + const persistentWidgetInRoom = this.context.getRoom(persistentWidgetInRoomId); + + // get the widget data + const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => { + return ev.getStateKey() === ActiveWidgetStore.instance.getPersistentWidgetId(); + }); + return WidgetUtils.makeAppConfig( + appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), + persistentWidgetInRoomId, appEvent.getId(), + ); + } public render(): JSX.Element { - const wId = this.props.persistentWidgetId; - if (wId) { - const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(wId); - const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId); + const app = this.app; + if (app) { + const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId); + const persistentWidgetInRoom = this.context.getRoom(persistentWidgetInRoomId); - // 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 { // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId public updateShowWidgetInPip(persistentWidgetId = this.state.persistentWidgetId) { - let userIsPartOfTheRoom = false; let fromAnotherRoom = false; let notVisible = false; if (persistentWidgetId) { @@ -248,16 +247,13 @@ export default class PipView extends React.Component { if (persistentWidgetInRoom) { const wls = WidgetLayoutStore.instance; notVisible = !wls.isVisibleOnScreen(persistentWidgetInRoom, persistentWidgetId); - userIsPartOfTheRoom = persistentWidgetInRoom.getMyMembership() == "join"; 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 // 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) || - (notVisible && userIsPartOfTheRoom); + const showWidgetInPip = fromAnotherRoom || notVisible; this.setState({ showWidgetInPip, persistentWidgetId }); } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 5d1f141218..3d09be835f 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -64,9 +64,8 @@ import { arrayFastClone } from "../../utils/arrays"; interface IAppTileProps { // Note: these are only the props we care about - app: IWidget; - room: Room; + room?: Room; // without a room it is a user widget userId: string; creatorUserId: string; waitForIframeLoad: boolean; @@ -423,6 +422,7 @@ export class StopGapWidget extends EventEmitter { if (!this.started) return; WidgetMessagingStore.instance.stopMessaging(this.mockWidget); ActiveWidgetStore.instance.delRoomId(this.mockWidget.id); + this.messaging = null; if (MatrixClientPeg.get()) { MatrixClientPeg.get().off('event', this.onEvent);