Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Properly handle persistent widgets when room is left #7724

Merged
merged 8 commits into from
Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 104 additions & 51 deletions src/components/views/elements/AppTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -102,6 +103,9 @@ interface IState {

@replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
context: ContextType<typeof MatrixClientContext>;

public static defaultProps: Partial<IProps> = {
waitForIframeLoad: true,
showMenubar: true,
Expand All @@ -128,10 +132,7 @@ export default class AppTile extends React.Component<IProps, IState> {
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;
Expand Down Expand Up @@ -164,26 +165,42 @@ export default class AppTile extends React.Component<IProps, IState> {
};

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();
}
};

Expand Down Expand Up @@ -247,6 +264,7 @@ export default class AppTile extends React.Component<IProps, IState> {
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);
Expand All @@ -262,19 +280,35 @@ export default class AppTile extends React.Component<IProps, IState> {
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();
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
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);
Expand All @@ -288,14 +322,18 @@ export default class AppTile extends React.Component<IProps, IState> {
});
}

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);
}
Expand Down Expand Up @@ -364,24 +402,31 @@ export default class AppTile extends React.Component<IProps, IState> {
});
};

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;
}
};

Expand Down Expand Up @@ -436,17 +481,25 @@ export default class AppTile extends React.Component<IProps, IState> {
);
}

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');
Expand Down Expand Up @@ -507,7 +560,7 @@ export default class AppTile extends React.Component<IProps, IState> {
);
} 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 = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppPermission
Expand Down
66 changes: 22 additions & 44 deletions src/components/views/elements/PersistentApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,73 +15,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import React, { ContextType } from 'react';

import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AppTile from "./AppTile";
import { IApp } from '../../../stores/WidgetStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";

interface IProps {
persistentWidgetId: string;
pointerEvents?: string;
}

interface IState {
roomId: string;
}

@replaceableComponent("views.elements.PersistentApp")
export default class PersistentApp extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
export default class PersistentApp extends React.Component<IProps> {
public static contextType = MatrixClientContext;
context: ContextType<typeof MatrixClientContext>;

this.state = {
roomId: RoomViewStore.getRoomId(),
};
}

public componentDidMount(): void {
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
}
private get app(): IApp {
const persistentWidgetInRoomId = ActiveWidgetStore.instance.getRoomId(this.props.persistentWidgetId);
const persistentWidgetInRoom = this.context.getRoom(persistentWidgetInRoomId);

public componentWillUnmount(): void {
MatrixClientPeg.get().off("Room.myMembership", this.onMyMembership);
// 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(),
);
}

private onMyMembership = async (room: Room, membership: string): Promise<void> => {
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);
}
}
};

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 <AppTile
key={app.id}
app={app}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
userId={this.context.credentials.userId}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
Expand Down
6 changes: 1 addition & 5 deletions src/components/views/voip/PipView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
public updateShowWidgetInPip(persistentWidgetId = this.state.persistentWidgetId) {
let userIsPartOfTheRoom = false;
let fromAnotherRoom = false;
let notVisible = false;
if (persistentWidgetId) {
Expand All @@ -248,16 +247,13 @@ export default class PipView extends React.Component<IProps, IState> {
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 });
}
Expand Down
Loading