Files
ThreadNet-Web/apps/web/src/components/views/elements/AppTile.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

903 lines
39 KiB
TypeScript
Raw Normal View History

/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
2024-09-09 14:57:16 +01:00
Copyright 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
2017-05-22 12:34:27 +01:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2017-05-22 12:34:27 +01:00
*/
import React, {
type JSX,
type ContextType,
createRef,
type CSSProperties,
type RefObject,
type ReactNode,
} from "react";
2021-12-09 09:10:23 +00:00
import classNames from "classnames";
import { type IWidget, MatrixCapabilities, type ClientWidgetApi } from "matrix-widget-api";
2025-02-05 13:25:06 +00:00
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
2021-12-09 09:10:23 +00:00
import { logger } from "matrix-js-sdk/src/logger";
2025-02-05 13:25:06 +00:00
import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
2024-10-21 11:36:31 +01:00
import {
OverflowHorizontalIcon,
MinusIcon,
ExpandIcon,
CollapseIcon,
2025-12-02 15:58:46 +00:00
PopOutIcon,
2024-10-21 11:36:31 +01:00
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { I18nContext } from "@element-hq/web-shared-components";
2021-12-09 09:10:23 +00:00
2019-02-01 11:02:02 +01:00
import AccessibleButton from "./AccessibleButton";
import { _t } from "../../../languageHandler";
2017-07-26 11:28:43 +01:00
import AppPermission from "./AppPermission";
2017-08-01 17:29:29 +01:00
import AppWarning from "./AppWarning";
2020-04-24 22:14:41 +01:00
import Spinner from "./Spinner";
2020-05-13 20:41:41 -06:00
import dis from "../../../dispatcher/dispatcher";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenuButton } from "../../structures/ContextMenu";
2021-06-29 13:11:58 +01:00
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging";
2020-10-12 13:16:55 +01:00
import WidgetAvatar from "../avatars/WidgetAvatar";
import LegacyCallHandler from "../../../LegacyCallHandler";
2025-02-05 13:25:06 +00:00
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
2022-01-24 15:07:54 +01:00
import WidgetUtils from "../../../utils/WidgetUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
2025-02-05 13:25:06 +00:00
import { type ActionPayload } from "../../../dispatcher/payloads";
2022-02-22 11:04:27 +01:00
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";
2021-12-09 09:10:23 +00:00
// 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;";
2021-09-16 20:05:57 +02:00
interface IProps {
app: IWidget | IApp;
2021-09-16 20:05:57 +02:00
// 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.
2021-09-16 20:05:57 +02:00
// 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
2023-02-13 11:39:16 +00:00
pointerEvents?: CSSProperties["pointerEvents"];
2021-09-16 20:05:57 +02:00
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>;
2021-09-16 20:05:57 +02:00
}
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;
2021-09-16 20:05:57 +02:00
menuDisplayed: boolean;
requiresClient: boolean;
2021-09-16 20:05:57 +02:00
}
2021-09-16 20:05:57 +02:00
export default class AppTile extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
2024-12-02 09:39:36 +00:00
declare public context: ContextType<typeof MatrixClientContext>;
2021-09-16 20:05:57 +02:00
public static defaultProps: Partial<IProps> = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showPopout: true,
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
threadId: null,
showLayoutButtons: true,
2021-09-16 20:05:57 +02:00
};
private readonly widget: ElementWidget;
2021-09-16 20:05:57 +02:00
private contextMenuButton = createRef<any>();
private iframeParent: HTMLElement | null = null; // parent div of the iframe
private allowedWidgetsWatchRef?: string;
2021-09-16 20:05:57 +02:00
private persistKey: string;
private messaging?: WidgetMessaging;
private dispatcherRef?: string;
private unmounted = false;
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
2024-08-01 13:01:05 +01:00
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);
}
}
2021-09-16 20:05:57 +02:00
this.state = this.getNewState(props);
2018-01-11 12:33:02 +00:00
}
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 => {
2021-09-16 20:05:57 +02:00
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;
2020-10-09 08:42:21 +01:00
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
const allowed =
isAppWidget(props.app) &&
props.app.eventId !== undefined &&
(currentlyAllowedWidgets[props.app.eventId] ?? false);
2022-09-16 11:12:27 -04:00
return allowed || props.userId === props.creatorUserId;
2020-10-09 08:42:21 +01:00
};
/**
* 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 => {
2024-03-11 17:16:53 +00:00
if (
(membership === KnownMembership.Leave || membership === KnownMembership.Ban) &&
2024-03-11 17:16:53 +00:00
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;
}
/**
2017-11-10 11:50:14 +00:00
* 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
*/
2021-09-16 20:05:57 +02:00
private getNewState(newProps: IProps): IState {
2017-11-02 18:33:11 +00:00
return {
2017-12-28 22:27:12 +00:00
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,
2017-11-02 18:33:11 +00:00
error: null,
2019-11-28 18:16:59 +00:00
menuDisplayed: false,
requiresClient: this.determineInitialRequiresClientState(),
2017-11-02 18:33:11 +00:00
};
2018-01-11 11:49:46 +00:00
}
2021-09-16 20:05:57 +02:00
private onAllowedWidgetsChange = (): void => {
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
if (this.unmounted) return;
2020-10-09 08:42:21 +01:00
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();
}
2020-10-09 08:42:21 +01:00
if (!this.state.hasPermissionToLoad && hasPermissionToLoad) {
this.startWidget();
}
this.setState({ hasPermissionToLoad });
});
2020-10-09 08:42:21 +01:00
};
2021-09-16 20:05:57 +02:00
private isMixedContent(): boolean {
2017-08-01 17:29:29 +01:00
const parentContentProtocol = window.location.protocol;
const u = parseUrl(this.props.app.url);
2017-08-01 17:29:29 +01:00
const childContentProtocol = u.protocol;
2017-08-01 17:49:41 +01:00
if (parentContentProtocol === "https:" && childContentProtocol !== "https:") {
2021-10-15 16:31:29 +02:00
logger.warn(
"Refusing to load mixed-content app:",
2021-04-27 17:23:27 +02:00
parentContentProtocol,
childContentProtocol,
window.location,
this.props.app.url,
);
2017-08-01 17:29:29 +01:00
return true;
}
return false;
2018-01-11 11:49:46 +00:00
}
2017-08-01 17:29:29 +01:00
2021-09-16 20:05:57 +02:00
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.
2021-09-16 20:05:57 +02:00
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);
2018-01-18 12:02:45 +00:00
// Widget action listeners
2021-09-16 20:05:57 +02:00
this.dispatcherRef = dis.register(this.onAction);
2018-01-11 11:49:46 +00:00
}
2017-12-15 16:56:02 +00:00
2021-09-16 20:05:57 +02:00
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();
}
2018-01-18 12:02:45 +00:00
// Widget action listeners
dis.unregister(this.dispatcherRef);
2018-01-18 12:02:45 +00:00
if (this.props.room) {
this.context.off(RoomEvent.MyMembership, this.onMyMembership);
}
SettingsStore.unwatchSetting(this.allowedWidgetsWatchRef);
OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady);
2018-01-18 12:02:45 +00:00
}
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);
};
2021-09-16 20:05:57 +02:00
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();
2021-09-16 20:05:57 +02:00
this.startWidget();
} catch (e) {
logger.error("Failed to construct widget", e);
this.messaging = undefined;
}
}
2017-11-02 18:33:11 +00:00
2021-09-16 20:05:57 +02:00
private startWidget(): void {
this.messaging?.prepare().then(() => {
if (this.unmounted) return;
2021-06-29 13:11:58 +01:00
this.setState({ initialising: false });
});
2018-01-11 11:49:46 +00:00
}
/**
* 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.
2021-09-16 20:05:57 +02:00
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);
}
});
2020-09-29 14:33:46 -06:00
}
};
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);
}
2020-04-09 14:31:46 -06:00
}
2018-01-11 11:49:46 +00:00
}
2020-04-09 14:31:46 -06:00
/**
* 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.
2025-10-02 13:46:55 +01:00
// XXX: This removes persistent elements from the DOM entirely, which feels like
// a lot more than this function claims to be doing.
2021-09-16 20:05:57 +02:00
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 });
2020-04-09 14:31:46 -06:00
}
private onWidgetReady = (): void => {
2021-06-29 13:11:58 +01:00
this.setState({ loading: false });
};
2018-03-28 12:22:06 +01:00
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;
2022-02-22 11:04:27 +01:00
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;
2018-01-04 18:58:55 +00:00
}
2020-10-09 08:42:21 +01:00
};
2018-01-04 18:41:47 +00:00
2021-09-16 20:05:57 +02:00
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(() => {
2021-06-29 13:11:58 +01:00
this.setState({ hasPermissionToLoad: true });
// Fetch a token for the integration manager, now that we're allowed to
2021-09-16 20:05:57 +02:00
this.startWidget();
})
.catch((err) => {
2021-10-15 16:30:53 +02:00
logger.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
2020-10-09 08:42:21 +01:00
};
2017-05-22 18:00:17 +01:00
2021-09-16 20:05:57 +02:00
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;
2018-01-11 11:49:46 +00:00
}
/**
* Whether we're using a local version of the widget rather than loading the
* actual widget URL
2020-09-09 15:42:25 +01:00
* @returns {bool} true If using a local version of the widget
*/
2021-09-16 20:05:57 +02:00
private usingLocalWidget(): boolean {
return WidgetType.JITSI.matches(this.props.app.type);
}
2021-09-16 20:05:57 +02:00
private getTileTitle(): JSX.Element {
2018-02-07 14:44:01 +00:00
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = "";
if (this.props.widgetPageTitle && this.props.widgetPageTitle !== this.formatAppTileName()) {
title = this.props.widgetPageTitle;
2018-02-07 14:44:01 +00:00
}
return (
<span>
<WidgetAvatar app={this.props.app} size="20px" />
<h1>{name}</h1>
2018-02-07 14:44:01 +00:00
<span>
{title ? titleSpacer : ""}
{title}
</span>
</span>
);
}
private reload(): void {
this.endWidgetActions();
// reset messaging
this.resetWidget(this.props);
this.iframeParent?.querySelector("iframe")?.remove();
2025-10-02 13:46:55 +01:00
// iframeParent will now be re-mounted, at which point startMessaging will be called
}
2020-10-09 08:42:21 +01:00
// TODO replace with full screen interactions
2021-09-16 20:05:57 +02:00
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).
2020-10-09 08:42:21 +01:00
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"), {
2021-09-16 20:05:57 +02:00
target: "_blank",
href: this.messaging?.popoutUrl,
2021-09-16 20:05:57 +02:00
rel: "noreferrer noopener",
}).click();
2020-10-09 08:42:21 +01:00
};
2018-05-22 19:14:54 +01:00
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, "center")
? "top"
: "center";
2021-11-16 15:43:18 +01:00
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
if (targetContainer === "top") this.closeChatCardIfNeeded();
2021-11-16 15:43:18 +01:00
};
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, "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);
}
};
2021-09-16 20:05:57 +02:00
private onContextMenuClick = (): void => {
2019-11-28 18:16:59 +00:00
this.setState({ menuDisplayed: true });
};
2019-11-20 19:17:42 -07:00
2021-09-16 20:05:57 +02:00
private closeContextMenu = (): void => {
2019-11-28 18:16:59 +00:00
this.setState({ menuDisplayed: false });
2019-11-20 19:17:42 -07:00
};
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,
});
2023-02-13 11:39:16 +00:00
const appTileBodyStyles: CSSProperties = {};
2021-03-05 17:52:51 +00:00
if (this.props.pointerEvents) {
2023-02-13 11:39:16 +00:00
appTileBodyStyles.pointerEvents = this.props.pointerEvents;
2021-03-05 17:52:51 +00:00
}
2018-07-12 18:43:49 +01:00
2020-10-09 08:42:21 +01:00
const loadingElement = (
<div className="mx_AppTileBody_fadeInSpinner">
<Spinner message={_t("common|loading")} />
2020-10-09 08:42:21 +01:00
</div>
);
2022-01-24 15:07:54 +01:00
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);
2020-10-09 08:42:21 +01:00
appTileBody = (
2021-03-05 17:52:51 +00:00
<div className={appTileBodyClass} style={appTileBodyStyles}>
2020-10-09 08:42:21 +01:00
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this.messaging.embedUrl}
2020-10-09 08:42:21 +01:00
isRoomEncrypted={isEncrypted}
2021-09-16 20:05:57 +02:00
onPermissionGranted={this.grantWidgetPermission}
2020-10-09 08:42:21 +01:00
/>
2017-11-08 20:38:31 +00:00
</div>
);
} else if (this.state.initialising || !this.state.isUserProfileReady) {
2020-10-09 08:42:21 +01:00
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
2020-10-09 08:42:21 +01:00
{loadingElement}
</div>
);
} else {
if (this.isMixedContent()) {
appTileBody = (
2021-03-05 17:52:51 +00:00
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("widget|error_mixed_content")} />
</div>
);
} else if (this.messaging) {
2020-10-09 08:42:21 +01:00
appTileBody = (
<>
<div className={appTileBodyClass} style={appTileBodyStyles} ref={this.iframeParentRef}>
{this.state.loading && loadingElement}
</div>
{this.props.overlay}
</>
2020-10-09 08:42:21 +01:00
);
2020-11-26 15:09:08 -07:00
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.
2020-11-26 15:09:08 -07:00
// 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;
2020-11-26 15:09:08 -07:00
appTileBody = (
<div className="mx_AppTile_persistedWrapper">
<PersistedElement
zIndex={this.props.miniMode ? zIndexAboveOtherPersistentElements : 9}
persistKey={this.persistKey}
moveRef={this.props.movePersistedElement}
>
{appTileBody}
2020-11-26 15:09:08 -07:00
</PersistedElement>
</div>
);
}
2017-07-12 14:16:47 +01:00
}
}
2017-07-14 11:17:59 +01:00
let appTileClasses;
2018-07-23 15:08:17 +01:00
if (this.props.miniMode) {
2021-06-29 13:11:58 +01:00
appTileClasses = { mx_AppTile_mini: true };
2018-07-23 15:08:17 +01:00
} else if (this.props.fullWidth) {
2021-06-29 13:11:58 +01:00
appTileClasses = { mx_AppTileFullWidth: true };
2018-07-23 15:08:17 +01:00
} else {
2021-06-29 13:11:58 +01:00
appTileClasses = { mx_AppTile: true };
2018-07-23 15:08:17 +01:00
}
appTileClasses = classNames(appTileClasses);
2018-07-23 15:08:17 +01:00
2022-09-12 11:58:05 +01:00
const layoutButtons: ReactNode[] = [];
if (this.props.showLayoutButtons) {
const isMaximised =
this.props.room && WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "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" />
) : (
2024-10-21 11:36:31 +01:00
<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}
>
2024-10-21 11:36:31 +01:00
<MinusIcon className="mx_Icon mx_Icon_16" />
</AccessibleButton>,
);
2021-11-16 15:43:18 +01:00
}
2019-11-28 18:16:59 +00:00
return (
<React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
2018-01-11 13:20:58 +00:00
{this.props.showMenubar && (
2021-08-19 06:42:10 +02:00
<div className="mx_AppTileMenuBar">
2021-09-16 20:05:57 +02:00
<span
className="mx_AppTileMenuBar_title"
2021-09-16 20:05:57 +02:00
style={{ pointerEvents: this.props.handleMinimisePointerEvents ? "all" : "none" }}
>
{this.props.showTitle && this.getTileTitle()}
2021-08-19 06:42:10 +02:00
</span>
<span className="mx_AppTileMenuBar_widgets">
{layoutButtons}
{this.props.showPopout && !this.state.requiresClient && (
<AccessibleButton
className="mx_AppTileMenuBar_widgets_button"
title={_t("widget|popout")}
2021-09-16 20:05:57 +02:00
onClick={this.onPopoutWidgetClick}
>
2025-12-02 15:58:46 +00:00
<PopOutIcon className="mx_Icon mx_Icon_12" />
</AccessibleButton>
2021-08-19 06:42:10 +02:00
)}
<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>
2021-08-19 06:42:10 +02:00
</span>
</div>
)}
{appTileBody}
2017-05-22 12:34:27 +01:00
</div>
2019-11-28 18:16:59 +00:00
</React.Fragment>
);
2018-01-11 11:49:46 +00:00
}
}