Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 77 additions & 18 deletions apps/web/src/components/views/elements/AppTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidget
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";
Expand Down Expand Up @@ -191,10 +193,21 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setState({ isUserProfileReady: true });
};

// This is a function to make the impact of calling SettingsStore slightly less
private hasPermissionToLoad = (props: IProps): boolean => {
/**
* 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 => {
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;
Expand All @@ -207,6 +220,36 @@ export default class AppTile extends React.Component<IProps, IState> {
return allowed || props.userId === props.creatorUserId;
};

/**
* 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,
Expand Down Expand Up @@ -269,9 +312,9 @@ export default class AppTile extends React.Component<IProps, IState> {
// 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),
// 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: this.hasPermissionToLoad(newProps),
// 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,
error: null,
menuDisplayed: false,
Expand All @@ -280,19 +323,25 @@ export default class AppTile extends React.Component<IProps, IState> {
}

private onAllowedWidgetsChange = (): void => {
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
if (this.unmounted) return;

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();
}
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();
}

this.setState({ hasPermissionToLoad });
if (!this.state.hasPermissionToLoad && hasPermissionToLoad) {
this.startWidget();
}

this.setState({ hasPermissionToLoad });
});
};

private isMixedContent(): boolean {
Expand Down Expand Up @@ -327,9 +376,19 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setupMessagingListeners();
}

// Only fetch IM token on mount if we're showing and have permission to load
if (this.messaging && this.state.hasPermissionToLoad) {
// 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.
this.startWidget();
} else {
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
if (this.unmounted) return;
this.setState({ hasPermissionToLoad });
if (hasPermissionToLoad) {
this.startWidget();
}
});
}
this.watchUserReady();

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ElementWebExtrasApi } from "./ExtrasApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";

const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
Expand Down Expand Up @@ -85,6 +86,7 @@ export class ModuleApi implements Api {
public readonly customComponents = new CustomComponentsApi();
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly widgetLifecycle = new WidgetLifecycleApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public readonly stores = new StoresApi();
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/modules/WidgetLifecycleApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import type { Widget } from "matrix-widget-api";
import type {
CapabilitiesApprover,
IdentityApprover,
PreloadApprover,
WidgetDescriptor,
WidgetLifecycleApi as WidgetLifecycleApiInterface,
} from "@element-hq/element-web-module-api";

/**
* Converts a matrix-widget-api {@link Widget} into a {@link WidgetDescriptor} for the module API.
* @param widget - The widget instance from matrix-widget-api.
* @param roomId - The room ID the widget belongs to, if applicable.
*/
export const toWidgetDescriptor = (widget: Widget, roomId?: string): WidgetDescriptor => {
return {
id: widget.id,
templateUrl: widget.templateUrl,
creatorUserId: widget.creatorUserId,
type: widget.type,
origin: widget.origin,
roomId,
};
};

/**
* Host-side implementation of the widget lifecycle API.
* Allows a single module to register approver callbacks for widget preloading,
* identity token requests, and capability requests. Only one approver per slot
* is supported; attempting to register a second throws an error.
*/
export class WidgetLifecycleApi implements WidgetLifecycleApiInterface {
private preloadApprover?: PreloadApprover;
private identityApprover?: IdentityApprover;
private capabilitiesApprover?: CapabilitiesApprover;

private ensureApproverUnset<T>(current: T | undefined, name: string): void {
if (current) {
throw new Error(`Widget lifecycle ${name} approver already registered`);
}
}

/**
* Register a handler that can auto-approve widget preloading.
* Only one preload approver may be registered; a second call throws.
*/
public registerPreloadApprover(approver: PreloadApprover): void {
this.ensureApproverUnset(this.preloadApprover, "preload");
this.preloadApprover = approver;
}

/**
* Register a handler that can auto-approve identity token requests.
* Only one identity approver may be registered; a second call throws.
*/
public registerIdentityApprover(approver: IdentityApprover): void {
this.ensureApproverUnset(this.identityApprover, "identity");
this.identityApprover = approver;
}

/**
* Register a handler that can auto-approve widget capabilities.
* Only one capabilities approver may be registered; a second call throws.
*/
public registerCapabilitiesApprover(approver: CapabilitiesApprover): void {
this.ensureApproverUnset(this.capabilitiesApprover, "capabilities");
this.capabilitiesApprover = approver;
}

/**
* Invoke the registered preload approver for the given widget.
* @returns `true` if the module approved preloading, `false` otherwise.
*/
public async preapprovePreload(widget: WidgetDescriptor): Promise<boolean> {
if (!this.preloadApprover) return false;
try {
return (await this.preloadApprover(widget)) === true;
} catch (error) {
console.error("Widget preload approver failed", error);
return false;
}
}

/**
* Invoke the registered identity approver for the given widget.
* @returns `true` if the module approved the identity token request, `false` otherwise.
*/
public async preapproveIdentity(widget: WidgetDescriptor): Promise<boolean> {
if (!this.identityApprover) return false;
try {
return (await this.identityApprover(widget)) === true;
} catch (error) {
console.error("Widget identity approver failed", error);
return false;
}
}

/**
* Invoke the registered capabilities approver for the given widget.
* @returns The set of approved capabilities, or `undefined` to defer to the default consent flow.
*/
public async preapproveCapabilities(
widget: WidgetDescriptor,
requestedCapabilities: Set<string>,
): Promise<Set<string> | undefined> {
if (!this.capabilitiesApprover) return undefined;
try {
return await this.capabilitiesApprover(widget, requestedCapabilities);
} catch (error) {
console.error("Widget capabilities approver failed", error);
return undefined;
}
}
}
37 changes: 28 additions & 9 deletions apps/web/src/stores/widgets/ElementWidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { navigateToPermalink } from "../../utils/permalinks/navigator";
import { SdkContextClass } from "../../contexts/SDKContext";
import { ModuleRunner } from "../../modules/ModuleRunner";
import { ModuleApi } from "../../modules/Api";
import { toWidgetDescriptor } from "../../modules/WidgetLifecycleApi";
import SettingsStore from "../../settings/SettingsStore";
import { mediaFromMxc } from "../../customisations/Media";

Expand Down Expand Up @@ -250,13 +252,20 @@ export class ElementWidgetDriver extends WidgetDriver {
missing.delete(cap);
});

// Try the new module API first, then fall back to legacy paths
let approved: Set<string> | undefined;
if (WidgetPermissionCustomisations.preapproveCapabilities) {
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
} else {
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
approved = opts.approvedCapabilities;
approved = await ModuleApi.instance.widgetLifecycle.preapproveCapabilities(
toWidgetDescriptor(this.forWidget, this.inRoomId),
requested,
);
if (!approved) {
if (WidgetPermissionCustomisations.preapproveCapabilities) {
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
} else {
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
approved = opts.approvedCapabilities;
}
}
if (approved) {
approved.forEach((cap) => {
Expand Down Expand Up @@ -663,9 +672,19 @@ export class ElementWidgetDriver extends WidgetDriver {
}

public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, opts, this.forWidget);
if (opts.approved) {
// Try the new module API first, then fall back to legacy path
let approved: boolean | undefined = await ModuleApi.instance.widgetLifecycle.preapproveIdentity(
toWidgetDescriptor(this.forWidget, this.inRoomId),
);

if (!approved) {
// Legacy module API fallback
const legacyOpts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget);
approved = legacyOpts.approved;
}

if (approved) {
return observer.update({
state: OpenIDRequestState.Allowed,
token: await MatrixClientPeg.safeGet().getOpenIdToken(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/Ele
import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
import { ModuleApi } from "../../../../../src/modules/Api";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";

Expand Down Expand Up @@ -426,6 +427,30 @@ describe("AppTile", () => {
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
});

it("should auto-approve preload via new widget lifecycle API", async () => {
// Legacy module API denies preload
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) {
(opts as ApprovalOpts).approved = false;
}
});

// New API approves preload
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapprovePreload").mockResolvedValue(true);

// userId and creatorUserId are different so legacy path would show "Continue"
const renderResult = render(
<MatrixClientContext.Provider value={cli}>
<AppTile key={app1.id} app={app1} room={r1} userId="@user1" creatorUserId="@userAnother" />
</MatrixClientContext.Provider>,
);

// The new API runs async in componentDidMount, so wait for it to take effect
await waitFor(() => {
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
});
});

describe("for a maximised (centered) widget", () => {
beforeEach(() => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation(
Expand Down
Loading
Loading