From f28d9e9b06693276b7fe1fc0b5bffe53e5afb4f6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:04:24 +0200 Subject: [PATCH] Stop asking whether Element Call is a widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining reads of the widget global were all asking one of two different questions, so they get two different answers. The app shell — the auth hooks, automatic guest registration, the group call loader's diagnostic and the initial mute state — wants to know whether Element Call was launched as a widget. That is a property of the URL it was launched with, so expose the isWidget that computeUrlParams already computed internally, documented as being for shell use only. The call interface — whether to offer the profile settings tab — wants to know something about its host, so it asks the bridge. A host that can dismiss Element Call owns the user's account, so their profile is not ours to edit; this reuses the close capability as a proxy, with a TODO alongside the others. ClientContext also takes supportsReactions from the bridge rather than checking four widget capabilities itself, which removes widgetApi from InitResult — a field that was always null outside widget mode. Note this changes behaviour for a malformed widget URL: one carrying a widget ID and parent URL but missing the room, user, device or base URL would previously have fallen back to registering a guest user, and will now not. --- src/ClientContext.tsx | 34 +++++-------------------- src/UrlParams.ts | 12 +++++++++ src/auth/useInteractiveRegistration.ts | 7 ++--- src/auth/useRegisterPasswordlessUser.ts | 7 ++--- src/room/InCallView.tsx | 11 +++++--- src/room/RoomPage.tsx | 6 ++--- src/room/useLoadGroupCall.ts | 6 +++-- src/settings/SettingsModal.tsx | 7 +++-- src/utils/spa.ts | 1 - 9 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 288665c59..a0d65e64f 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; -import type { WidgetApi } from "matrix-widget-api"; import { ErrorPage } from "./FullScreenView"; import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, RegistrationType, @@ -138,6 +138,7 @@ interface Props { export const ClientProvider: FC = ({ children }) => { const navigate = useNavigate(); + const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< @@ -201,7 +202,6 @@ export const ClientProvider: FC = ({ children }) => { saveSession(session); setInitClientState({ - widgetApi: null, client, passwordlessUser: session.passwordlessUser, }); @@ -307,36 +307,16 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } - if (initClientState.widgetApi) { - const reactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.reaction", - ); - const redactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.room.redaction", - ); - const reactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.reaction", - ); - const redactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.room.redaction", - ); - - if (!reactSend || !reactRcv || !redactSend || !redactRcv) { - logger.warn("Widget does not support reactions"); - setSupportsReactions(false); - } else { - setSupportsReactions(true); - } - } else { - setSupportsReactions(true); - } + if (!hostBridge.supportsReactions) + logger.warn("The host does not permit reactions"); + setSupportsReactions(hostBridge.supportsReactions); return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync]); + }, [initClientState, onSync, hostBridge]); if (alreadyOpenedErr) { return ; @@ -346,7 +326,6 @@ export const ClientProvider: FC = ({ children }) => { }; export type InitResult = { - widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -357,7 +336,6 @@ async function loadClient(): Promise { logger.log("Using a matryoshka client"); const client = await widget.client; return { - widgetApi: widget.api, client, passwordlessUser: false, }; diff --git a/src/UrlParams.ts b/src/UrlParams.ts index dd83a4966..422163fc4 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -59,6 +59,17 @@ export interface UrlProperties { // Widget api related params widgetId: string | null; parentUrl: string | null; + /** + * Whether Element Call was started as a widget of a Matrix client, which is + * to say whether it was given a widget ID and a parent to talk to. + * + * Only meaningful to the standalone and widget builds, which own the URL — + * so use it for decisions that belong to the app shell, such as whether + * Element Call is responsible for authenticating the user. Anything the call + * interface itself needs to know about its host should come from the host + * bridge instead. + */ + isWidget: boolean; /** * Anything about what room we're pointed to should be from useRoomIdentifier which * parses the path and resolves alias with respect to the default server name, however @@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { const properties: UrlProperties = { widgetId, parentUrl, + isWidget, // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // what would we do if it were invalid? If the widget API says that's what // the room ID is, then that's what it is. diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index 4972c0312..7314c9e4a 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { initClient } from "../utils/matrix"; import { type Session } from "../ClientContext"; import { Config } from "../config/Config"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export const useInteractiveRegistration = ( oldClient?: MatrixClient, @@ -32,6 +32,7 @@ export const useInteractiveRegistration = ( passwordlessUser: boolean, ) => Promise<[MatrixClient, Session]>; } => { + const { isWidget } = useUrlParams(); const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState( undefined, ); @@ -47,7 +48,7 @@ export const useInteractiveRegistration = ( } useEffect(() => { - if (widget) return; + if (isWidget) return; // An empty registerRequest is used to get the privacy policy and recaptcha key. authClient.current!.registerRequest({}).catch((error) => { setPrivacyPolicyUrl( @@ -55,7 +56,7 @@ export const useInteractiveRegistration = ( ); setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); }); - }, []); + }, [isWidget]); const register = useCallback( async ( diff --git a/src/auth/useRegisterPasswordlessUser.ts b/src/auth/useRegisterPasswordlessUser.ts index c2cbe2d37..27674c623 100644 --- a/src/auth/useRegisterPasswordlessUser.ts +++ b/src/auth/useRegisterPasswordlessUser.ts @@ -12,7 +12,7 @@ import { useClient } from "../ClientContext"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { generateRandomName } from "../auth/generateRandomName"; import { useRecaptcha } from "../auth/useRecaptcha"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; interface UseRegisterPasswordlessUserType { privacyPolicyUrl?: string; @@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType { export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { const { setClient } = useClient(); + const { isWidget } = useUrlParams(); const { privacyPolicyUrl, recaptchaKey, register } = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); @@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { if (!setClient) { throw new Error("No client context"); } - if (widget) { + if (isWidget) { throw new Error( "Registration was skipped: We should never try to register password-less user in embedded mode.", ); @@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { throw e; } }, - [execute, reset, register, setClient], + [execute, reset, register, setClient, isWidget], ); return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e2edde844..ef1e1f988 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -28,7 +28,6 @@ import { useTranslation } from "react-i18next"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; @@ -251,6 +250,7 @@ export const InCallView: FC = ({ }) => { const logger = rootLogger.getChild("[InCallView]"); const { t } = useTranslation(); + const hostBridge = useHostBridge(); const { sendReaction, toggleRaisedHand } = useReactionsSender(); useWakeLock(); @@ -334,14 +334,17 @@ export const InCallView: FC = ({ const openProfile = useMemo( () => - // Profile settings are unavailable in widget mode - widget === null + // A host that can dismiss us is a host that owns the user's account, so + // their profile is not ours to edit. + // TODO: another use of the close capability as a proxy — see the note in + // GroupCallView. + hostBridge.close === undefined ? (): void => { setSettingsTab("profile"); setSettingsOpen(true); } : null, - [setSettingsTab, setSettingsOpen], + [setSettingsTab, setSettingsOpen, hostBridge], ); const [headerRef, headerBounds] = useMeasure(); diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 00905ac4d..33eb80ddb 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -29,7 +29,6 @@ import { GroupCallView } from "./GroupCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; @@ -77,7 +76,7 @@ export const RoomPage: FC = (): ReactNode => { calculateInitialMuteState( urlParams.skipLobby, urlParams.callIntent, - widget !== null, + urlParams.isWidget, ), hostBridge, ), @@ -88,7 +87,7 @@ export const RoomPage: FC = (): ReactNode => { useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user - if (!loading && !authenticated && displayName && !widget) { + if (!loading && !authenticated && displayName && !urlParams.isWidget) { setIsRegistering(true); registerPasswordlessUser(displayName) .catch((e) => { @@ -102,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => { loading, authenticated, displayName, + urlParams.isWidget, setIsRegistering, registerPasswordlessUser, ]); diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 8a7617d85..42464bb24 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -34,7 +34,7 @@ import { EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export type GroupCallLoaded = { kind: "loaded"; @@ -132,6 +132,7 @@ export const useLoadGroupCall = ( const [state, setState] = useState({ kind: "loading" }); const activeRoom = useRef(undefined); const { t } = useTranslation(); + const { isWidget } = useUrlParams(); const bannedError = useCallback( (): CallTerminatedMessage => @@ -249,7 +250,7 @@ export const useLoadGroupCall = ( // room already joined so we are done here already. return room!; } - if (widget) + if (isWidget) // in widget mode we never should reach this point. (getRoom should return the room.) throw new Error( "Room not found. The widget-api did not pass over the relevant room events/information.", @@ -373,6 +374,7 @@ export const useLoadGroupCall = ( }, [ bannedError, client, + isWidget, knockRejectError, removeNoticeError, roomIdOrAlias, diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b2ffef4ab..35604d983 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { iosDeviceMenu$ } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; -import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge"; import { useSetting, soundEffectVolume as soundEffectVolumeSetting, @@ -123,6 +123,7 @@ export const SettingsModal: FC = ({ // On EC, we decided that it is less confusing for the user if they see those options in the output section // rather than the input section. const { controlledAudioDevices } = useUrlParams(); + const hostBridge = useHostBridge(); // If we are on iOS we will show a button to open the native audio device picker. const iosDeviceMenu = useBehavior(iosDeviceMenu$); @@ -234,7 +235,9 @@ export const SettingsModal: FC = ({ }; const tabs = [audioTab, videoTab]; - if (widget === null) tabs.push(profileTab); + // A host that can dismiss us is a host that owns the user's account, so their + // profile is not ours to edit. + if (hostBridge.close === undefined) tabs.push(profileTab); tabs.push(preferencesTab); if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { // for full package we want to show the analytics consent checkbox diff --git a/src/utils/spa.ts b/src/utils/spa.ts index e97d78101..b8e959f8b 100644 --- a/src/utils/spa.ts +++ b/src/utils/spa.ts @@ -40,7 +40,6 @@ export async function initSPA( try { const client = await initClient(initClientParams, true); return { - widgetApi: null, client, passwordlessUser, };