* Add widget lifecycle API at top level * Integrate while still falling back to the legacy api * Remove WidgetKind * Update module api to the one that includes the new widget lifecycle api * lint * Make preload checks easier to understand - Have single code path for preload checks. - Remove duplicated logic for preapproveIdentity check - Fix headers * lint
908 lines
39 KiB
TypeScript
908 lines
39 KiB
TypeScript
/*
|
||
Copyright 2024 New Vector Ltd.
|
||
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
|
||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||
Copyright 2018 New Vector Ltd
|
||
Copyright 2017 Vector Creations Ltd
|
||
|
||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||
Please see LICENSE files in the repository root for full details.
|
||
*/
|
||
|
||
import React, {
|
||
type JSX,
|
||
type ContextType,
|
||
createRef,
|
||
type CSSProperties,
|
||
type RefObject,
|
||
type ReactNode,
|
||
} from "react";
|
||
import classNames from "classnames";
|
||
import { type IWidget, MatrixCapabilities, type ClientWidgetApi } from "matrix-widget-api";
|
||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||
import { logger } from "matrix-js-sdk/src/logger";
|
||
import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
|
||
import {
|
||
OverflowHorizontalIcon,
|
||
MinusIcon,
|
||
ExpandIcon,
|
||
CollapseIcon,
|
||
PopOutIcon,
|
||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||
import { I18nContext } from "@element-hq/web-shared-components";
|
||
|
||
import AccessibleButton from "./AccessibleButton";
|
||
import { _t } from "../../../languageHandler";
|
||
import AppPermission from "./AppPermission";
|
||
import AppWarning from "./AppWarning";
|
||
import Spinner from "./Spinner";
|
||
import dis from "../../../dispatcher/dispatcher";
|
||
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
|
||
import SettingsStore from "../../../settings/SettingsStore";
|
||
import { ContextMenuButton } from "../../structures/ContextMenu";
|
||
import PersistedElement, { getPersistKey } from "./PersistedElement";
|
||
import { WidgetType } from "../../../widgets/WidgetType";
|
||
import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging";
|
||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
|
||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
|
||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||
import { type ActionPayload } from "../../../dispatcher/payloads";
|
||
import { Action } from "../../../dispatcher/actions";
|
||
import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities";
|
||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||
import { ModuleRunner } from "../../../modules/ModuleRunner";
|
||
import { ModuleApi } from "../../../modules/Api";
|
||
import { toWidgetDescriptor } from "../../../modules/WidgetLifecycleApi";
|
||
import { parseUrl } from "../../../utils/UrlUtils";
|
||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
|
||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
|
||
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
|
||
|
||
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
|
||
// because that would allow the iframe to programmatically remove the sandbox attribute, but
|
||
// this would only be for content hosted on the same origin as the element client: anything
|
||
// hosted on the same origin as the client will get the same access as if you clicked
|
||
// a link to it.
|
||
const sandboxFlags =
|
||
"allow-forms allow-popups allow-popups-to-escape-sandbox " +
|
||
"allow-same-origin allow-scripts allow-presentation allow-downloads";
|
||
|
||
// Additional iframe feature permissions
|
||
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
|
||
const iframeFeatures =
|
||
"microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;";
|
||
|
||
interface IProps {
|
||
app: IWidget | 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;
|
||
threadId?: string | null;
|
||
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer container.
|
||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||
fullWidth?: boolean;
|
||
// Optional. If set, renders a smaller view of the widget
|
||
miniMode?: boolean;
|
||
// UserId of the current user
|
||
userId: string;
|
||
// UserId of the entity that added / modified the widget
|
||
creatorUserId: string;
|
||
waitForIframeLoad: boolean;
|
||
showMenubar?: boolean;
|
||
// Optional onEditClickHandler (overrides default behaviour)
|
||
onEditClick?: () => void;
|
||
// Optional onDeleteClickHandler (overrides default behaviour)
|
||
onDeleteClick?: () => void;
|
||
// Optionally hide the tile title
|
||
showTitle?: boolean;
|
||
// Optionally handle minimise button pointer events (default false)
|
||
handleMinimisePointerEvents?: boolean;
|
||
// Optionally hide the popout widget icon
|
||
showPopout?: boolean;
|
||
// Is this an instance of a user widget
|
||
userWidget: boolean;
|
||
// sets the pointer-events property on the iframe
|
||
pointerEvents?: CSSProperties["pointerEvents"];
|
||
widgetPageTitle?: string;
|
||
showLayoutButtons?: boolean;
|
||
// Handle to manually notify the PersistedElement that it needs to move
|
||
movePersistedElement?: RefObject<(() => void) | null>;
|
||
// An element to render after the iframe as an overlay
|
||
overlay?: ReactNode;
|
||
// If defined this async method will be called when the widget requests to become sticky.
|
||
// It will only become sticky once the returned promise resolves.
|
||
// This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately.
|
||
// This promise allows to do Widget B related cleanup before Widget A becomes sticky. (e.g. hangup a Voip call)
|
||
stickyPromise?: () => Promise<void>;
|
||
}
|
||
|
||
interface IState {
|
||
initialising: boolean; // True while we are mangling the widget URL
|
||
// True while the iframe content is loading
|
||
loading: boolean;
|
||
// Assume that widget has permission to load if we are the user who
|
||
// added it to the room, or if explicitly granted by the user
|
||
hasPermissionToLoad: boolean;
|
||
// Wait for user profile load to display correct name
|
||
isUserProfileReady: boolean;
|
||
error: Error | null;
|
||
menuDisplayed: boolean;
|
||
requiresClient: boolean;
|
||
}
|
||
|
||
export default class AppTile extends React.Component<IProps, IState> {
|
||
public static contextType = MatrixClientContext;
|
||
declare public context: ContextType<typeof MatrixClientContext>;
|
||
|
||
public static defaultProps: Partial<IProps> = {
|
||
waitForIframeLoad: true,
|
||
showMenubar: true,
|
||
showTitle: true,
|
||
showPopout: true,
|
||
handleMinimisePointerEvents: false,
|
||
userWidget: false,
|
||
miniMode: false,
|
||
threadId: null,
|
||
showLayoutButtons: true,
|
||
};
|
||
|
||
private readonly widget: ElementWidget;
|
||
private contextMenuButton = createRef<any>();
|
||
private iframeParent: HTMLElement | null = null; // parent div of the iframe
|
||
private allowedWidgetsWatchRef?: string;
|
||
private persistKey: string;
|
||
private messaging?: WidgetMessaging;
|
||
private dispatcherRef?: string;
|
||
private unmounted = false;
|
||
|
||
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
|
||
super(props, context);
|
||
|
||
// The key used for PersistedElement
|
||
this.persistKey = getPersistKey(WidgetUtils.getWidgetUid(this.props.app));
|
||
|
||
this.widget = new ElementWidget(props.app);
|
||
this.messaging = WidgetMessagingStore.instance.getMessaging(this.widget, props.room?.roomId);
|
||
if (this.messaging === undefined) {
|
||
try {
|
||
this.messaging = new WidgetMessaging(this.widget, props);
|
||
WidgetMessagingStore.instance.storeMessaging(this.widget, props.room?.roomId, this.messaging);
|
||
} catch (e) {
|
||
logger.error("Failed to construct widget", e);
|
||
}
|
||
}
|
||
|
||
this.state = this.getNewState(props);
|
||
}
|
||
|
||
private watchUserReady = (): void => {
|
||
if (OwnProfileStore.instance.isProfileInfoFetched) {
|
||
return;
|
||
}
|
||
OwnProfileStore.instance.once(UPDATE_EVENT, this.onUserReady);
|
||
};
|
||
|
||
private onUserReady = (): void => {
|
||
this.setState({ isUserProfileReady: true });
|
||
};
|
||
|
||
/**
|
||
* Synchronous permission check (fast path).
|
||
*
|
||
* Returns true when the widget can be loaded immediately, based on:
|
||
* - local (Jitsi) widgets,
|
||
* - account-level (user) widgets (no room),
|
||
* - the **legacy** module API ({@link WidgetLifecycle.PreLoadRequest}),
|
||
* - explicit user consent stored in the `allowedWidgets` setting,
|
||
* - the current user being the widget creator.
|
||
*/
|
||
private hasPermissionToLoadSync = (props: IProps): boolean => {
|
||
if (this.usingLocalWidget()) return true;
|
||
if (!props.room) return true; // user widgets always have permissions
|
||
|
||
// Legacy module API (synchronous)
|
||
const opts: ApprovalOpts = { approved: undefined };
|
||
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app));
|
||
if (opts.approved) return true;
|
||
|
||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
||
const allowed =
|
||
isAppWidget(props.app) &&
|
||
props.app.eventId !== undefined &&
|
||
(currentlyAllowedWidgets[props.app.eventId] ?? false);
|
||
return allowed || props.userId === props.creatorUserId;
|
||
};
|
||
|
||
/**
|
||
* Unified permission check that consults **both** the legacy (sync) and
|
||
* new (async) module APIs.
|
||
*
|
||
* 1. Runs the fast synchronous checks ({@link hasPermissionToLoadSync}).
|
||
* If any approve, resolves `true` immediately.
|
||
* 2. Falls back to the new module API
|
||
* ({@link ModuleApi.widgetLifecycle.preapprovePreload}) which may be
|
||
* async (config look-ups, network calls, etc.).
|
||
* 3. Returns `false` only when **neither** API grants permission.
|
||
*
|
||
* Every call site that needs to know "can this widget load?" should go
|
||
* through this method so the two APIs are never accidentally divergent.
|
||
*/
|
||
private async resolvePermissionToLoad(props: IProps): Promise<boolean> {
|
||
// Phase 1 – synchronous checks (legacy module API, settings, creator)
|
||
if (this.hasPermissionToLoadSync(props)) return true;
|
||
|
||
// Phase 2 – async new module API
|
||
if (!props.room) return false; // only room widgets go through the async path
|
||
try {
|
||
const descriptor = toWidgetDescriptor(this.widget, props.room.roomId);
|
||
const approved = await ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor);
|
||
return approved === true;
|
||
} catch (err) {
|
||
logger.error("Module API preload approval check failed", err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private onUserLeftRoom(): void {
|
||
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
if (isActiveWidget) {
|
||
// We just left the room that the active widget was from.
|
||
if (this.props.room && SdkContextClass.instance.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,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
private onMyMembership = (room: Room, membership: string): void => {
|
||
if (
|
||
(membership === KnownMembership.Leave || membership === KnownMembership.Ban) &&
|
||
room.roomId === this.props.room?.roomId
|
||
) {
|
||
this.onUserLeftRoom();
|
||
}
|
||
};
|
||
|
||
private determineInitialRequiresClientState(): boolean {
|
||
try {
|
||
const widget = new ElementWidget(this.props.app);
|
||
const messaging = WidgetMessagingStore.instance.getMessaging(widget, this.props.room?.roomId);
|
||
if (messaging?.widgetApi) {
|
||
// Load value from existing API to prevent resetting the requiresClient value on layout changes.
|
||
return messaging.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient);
|
||
}
|
||
} catch {
|
||
// fallback to true
|
||
}
|
||
|
||
// requiresClient is initially set to true. This avoids the broken state of the popout
|
||
// button being visible (for an instance) and then disappearing when the widget is loaded.
|
||
// requiresClient <-> hide the popout button
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Set initial component state when the App wUrl (widget URL) is being updated.
|
||
* Component props *must* be passed (rather than relying on this.props).
|
||
* @param {Object} newProps The new properties of the component
|
||
* @return {Object} Updated component state to be set with setState
|
||
*/
|
||
private getNewState(newProps: IProps): IState {
|
||
return {
|
||
initialising: true, // True while we are mangling the widget URL
|
||
// Don't show loading at all if the widget is ready once the IFrame is loaded (waitForIframeLoad = true).
|
||
// We only need the loading screen if the widget sends a contentLoaded event (waitForIframeLoad = false).
|
||
loading: !this.props.waitForIframeLoad && !PersistedElement.isMounted(this.persistKey),
|
||
// Use the sync check for the initial render (constructor can't await).
|
||
// componentDidMount will immediately follow up with the full async check.
|
||
hasPermissionToLoad: this.hasPermissionToLoadSync(newProps),
|
||
isUserProfileReady: OwnProfileStore.instance.isProfileInfoFetched,
|
||
error: null,
|
||
menuDisplayed: false,
|
||
requiresClient: this.determineInitialRequiresClientState(),
|
||
};
|
||
}
|
||
|
||
private onAllowedWidgetsChange = (): void => {
|
||
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
|
||
if (this.unmounted) return;
|
||
|
||
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
|
||
// Force the widget to be non-persistent (able to be deleted/forgotten)
|
||
ActiveWidgetStore.instance.destroyPersistentWidget(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
PersistedElement.destroyElement(this.persistKey);
|
||
this.messaging?.stop();
|
||
}
|
||
|
||
if (!this.state.hasPermissionToLoad && hasPermissionToLoad) {
|
||
this.startWidget();
|
||
}
|
||
|
||
this.setState({ hasPermissionToLoad });
|
||
});
|
||
};
|
||
|
||
private isMixedContent(): boolean {
|
||
const parentContentProtocol = window.location.protocol;
|
||
const u = parseUrl(this.props.app.url);
|
||
const childContentProtocol = u.protocol;
|
||
if (parentContentProtocol === "https:" && childContentProtocol !== "https:") {
|
||
logger.warn(
|
||
"Refusing to load mixed-content app:",
|
||
parentContentProtocol,
|
||
childContentProtocol,
|
||
window.location,
|
||
this.props.app.url,
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public componentDidMount(): void {
|
||
this.unmounted = false;
|
||
|
||
// Tiles in miniMode are floating, and therefore not docked
|
||
if (!this.props.miniMode) {
|
||
ActiveWidgetStore.instance.dockWidget(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
}
|
||
|
||
if (this.messaging) {
|
||
this.setupMessagingListeners();
|
||
}
|
||
|
||
// The constructor used the sync-only fast path for initial state.
|
||
// Now run the full check (sync + async) to catch any module API approvals.
|
||
if (this.state.hasPermissionToLoad && this.messaging) {
|
||
// Sync check already approved — start immediately, no need to re-check.
|
||
this.startWidget();
|
||
} else {
|
||
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
|
||
if (this.unmounted) return;
|
||
this.setState({ hasPermissionToLoad });
|
||
if (hasPermissionToLoad) {
|
||
this.startWidget();
|
||
}
|
||
});
|
||
}
|
||
this.watchUserReady();
|
||
|
||
if (this.props.room) {
|
||
this.context.on(RoomEvent.MyMembership, this.onMyMembership);
|
||
}
|
||
this.allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
|
||
// Widget action listeners
|
||
this.dispatcherRef = dis.register(this.onAction);
|
||
}
|
||
|
||
public componentWillUnmount(): void {
|
||
this.unmounted = true;
|
||
|
||
if (!this.props.miniMode) {
|
||
ActiveWidgetStore.instance.undockWidget(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
}
|
||
|
||
// Only tear down the widget if no other component is keeping it alive,
|
||
// because we support moving widgets between containers, in which case
|
||
// another component will keep it loaded throughout the transition
|
||
if (
|
||
!ActiveWidgetStore.instance.isLive(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
)
|
||
) {
|
||
this.endWidgetActions();
|
||
}
|
||
|
||
// Widget action listeners
|
||
dis.unregister(this.dispatcherRef);
|
||
|
||
if (this.props.room) {
|
||
this.context.off(RoomEvent.MyMembership, this.onMyMembership);
|
||
}
|
||
|
||
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
|
||
OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady);
|
||
}
|
||
|
||
private setupMessagingListeners(): void {
|
||
this.messaging?.on(WidgetMessagingEvent.Start, this.onMessagingStart);
|
||
this.messaging?.on(WidgetMessagingEvent.Stop, this.onMessagingStop);
|
||
}
|
||
|
||
private stopMessagingListeners(): void {
|
||
this.messaging?.off(WidgetMessagingEvent.Start, this.onMessagingStart);
|
||
this.messaging?.off(WidgetMessagingEvent.Stop, this.onMessagingStop);
|
||
}
|
||
|
||
private readonly onMessagingStart = (widgetApi: ClientWidgetApi): void => {
|
||
widgetApi.on("ready", this.onWidgetReady);
|
||
widgetApi.on("error:preparing", this.updateRequiresClient);
|
||
// emits when the capabilities have been set up or changed
|
||
widgetApi.on("capabilitiesNotified", this.updateRequiresClient);
|
||
};
|
||
|
||
private readonly onMessagingStop = (widgetApi: ClientWidgetApi): void => {
|
||
widgetApi.off("ready", this.onWidgetReady);
|
||
widgetApi.off("error:preparing", this.updateRequiresClient);
|
||
widgetApi.off("capabilitiesNotified", this.updateRequiresClient);
|
||
};
|
||
|
||
private resetWidget(newProps: IProps): void {
|
||
this.messaging?.stop();
|
||
this.stopMessagingListeners();
|
||
|
||
try {
|
||
WidgetMessagingStore.instance.stopMessaging(this.widget, this.props.room?.roomId);
|
||
this.messaging = new WidgetMessaging(this.widget, newProps);
|
||
WidgetMessagingStore.instance.storeMessaging(this.widget, this.props.room?.roomId, this.messaging);
|
||
this.setupMessagingListeners();
|
||
this.startWidget();
|
||
} catch (e) {
|
||
logger.error("Failed to construct widget", e);
|
||
this.messaging = undefined;
|
||
}
|
||
}
|
||
|
||
private startWidget(): void {
|
||
this.messaging?.prepare().then(() => {
|
||
if (this.unmounted) return;
|
||
this.setState({ initialising: false });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* A callback ref receiving the current parent div of the iframe. This is
|
||
* responsible for creating the iframe and starting or resetting
|
||
* communication with the widget.
|
||
*/
|
||
private iframeParentRef = (element: HTMLElement | null): void => {
|
||
if (this.unmounted) return;
|
||
// Detach the existing iframe (if any) from the document so we know not
|
||
// to do anything further with it, like starting up the messaging
|
||
this.iframeParent?.querySelector("iframe")?.remove();
|
||
this.iframeParent = element;
|
||
|
||
if (this.iframeParent === null) {
|
||
// The component is trying to unmount the iframe. We could reach
|
||
// this path if the widget definition was updated, for example. The
|
||
// iframe parent will later be remounted and widget communications
|
||
// reopened after this.state.initializing resets to false.
|
||
this.resetWidget(this.props);
|
||
} else if (
|
||
this.messaging &&
|
||
// Check whether an iframe already exists (it totally could exist,
|
||
// seeing as it is a persisted element which might have hopped
|
||
// between React components)
|
||
this.iframeParent.querySelector("iframe") === null
|
||
) {
|
||
// We create the iframe ourselves rather than leaving the job to React,
|
||
// because we need the lifetime of the messaging and the iframe to be
|
||
// the same; we don't want strict mode, for instance, to cause the
|
||
// messaging to restart (lose its state) without also killing the widget
|
||
const iframe = document.createElement("iframe");
|
||
iframe.title = WidgetUtils.getWidgetName(this.props.app);
|
||
iframe.allow = iframeFeatures;
|
||
iframe.src = this.messaging.embedUrl;
|
||
iframe.allowFullscreen = true;
|
||
iframe.sandbox = sandboxFlags;
|
||
this.iframeParent.appendChild(iframe);
|
||
// In order to start the widget messaging we need iframe.contentWindow
|
||
// to exist. Waiting until the next layout gives the browser a chance to
|
||
// initialize it.
|
||
requestAnimationFrame(() => {
|
||
// Handle the race condition (seen in strict mode) where the element is
|
||
// added and then removed from the DOM before we enter this callback
|
||
if (iframe.parentElement === null) return;
|
||
try {
|
||
this.messaging?.start(iframe);
|
||
} catch (e) {
|
||
logger.error("Failed to start widget", e);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
public componentDidUpdate(prevProps: IProps): void {
|
||
if (prevProps.app.url !== this.props.app.url) {
|
||
this.getNewState(this.props);
|
||
if (this.state.hasPermissionToLoad) {
|
||
this.resetWidget(this.props);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
||
*/
|
||
private endWidgetActions(): void {
|
||
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) {
|
||
LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId);
|
||
}
|
||
|
||
// Delete the widget from the persisted store for good measure.
|
||
// XXX: This removes persistent elements from the DOM entirely, which feels like
|
||
// a lot more than this function claims to be doing.
|
||
PersistedElement.destroyElement(this.persistKey);
|
||
ActiveWidgetStore.instance.destroyPersistentWidget(
|
||
this.props.app.id,
|
||
isAppWidget(this.props.app) ? this.props.app.roomId : null,
|
||
);
|
||
|
||
this.messaging?.stop({ forceDestroy: true });
|
||
}
|
||
|
||
private onWidgetReady = (): void => {
|
||
this.setState({ loading: false });
|
||
};
|
||
|
||
private updateRequiresClient = (): void => {
|
||
this.setState({
|
||
requiresClient: !!this.messaging?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient),
|
||
});
|
||
};
|
||
|
||
private onAction = (payload: ActionPayload): void => {
|
||
switch (payload.action) {
|
||
case "m.sticker":
|
||
if (
|
||
payload.widgetId === this.props.app.id &&
|
||
this.messaging?.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 Action.AfterLeaveRoom:
|
||
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;
|
||
}
|
||
};
|
||
|
||
private grantWidgetPermission = (): void => {
|
||
const roomId = this.props.room?.roomId;
|
||
const eventId = isAppWidget(this.props.app) ? this.props.app.eventId : undefined;
|
||
logger.info("Granting permission for widget to load: " + eventId);
|
||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||
if (eventId !== undefined) current[eventId] = true;
|
||
const level = SettingsStore.firstSupportedLevel("allowedWidgets")!;
|
||
SettingsStore.setValue("allowedWidgets", roomId ?? null, level, current)
|
||
.then(() => {
|
||
this.setState({ hasPermissionToLoad: true });
|
||
|
||
// Fetch a token for the integration manager, now that we're allowed to
|
||
this.startWidget();
|
||
})
|
||
.catch((err) => {
|
||
logger.error(err);
|
||
// We don't really need to do anything about this - the user will just hit the button again.
|
||
});
|
||
};
|
||
|
||
private formatAppTileName(): string {
|
||
let appTileName = "No name";
|
||
if (this.props.app.name && this.props.app.name.trim()) {
|
||
appTileName = this.props.app.name.trim();
|
||
}
|
||
return appTileName;
|
||
}
|
||
|
||
/**
|
||
* Whether we're using a local version of the widget rather than loading the
|
||
* actual widget URL
|
||
* @returns {bool} true If using a local version of the widget
|
||
*/
|
||
private usingLocalWidget(): boolean {
|
||
return WidgetType.JITSI.matches(this.props.app.type);
|
||
}
|
||
|
||
private getTileTitle(): JSX.Element {
|
||
const name = this.formatAppTileName();
|
||
const titleSpacer = <span> - </span>;
|
||
let title = "";
|
||
if (this.props.widgetPageTitle && this.props.widgetPageTitle !== this.formatAppTileName()) {
|
||
title = this.props.widgetPageTitle;
|
||
}
|
||
|
||
return (
|
||
<span>
|
||
<WidgetAvatar app={this.props.app} size="20px" />
|
||
<h1>{name}</h1>
|
||
<span>
|
||
{title ? titleSpacer : ""}
|
||
{title}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
private reload(): void {
|
||
this.endWidgetActions();
|
||
// reset messaging
|
||
this.resetWidget(this.props);
|
||
this.iframeParent?.querySelector("iframe")?.remove();
|
||
// iframeParent will now be re-mounted, at which point startMessaging will be called
|
||
}
|
||
|
||
// 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.
|
||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||
Object.assign(document.createElement("a"), {
|
||
target: "_blank",
|
||
href: this.messaging?.popoutUrl,
|
||
rel: "noreferrer noopener",
|
||
}).click();
|
||
};
|
||
|
||
private onToggleMaximisedClick = (): void => {
|
||
if (!this.props.room) return; // ignore action - it shouldn't even be visible
|
||
const targetContainer = WidgetLayoutStore.instance.isInContainer(
|
||
this.props.room,
|
||
this.props.app,
|
||
Container.Center,
|
||
)
|
||
? Container.Top
|
||
: Container.Center;
|
||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
|
||
|
||
if (targetContainer === Container.Top) this.closeChatCardIfNeeded();
|
||
};
|
||
|
||
private onMinimiseClicked = (): void => {
|
||
if (!this.props.room) return; // ignore action - it shouldn't even be visible
|
||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, Container.Right);
|
||
this.closeChatCardIfNeeded();
|
||
};
|
||
|
||
private closeChatCardIfNeeded = (): void => {
|
||
if (!this.props.room) return; // ignore action - it shouldn't even be visible
|
||
// If the right panel has a timeline, but we're about to show the timeline in the main view, pop the right panel
|
||
if (RightPanelStore.instance.currentCardForRoom(this.props.room.roomId).phase === RightPanelPhases.Timeline) {
|
||
RightPanelStore.instance.popCard(this.props.room.roomId);
|
||
}
|
||
};
|
||
|
||
private onContextMenuClick = (): void => {
|
||
this.setState({ menuDisplayed: true });
|
||
};
|
||
|
||
private closeContextMenu = (): void => {
|
||
this.setState({ menuDisplayed: false });
|
||
};
|
||
|
||
public render(): React.ReactNode {
|
||
let appTileBody: JSX.Element | undefined;
|
||
|
||
const appTileBodyClass = classNames({
|
||
"mx_AppTileBody": true,
|
||
"mx_AppTileBody--large": !this.props.miniMode,
|
||
"mx_AppTileBody--mini": this.props.miniMode,
|
||
"mx_AppTileBody--loading": this.state.loading,
|
||
// We don't want mx_AppTileBody (rounded corners) for call widgets
|
||
"mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred,
|
||
});
|
||
const appTileBodyStyles: CSSProperties = {};
|
||
if (this.props.pointerEvents) {
|
||
appTileBodyStyles.pointerEvents = this.props.pointerEvents;
|
||
}
|
||
|
||
const loadingElement = (
|
||
<div className="mx_AppTileBody_fadeInSpinner">
|
||
<Spinner message={_t("common|loading")} />
|
||
</div>
|
||
);
|
||
|
||
if (this.messaging === null) {
|
||
appTileBody = (
|
||
<div className={appTileBodyClass} style={appTileBodyStyles}>
|
||
<AppWarning errorMsg={_t("widget|error_loading")} />
|
||
</div>
|
||
);
|
||
} else if (!this.state.hasPermissionToLoad && this.props.room && this.messaging) {
|
||
// only possible for room widgets, can assert this.props.room here
|
||
const isEncrypted = this.context.isRoomEncrypted(this.props.room.roomId);
|
||
appTileBody = (
|
||
<div className={appTileBodyClass} style={appTileBodyStyles}>
|
||
<AppPermission
|
||
roomId={this.props.room.roomId}
|
||
creatorUserId={this.props.creatorUserId}
|
||
url={this.messaging.embedUrl}
|
||
isRoomEncrypted={isEncrypted}
|
||
onPermissionGranted={this.grantWidgetPermission}
|
||
/>
|
||
</div>
|
||
);
|
||
} else if (this.state.initialising || !this.state.isUserProfileReady) {
|
||
appTileBody = (
|
||
<div className={appTileBodyClass} style={appTileBodyStyles}>
|
||
{loadingElement}
|
||
</div>
|
||
);
|
||
} else {
|
||
if (this.isMixedContent()) {
|
||
appTileBody = (
|
||
<div className={appTileBodyClass} style={appTileBodyStyles}>
|
||
<AppWarning errorMsg={_t("widget|error_mixed_content")} />
|
||
</div>
|
||
);
|
||
} else if (this.messaging) {
|
||
appTileBody = (
|
||
<>
|
||
<div className={appTileBodyClass} style={appTileBodyStyles} ref={this.iframeParentRef}>
|
||
{this.state.loading && loadingElement}
|
||
</div>
|
||
{this.props.overlay}
|
||
</>
|
||
);
|
||
|
||
if (!this.props.userWidget) {
|
||
// All room widgets can theoretically be allowed to remain on screen, so we
|
||
// wrap them all in a PersistedElement from the get-go. If we wait, the iframe
|
||
// will be re-mounted later, which means the widget has to start over, which is
|
||
// bad.
|
||
|
||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||
// AppTile's border is in the wrong place
|
||
|
||
// For persisted apps in PiP we want the zIndex to be higher then for other persisted 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">
|
||
<PersistedElement
|
||
zIndex={this.props.miniMode ? zIndexAboveOtherPersistentElements : 9}
|
||
persistKey={this.persistKey}
|
||
moveRef={this.props.movePersistedElement}
|
||
>
|
||
{appTileBody}
|
||
</PersistedElement>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let appTileClasses;
|
||
if (this.props.miniMode) {
|
||
appTileClasses = { mx_AppTile_mini: true };
|
||
} else if (this.props.fullWidth) {
|
||
appTileClasses = { mx_AppTileFullWidth: true };
|
||
} else {
|
||
appTileClasses = { mx_AppTile: true };
|
||
}
|
||
appTileClasses = classNames(appTileClasses);
|
||
|
||
const layoutButtons: ReactNode[] = [];
|
||
if (this.props.showLayoutButtons) {
|
||
const isMaximised =
|
||
this.props.room &&
|
||
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center);
|
||
|
||
layoutButtons.push(
|
||
<AccessibleButton
|
||
key="toggleMaximised"
|
||
className="mx_AppTileMenuBar_widgets_button"
|
||
title={isMaximised ? _t("widget|unmaximise") : _t("action|maximise")}
|
||
onClick={this.onToggleMaximisedClick}
|
||
>
|
||
{isMaximised ? (
|
||
<CollapseIcon className="mx_Icon mx_Icon_12" />
|
||
) : (
|
||
<ExpandIcon className="mx_Icon mx_Icon_12" />
|
||
)}
|
||
</AccessibleButton>,
|
||
);
|
||
|
||
layoutButtons.push(
|
||
<AccessibleButton
|
||
key="minimise"
|
||
className="mx_AppTileMenuBar_widgets_button"
|
||
title={_t("action|minimise")}
|
||
onClick={this.onMinimiseClicked}
|
||
>
|
||
<MinusIcon className="mx_Icon mx_Icon_16" />
|
||
</AccessibleButton>,
|
||
);
|
||
}
|
||
|
||
return (
|
||
<React.Fragment>
|
||
<div className={appTileClasses} id={this.props.app.id}>
|
||
{this.props.showMenubar && (
|
||
<div className="mx_AppTileMenuBar">
|
||
<span
|
||
className="mx_AppTileMenuBar_title"
|
||
style={{ pointerEvents: this.props.handleMinimisePointerEvents ? "all" : "none" }}
|
||
>
|
||
{this.props.showTitle && this.getTileTitle()}
|
||
</span>
|
||
<span className="mx_AppTileMenuBar_widgets">
|
||
{layoutButtons}
|
||
{this.props.showPopout && !this.state.requiresClient && (
|
||
<AccessibleButton
|
||
className="mx_AppTileMenuBar_widgets_button"
|
||
title={_t("widget|popout")}
|
||
onClick={this.onPopoutWidgetClick}
|
||
>
|
||
<PopOutIcon className="mx_Icon mx_Icon_12" />
|
||
</AccessibleButton>
|
||
)}
|
||
<I18nContext.Provider value={window.mxModuleApi.i18n}>
|
||
<WidgetContextMenu
|
||
trigger={
|
||
<ContextMenuButton
|
||
className="mx_AppTileMenuBar_widgets_button"
|
||
label={_t("common|options")}
|
||
isExpanded={this.state.menuDisplayed}
|
||
ref={this.contextMenuButton}
|
||
onClick={this.onContextMenuClick}
|
||
>
|
||
<OverflowHorizontalIcon className="mx_Icon mx_Icon_12" />
|
||
</ContextMenuButton>
|
||
}
|
||
app={this.props.app}
|
||
onFinished={this.closeContextMenu}
|
||
showUnpin={!this.props.userWidget}
|
||
userWidget={this.props.userWidget}
|
||
onEditClick={this.props.onEditClick}
|
||
onDeleteClick={this.props.onDeleteClick}
|
||
menuDisplayed={this.state.menuDisplayed}
|
||
/>
|
||
</I18nContext.Provider>
|
||
</span>
|
||
</div>
|
||
)}
|
||
{appTileBody}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
}
|
||
}
|