Stop asking whether Element Call is a widget

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.
This commit is contained in:
Valere
2026-09-02 15:04:24 +02:00
parent bc6c232ef0
commit f28d9e9b06
9 changed files with 45 additions and 46 deletions
+6 -28
View File
@@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorPage } from "./FullScreenView"; import { ErrorPage } from "./FullScreenView";
import { widget } from "./widget"; import { widget } from "./widget";
import { useHostBridge } from "./HostBridge";
import { import {
PosthogAnalytics, PosthogAnalytics,
RegistrationType, RegistrationType,
@@ -138,6 +138,7 @@ interface Props {
export const ClientProvider: FC<Props> = ({ children }) => { export const ClientProvider: FC<Props> = ({ children }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const hostBridge = useHostBridge();
// null = signed out, undefined = loading // null = signed out, undefined = loading
const [initClientState, setInitClientState] = useState< const [initClientState, setInitClientState] = useState<
@@ -201,7 +202,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
saveSession(session); saveSession(session);
setInitClientState({ setInitClientState({
widgetApi: null,
client, client,
passwordlessUser: session.passwordlessUser, passwordlessUser: session.passwordlessUser,
}); });
@@ -307,36 +307,16 @@ export const ClientProvider: FC<Props> = ({ children }) => {
initClientState.client.on(ClientEvent.Sync, onSync); initClientState.client.on(ClientEvent.Sync, onSync);
} }
if (initClientState.widgetApi) { if (!hostBridge.supportsReactions)
const reactSend = initClientState.widgetApi.hasCapability( logger.warn("The host does not permit reactions");
"org.matrix.msc2762.send.event:m.reaction", setSupportsReactions(hostBridge.supportsReactions);
);
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);
}
return (): void => { return (): void => {
if (initClientState.client) { if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync); initClientState.client.removeListener(ClientEvent.Sync, onSync);
} }
}; };
}, [initClientState, onSync]); }, [initClientState, onSync, hostBridge]);
if (alreadyOpenedErr) { if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />; return <ErrorPage error={alreadyOpenedErr} />;
@@ -346,7 +326,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}; };
export type InitResult = { export type InitResult = {
widgetApi: WidgetApi | null;
client: MatrixClient; client: MatrixClient;
passwordlessUser: boolean; passwordlessUser: boolean;
}; };
@@ -357,7 +336,6 @@ async function loadClient(): Promise<InitResult | null> {
logger.log("Using a matryoshka client"); logger.log("Using a matryoshka client");
const client = await widget.client; const client = await widget.client;
return { return {
widgetApi: widget.api,
client, client,
passwordlessUser: false, passwordlessUser: false,
}; };
+12
View File
@@ -59,6 +59,17 @@ export interface UrlProperties {
// Widget api related params // Widget api related params
widgetId: string | null; widgetId: string | null;
parentUrl: 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 * 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 * 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 = { const properties: UrlProperties = {
widgetId, widgetId,
parentUrl, parentUrl,
isWidget,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // 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 // 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. // the room ID is, then that's what it is.
+4 -3
View File
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix"; import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext"; import { type Session } from "../ClientContext";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { widget } from "../widget"; import { useUrlParams } from "../UrlParams";
export const useInteractiveRegistration = ( export const useInteractiveRegistration = (
oldClient?: MatrixClient, oldClient?: MatrixClient,
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
passwordlessUser: boolean, passwordlessUser: boolean,
) => Promise<[MatrixClient, Session]>; ) => Promise<[MatrixClient, Session]>;
} => { } => {
const { isWidget } = useUrlParams();
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>( const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined, undefined,
); );
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
} }
useEffect(() => { useEffect(() => {
if (widget) return; if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key. // An empty registerRequest is used to get the privacy policy and recaptcha key.
authClient.current!.registerRequest({}).catch((error) => { authClient.current!.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl( setPrivacyPolicyUrl(
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
); );
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
}); });
}, []); }, [isWidget]);
const register = useCallback( const register = useCallback(
async ( async (
+4 -3
View File
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha"; import { useRecaptcha } from "../auth/useRecaptcha";
import { widget } from "../widget"; import { useUrlParams } from "../UrlParams";
interface UseRegisterPasswordlessUserType { interface UseRegisterPasswordlessUserType {
privacyPolicyUrl?: string; privacyPolicyUrl?: string;
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient(); const { setClient } = useClient();
const { isWidget } = useUrlParams();
const { privacyPolicyUrl, recaptchaKey, register } = const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
if (!setClient) { if (!setClient) {
throw new Error("No client context"); throw new Error("No client context");
} }
if (widget) { if (isWidget) {
throw new Error( throw new Error(
"Registration was skipped: We should never try to register password-less user in embedded mode.", "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; throw e;
} }
}, },
[execute, reset, register, setClient], [execute, reset, register, setClient, isWidget],
); );
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
+7 -4
View File
@@ -28,7 +28,6 @@ import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { HeaderStyle, useUrlParams } from "../UrlParams"; import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts"; import { useHostBridge } from "../HostBridge.ts";
import styles from "./InCallView.module.css"; import styles from "./InCallView.module.css";
import { GridTile } from "../tile/GridTile"; import { GridTile } from "../tile/GridTile";
@@ -251,6 +250,7 @@ export const InCallView: FC<InCallViewProps> = ({
}) => { }) => {
const logger = rootLogger.getChild("[InCallView]"); const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation(); const { t } = useTranslation();
const hostBridge = useHostBridge();
const { sendReaction, toggleRaisedHand } = useReactionsSender(); const { sendReaction, toggleRaisedHand } = useReactionsSender();
useWakeLock(); useWakeLock();
@@ -334,14 +334,17 @@ export const InCallView: FC<InCallViewProps> = ({
const openProfile = useMemo( const openProfile = useMemo(
() => () =>
// Profile settings are unavailable in widget mode // A host that can dismiss us is a host that owns the user's account, so
widget === null // 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 => { ? (): void => {
setSettingsTab("profile"); setSettingsTab("profile");
setSettingsOpen(true); setSettingsOpen(true);
} }
: null, : null,
[setSettingsTab, setSettingsOpen], [setSettingsTab, setSettingsOpen, hostBridge],
); );
const [headerRef, headerBounds] = useMeasure(); const [headerRef, headerBounds] = useMeasure();
+3 -3
View File
@@ -29,7 +29,6 @@ import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage"; import { HomePage } from "../home/HomePage";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts"; import { useHostBridge } from "../HostBridge.ts";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
@@ -77,7 +76,7 @@ export const RoomPage: FC = (): ReactNode => {
calculateInitialMuteState( calculateInitialMuteState(
urlParams.skipLobby, urlParams.skipLobby,
urlParams.callIntent, urlParams.callIntent,
widget !== null, urlParams.isWidget,
), ),
hostBridge, hostBridge,
), ),
@@ -88,7 +87,7 @@ export const RoomPage: FC = (): ReactNode => {
useEffect(() => { useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as // 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 // a URL param, automatically register a passwordless user
if (!loading && !authenticated && displayName && !widget) { if (!loading && !authenticated && displayName && !urlParams.isWidget) {
setIsRegistering(true); setIsRegistering(true);
registerPasswordlessUser(displayName) registerPasswordlessUser(displayName)
.catch((e) => { .catch((e) => {
@@ -102,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => {
loading, loading,
authenticated, authenticated,
displayName, displayName,
urlParams.isWidget,
setIsRegistering, setIsRegistering,
registerPasswordlessUser, registerPasswordlessUser,
]); ]);
+4 -2
View File
@@ -34,7 +34,7 @@ import {
EndCallIcon, EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { widget } from "../widget"; import { useUrlParams } from "../UrlParams";
export type GroupCallLoaded = { export type GroupCallLoaded = {
kind: "loaded"; kind: "loaded";
@@ -132,6 +132,7 @@ export const useLoadGroupCall = (
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" }); const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
const activeRoom = useRef<Room | undefined>(undefined); const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation(); const { t } = useTranslation();
const { isWidget } = useUrlParams();
const bannedError = useCallback( const bannedError = useCallback(
(): CallTerminatedMessage => (): CallTerminatedMessage =>
@@ -249,7 +250,7 @@ export const useLoadGroupCall = (
// room already joined so we are done here already. // room already joined so we are done here already.
return room!; return room!;
} }
if (widget) if (isWidget)
// in widget mode we never should reach this point. (getRoom should return the room.) // in widget mode we never should reach this point. (getRoom should return the room.)
throw new Error( throw new Error(
"Room not found. The widget-api did not pass over the relevant room events/information.", "Room not found. The widget-api did not pass over the relevant room events/information.",
@@ -373,6 +374,7 @@ export const useLoadGroupCall = (
}, [ }, [
bannedError, bannedError,
client, client,
isWidget,
knockRejectError, knockRejectError,
removeNoticeError, removeNoticeError,
roomIdOrAlias, roomIdOrAlias,
+5 -2
View File
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { iosDeviceMenu$ } from "../state/MediaDevices"; import { iosDeviceMenu$ } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { widget } from "../widget"; import { useHostBridge } from "../HostBridge";
import { import {
useSetting, useSetting,
soundEffectVolume as soundEffectVolumeSetting, soundEffectVolume as soundEffectVolumeSetting,
@@ -123,6 +123,7 @@ export const SettingsModal: FC<Props> = ({
// On EC, we decided that it is less confusing for the user if they see those options in the output section // 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. // rather than the input section.
const { controlledAudioDevices } = useUrlParams(); const { controlledAudioDevices } = useUrlParams();
const hostBridge = useHostBridge();
// If we are on iOS we will show a button to open the native audio device picker. // If we are on iOS we will show a button to open the native audio device picker.
const iosDeviceMenu = useBehavior(iosDeviceMenu$); const iosDeviceMenu = useBehavior(iosDeviceMenu$);
@@ -234,7 +235,9 @@ export const SettingsModal: FC<Props> = ({
}; };
const tabs = [audioTab, videoTab]; 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); tabs.push(preferencesTab);
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox // for full package we want to show the analytics consent checkbox
-1
View File
@@ -40,7 +40,6 @@ export async function initSPA(
try { try {
const client = await initClient(initClientParams, true); const client = await initClient(initClientParams, true);
return { return {
widgetApi: null,
client, client,
passwordlessUser, passwordlessUser,
}; };