diff --git a/src/App.tsx b/src/App.tsx index 36afc4c2f..55703f4c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; +import { useRootElement } from "./RootElementContext"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -44,12 +45,11 @@ interface SimpleProviderProps { const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); + const rootElement = useRootElement(); useEffect(() => { - document - .getElementsByTagName("body")[0] - .setAttribute("data-background", background); - }, [pathname, background]); + rootElement.setAttribute("data-background", background); + }, [pathname, background, rootElement]); return children; }; diff --git a/src/Modal.tsx b/src/Modal.tsx index e6ffdf450..46316d370 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web"; import styles from "./Modal.module.css"; import overlayStyles from "./Overlay.module.css"; import { useMediaQuery } from "./useMediaQuery"; +import { useRootElement } from "./RootElementContext"; export interface Props { title: string; @@ -78,6 +79,7 @@ export const Modal: FC = ({ ...rest }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); // Empirically, Chrome on Android can end up not matching (hover: none), but // still matching (pointer: coarse) :/ const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)"); @@ -100,7 +102,7 @@ export const Modal: FC = ({ onOpenChange={onOpenChange} dismissible={onDismiss !== undefined} > - + = ({ return ( - + diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts new file mode 100644 index 000000000..b97b17796 --- /dev/null +++ b/src/RootElementContext.ts @@ -0,0 +1,30 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; + +/** + * The element that Element Call treats as the root of its own interface. + * + * Element Call decorates this element with the theme, layout and background + * attributes its stylesheets key off, and portals its modals into it. When + * Element Call owns the page this is simply the document body; when it is + * embedded in a host application it is the container the host mounted it into, + * so that Element Call does not reach outside its own subtree. + */ +const RootElementContext = createContext(null); + +export const RootElementProvider = RootElementContext.Provider; + +/** + * The element Element Call should decorate and portal into. + * + * Defaults to the document body, so that the standalone and widget builds work + * without a provider. + */ +export const useRootElement = (): HTMLElement => + use(RootElementContext) ?? document.body; diff --git a/src/Toast.tsx b/src/Toast.tsx index 83e220bc1..0a2a2f1b8 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web"; import styles from "./Toast.module.css"; import overlayStyles from "./Overlay.module.css"; +import { useRootElement } from "./RootElementContext"; interface Props { /** @@ -64,6 +65,7 @@ export const Toast: FC = ({ Icon, modal = true, }) => { + const rootElement = useRootElement(); const onOpenChange = useCallback( (open: boolean) => { if (!open) onDismiss(); @@ -104,7 +106,11 @@ export const Toast: FC = ({ return ( - {modal ? {content} : content} + {modal ? ( + {content} + ) : ( + content + )} ); }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index fbd589e78..dfeb0866a 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -80,6 +80,7 @@ import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { useRootElement } from "../RootElementContext.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -123,6 +124,7 @@ export const GroupCallView: FC = ({ null, ); const memberships = useMatrixRTCSessionMemberships(rtcSession); + const rootElement = useRootElement(); const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -150,11 +152,11 @@ export const GroupCallView: FC = ({ // viewport sizes smaller than 122px width. (It is actually this exact number: 122px // tested on different devices...) useEffect(() => { - document.body.classList.add("no-scroll-body"); + rootElement.classList.add("no-scroll-body"); return (): void => { - document.body.classList.remove("no-scroll-body"); + rootElement.classList.remove("no-scroll-body"); }; - }, []); + }, [rootElement]); useEffect(() => { window.rtcSession = rtcSession; diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 036e044fe..97e1f4a62 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -54,6 +54,7 @@ import { Slider } from "../Slider"; import { platform } from "../Platform"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { RingingStatus } from "./RingingStatus"; +import { useRootElement } from "../RootElementContext"; interface SpotlightItemBaseProps { ref?: Ref; @@ -414,6 +415,7 @@ export const SpotlightTile: FC = ({ style, }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); @@ -428,24 +430,22 @@ export const SpotlightTile: FC = ({ const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const isFullscreen = useCallback((): boolean => { - const rootElement = document.body; if (rootElement && document.fullscreenElement) return true; return false; - }, []); + }, [rootElement]); const FullScreenIcon = isFullscreen() ? FullScreenMinimiseIcon : FullScreenMaximiseIcon; const onToggleFullscreen = useCallback(() => { - const rootElement = document.body; if (!rootElement) return; if (isFullscreen()) { void document?.exitFullscreen(); } else { void rootElement.requestFullscreen(); } - }, [isFullscreen]); + }, [isFullscreen, rootElement]); // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run diff --git a/src/useTheme.ts b/src/useTheme.ts index e992aee7b..5bac28982 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -11,12 +11,14 @@ import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { getUrlParams } from "./UrlParams"; import { widget } from "./widget"; +import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { + const rootElement = useRootElement(); const [requestedTheme, setRequestedTheme] = useState( () => getUrlParams().theme, ); - const previousTheme = useRef(document.body.classList.item(0)); + const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { if (widget) { @@ -47,15 +49,15 @@ export const useTheme = (): void => { : ""; const themeString = "cpd-theme-" + theme + themeHighContrast; if (themeString !== previousTheme.current) { - document.body.classList.remove( + rootElement.classList.remove( "cpd-theme-light", "cpd-theme-dark", "cpd-theme-light-hc", "cpd-theme-dark-hc", ); - document.body.classList.add(themeString); + rootElement.classList.add(themeString); previousTheme.current = themeString; } - document.body.classList.remove("no-theme"); - }, [previousTheme, requestedTheme]); + rootElement.classList.remove("no-theme"); + }, [previousTheme, requestedTheme, rootElement]); };