/* Copyright 2018-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { MutableRefObject, ReactNode, StrictMode } from "react"; import { createRoot, Root } from "react-dom/client"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { TooltipProvider } from "@vector-im/compound-web"; import dis from "../../../dispatcher/dispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { ActionPayload } from "../../../dispatcher/payloads"; export const getPersistKey = (appId: string): string => "widget_" + appId; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and // pass in a custom control as the actual body. // We contain all persisted elements within a master container to allow them all to be within the same // CSS stacking context, and thus be able to control their z-indexes relative to each other. function getOrCreateMasterContainer(): HTMLDivElement { let container = document.getElementById("mx_PersistedElement_container") as HTMLDivElement; if (!container) { container = document.createElement("div"); container.id = "mx_PersistedElement_container"; document.body.appendChild(container); } return container; } function getOrCreateContainer(containerId: string): HTMLDivElement { const container = document.createElement("div"); container.id = containerId; getOrCreateMasterContainer().appendChild(container); return container; } interface IProps { // Unique identifier for this PersistedElement instance // Any PersistedElements with the same persistKey will use // the same DOM container. persistKey: string; // z-index for the element. Defaults to 9. zIndex?: number; style?: React.StyleHTMLAttributes; // Handle to manually notify this PersistedElement that it needs to move moveRef?: MutableRefObject<(() => void) | undefined>; children: ReactNode; } /** * Class of component that renders its children in a separate ReactDOM virtual tree * in a container element appended to document.body. * * This prevents the children from being unmounted when the parent of PersistedElement * unmounts, allowing them to persist. * * When PE is unmounted, it hides the children using CSS. When mounted or updated, the * children are made visible and are positioned into a div that is given the same * bounding rect as the parent of PE. */ export default class PersistedElement extends React.Component { private resizeObserver: ResizeObserver; private dispatcherRef?: string; private childContainer?: HTMLDivElement; private child?: HTMLDivElement; private static rootMap: Record = {}; public constructor(props: IProps) { super(props); this.resizeObserver = new ResizeObserver(this.repositionChild); if (this.props.moveRef) this.props.moveRef.current = this.repositionChild; } /** * Removes the DOM elements created when a PersistedElement with the given * persistKey was mounted. The DOM elements will be re-added if another * PersistedElement is mounted in the future. * * @param {string} persistKey Key used to uniquely identify this PersistedElement */ public static destroyElement(persistKey: string): void { const pair = PersistedElement.rootMap[persistKey]; if (pair) { pair[0].unmount(); pair[1].remove(); } delete PersistedElement.rootMap[persistKey]; } public static isMounted(persistKey: string): boolean { return Boolean(PersistedElement.rootMap[persistKey]); } private collectChildContainer = (ref: HTMLDivElement): void => { if (this.childContainer) { this.resizeObserver.unobserve(this.childContainer); } this.childContainer = ref; if (ref) { this.resizeObserver.observe(ref); } }; private collectChild = (ref: HTMLDivElement): void => { this.child = ref; this.updateChild(); }; public componentDidMount(): void { // Annoyingly, a resize observer is insufficient, since we also care // about when the element moves on the screen without changing its // dimensions. Doesn't look like there's a ResizeObserver equivalent // for this, so we bodge it by listening for document resize and // the timeline_resize action. window.addEventListener("resize", this.repositionChild); this.dispatcherRef = dis.register(this.onAction); this.updateChild(); this.renderApp(); } public componentDidUpdate(): void { this.updateChild(); this.renderApp(); } public componentWillUnmount(): void { this.updateChildVisibility(this.child, false); this.resizeObserver.disconnect(); window.removeEventListener("resize", this.repositionChild); dis.unregister(this.dispatcherRef); } private onAction = (payload: ActionPayload): void => { if (payload.action === "timeline_resize") { this.repositionChild(); } else if (payload.action === "logout") { PersistedElement.destroyElement(this.props.persistKey); } }; private repositionChild = (): void => { this.updateChildPosition(this.child, this.childContainer); }; private updateChild(): void { this.updateChildPosition(this.child, this.childContainer); this.updateChildVisibility(this.child, true); } private renderApp(): void { const content = (
{this.props.children}
); let rootPair = PersistedElement.rootMap[this.props.persistKey]; if (!rootPair) { const container = getOrCreateContainer("mx_persistedElement_" + this.props.persistKey); const root = createRoot(container); rootPair = [root, container]; PersistedElement.rootMap[this.props.persistKey] = rootPair; } rootPair[0].render(content); } private updateChildVisibility(child?: HTMLDivElement, visible = false): void { if (!child) return; child.style.display = visible ? "block" : "none"; } private updateChildPosition(child?: HTMLDivElement, parent?: HTMLDivElement): void { if (!child || !parent) return; const parentRect = parent.getBoundingClientRect(); Object.assign(child.style, { zIndex: isNullOrUndefined(this.props.zIndex) ? 9 : this.props.zIndex, position: "absolute", top: "0", left: "0", transform: `translateX(${parentRect.left}px) translateY(${parentRect.top}px)`, width: parentRect.width + "px", height: parentRect.height + "px", }); } public render(): React.ReactNode { return
; } }