From 02c9f4229f933147c6717137a00f4d1c4d36fe8c Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 18 Feb 2026 15:04:04 +0000 Subject: [PATCH 1/7] Add widget lifecycle API at top level --- src/modules/Api.ts | 2 + src/modules/WidgetLifecycleApi.ts | 137 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/modules/WidgetLifecycleApi.ts diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 057cbf71b09..bcb29f385ed 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -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 = (baseCustomisations: T) => { let used = false; @@ -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(); diff --git a/src/modules/WidgetLifecycleApi.ts b/src/modules/WidgetLifecycleApi.ts new file mode 100644 index 00000000000..c1ac75469b9 --- /dev/null +++ b/src/modules/WidgetLifecycleApi.ts @@ -0,0 +1,137 @@ +/* +Copyright 2026 New Vector 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, WidgetKind as MatrixWidgetKind } from "matrix-widget-api"; + +import type { + CapabilitiesApprover, + IdentityApprover, + PreloadApprover, + WidgetDescriptor, + WidgetKind, + WidgetLifecycleApi as WidgetLifecycleApiInterface, +} from "@element-hq/element-web-module-api"; + +const toModuleWidgetKind = (kind: MatrixWidgetKind): WidgetKind => { + switch (kind) { + case MatrixWidgetKind.Account: + return "account"; + case MatrixWidgetKind.Modal: + return "modal"; + case MatrixWidgetKind.Room: + default: + return "room"; + } +}; + +/** + * 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 kind - The widget kind (room, account, or modal). + * @param roomId - The room ID the widget belongs to, if applicable. + */ +export const toWidgetDescriptor = (widget: Widget, kind: MatrixWidgetKind, roomId?: string): WidgetDescriptor => { + return { + id: widget.id, + templateUrl: widget.templateUrl, + creatorUserId: widget.creatorUserId, + type: widget.type, + origin: widget.origin, + roomId, + kind: toModuleWidgetKind(kind), + }; +}; + +/** + * 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(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 { + 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 { + 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, + ): Promise | undefined> { + if (!this.capabilitiesApprover) return undefined; + try { + return await this.capabilitiesApprover(widget, requestedCapabilities); + } catch (error) { + console.error("Widget capabilities approver failed", error); + return undefined; + } + } +} From b2b7ff2a9ae35e0aee816eb1be8f4d4faed87c82 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 18 Feb 2026 15:05:08 +0000 Subject: [PATCH 2/7] Integrate while still falling back to the legacy api --- src/components/views/elements/AppTile.tsx | 20 +- src/stores/widgets/ElementWidgetDriver.ts | 39 +++- .../views/elements/AppTile-test.tsx | 25 ++ .../modules/WidgetLifecycleApi-test.ts | 215 ++++++++++++++++++ .../widgets/ElementWidgetDriver-test.ts | 71 ++++++ 5 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 test/unit-tests/modules/WidgetLifecycleApi-test.ts diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 46de406d843..e5497e46d16 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -18,7 +18,7 @@ import React, { type ReactNode, } from "react"; import classNames from "classnames"; -import { type IWidget, MatrixCapabilities, type ClientWidgetApi } from "matrix-widget-api"; +import { type IWidget, MatrixCapabilities, type ClientWidgetApi, WidgetKind as MatrixWidgetKind } from "matrix-widget-api"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -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"; @@ -295,6 +297,19 @@ export default class AppTile extends React.Component { this.setState({ hasPermissionToLoad }); }; + private checkPreloadApproval(): void { + const kind = this.props.room ? MatrixWidgetKind.Room : MatrixWidgetKind.Account; + const descriptor = toWidgetDescriptor(this.widget, kind, this.props.room?.roomId); + ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor).then((approved) => { + if (approved && !this.unmounted) { + this.setState({ hasPermissionToLoad: true }); + this.startWidget(); + } + }).catch((err) => { + logger.error("New API preload approval check failed", err); + }); + } + private isMixedContent(): boolean { const parentContentProtocol = window.location.protocol; const u = parseUrl(this.props.app.url); @@ -330,6 +345,9 @@ export default class AppTile extends React.Component { // Only fetch IM token on mount if we're showing and have permission to load if (this.messaging && this.state.hasPermissionToLoad) { this.startWidget(); + } else if (!this.state.hasPermissionToLoad && this.props.room) { + // Check the module API for preload approval (async) + this.checkPreloadApproval(); } this.watchUserReady(); diff --git a/src/stores/widgets/ElementWidgetDriver.ts b/src/stores/widgets/ElementWidgetDriver.ts index 725f149f0d3..e44103566bd 100644 --- a/src/stores/widgets/ElementWidgetDriver.ts +++ b/src/stores/widgets/ElementWidgetDriver.ts @@ -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"; @@ -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 | 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.forWidgetKind, 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) => { @@ -648,9 +657,21 @@ export class ElementWidgetDriver extends WidgetDriver { } public async askOpenID(observer: SimpleObservable): Promise { - 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 + const newApiApproved = await ModuleApi.instance.widgetLifecycle.preapproveIdentity( + toWidgetDescriptor(this.forWidget, this.forWidgetKind, this.inRoomId), + ); + if (newApiApproved) { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await MatrixClientPeg.safeGet().getOpenIdToken(), + }); + } + + // Legacy module API fallback + const legacyOpts: ApprovalOpts = { approved: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget); + if (legacyOpts.approved) { return observer.update({ state: OpenIDRequestState.Allowed, token: await MatrixClientPeg.safeGet().getOpenIdToken(), diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index 981e3c80cec..73242b20140 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -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"; @@ -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( + + + , + ); + + // 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( diff --git a/test/unit-tests/modules/WidgetLifecycleApi-test.ts b/test/unit-tests/modules/WidgetLifecycleApi-test.ts new file mode 100644 index 00000000000..6eeb667a97b --- /dev/null +++ b/test/unit-tests/modules/WidgetLifecycleApi-test.ts @@ -0,0 +1,215 @@ +/* +Copyright 2026 New Vector 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 { WidgetDescriptor } from "@element-hq/element-web-module-api"; + +import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi"; +import { Widget, WidgetKind } from "matrix-widget-api"; + +const mkDescriptor = (overrides: Partial = {}): WidgetDescriptor => ({ + id: "test-widget", + templateUrl: "https://example.org/widget", + creatorUserId: "@alice:example.org", + type: "m.custom", + origin: "https://example.org", + kind: "room", + roomId: "!room:example.org", + ...overrides, +}); + +describe("WidgetLifecycleApi", () => { + let api: WidgetLifecycleApi; + + beforeEach(() => { + api = new WidgetLifecycleApi(); + }); + + describe("toWidgetDescriptor", () => { + it("converts a Widget to a WidgetDescriptor for a room widget", () => { + const widget = new Widget({ + id: "w1", + creatorUserId: "@bob:example.org", + type: "m.jitsi", + url: "https://jitsi.example.org/meet?conf=$matrix_room_id", + }); + const descriptor = toWidgetDescriptor(widget, WidgetKind.Room, "!room:example.org"); + expect(descriptor).toEqual({ + id: "w1", + templateUrl: "https://jitsi.example.org/meet?conf=$matrix_room_id", + creatorUserId: "@bob:example.org", + type: "m.jitsi", + origin: "https://jitsi.example.org", + roomId: "!room:example.org", + kind: "room", + }); + }); + + it("maps Account widget kind", () => { + const widget = new Widget({ + id: "w1", + creatorUserId: "@bob:example.org", + type: "m.custom", + url: "https://example.org", + }); + const descriptor = toWidgetDescriptor(widget, WidgetKind.Account); + expect(descriptor.kind).toBe("account"); + expect(descriptor.roomId).toBeUndefined(); + }); + + it("maps Modal widget kind", () => { + const widget = new Widget({ + id: "w1", + creatorUserId: "@bob:example.org", + type: "m.custom", + url: "https://example.org", + }); + const descriptor = toWidgetDescriptor(widget, WidgetKind.Modal); + expect(descriptor.kind).toBe("modal"); + }); + }); + + describe("registerPreloadApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerPreloadApprover(() => true)).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerPreloadApprover(() => true); + expect(() => api.registerPreloadApprover(() => true)).toThrow( + "Widget lifecycle preload approver already registered", + ); + }); + }); + + describe("registerIdentityApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerIdentityApprover(() => true)).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerIdentityApprover(() => true); + expect(() => api.registerIdentityApprover(() => true)).toThrow( + "Widget lifecycle identity approver already registered", + ); + }); + }); + + describe("registerCapabilitiesApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerCapabilitiesApprover(() => new Set())).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerCapabilitiesApprover(() => new Set()); + expect(() => api.registerCapabilitiesApprover(() => new Set())).toThrow( + "Widget lifecycle capabilities approver already registered", + ); + }); + }); + + describe("preapprovePreload", () => { + const widget = mkDescriptor(); + + it("returns false when no approver registered", async () => { + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns true when approver returns true", async () => { + api.registerPreloadApprover(() => true); + expect(await api.preapprovePreload(widget)).toBe(true); + }); + + it("returns false when approver returns false", async () => { + api.registerPreloadApprover(() => false); + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns false when approver returns undefined", async () => { + api.registerPreloadApprover(() => undefined); + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns false and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerPreloadApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapprovePreload(widget)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("Widget preload approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("preapproveIdentity", () => { + const widget = mkDescriptor(); + + it("returns false when no approver registered", async () => { + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns true when approver returns true", async () => { + api.registerIdentityApprover(() => true); + expect(await api.preapproveIdentity(widget)).toBe(true); + }); + + it("returns false when approver returns false", async () => { + api.registerIdentityApprover(() => false); + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns false when approver returns undefined", async () => { + api.registerIdentityApprover(() => undefined); + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns false and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerIdentityApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapproveIdentity(widget)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("Widget identity approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("preapproveCapabilities", () => { + const widget = mkDescriptor(); + const requested = new Set(["cap1", "cap2", "cap3"]); + + it("returns undefined when no approver registered", async () => { + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + }); + + it("returns the set from the approver", async () => { + api.registerCapabilitiesApprover(() => new Set(["cap1", "cap2"])); + expect(await api.preapproveCapabilities(widget, requested)).toEqual(new Set(["cap1", "cap2"])); + }); + + it("passes widget and requested capabilities to the approver", async () => { + const approver = jest.fn().mockReturnValue(new Set(["cap1"])); + api.registerCapabilitiesApprover(approver); + await api.preapproveCapabilities(widget, requested); + expect(approver).toHaveBeenCalledWith(widget, requested); + }); + + it("returns undefined when approver returns undefined", async () => { + api.registerCapabilitiesApprover(() => undefined); + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + }); + + it("returns undefined and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerCapabilitiesApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith("Widget capabilities approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts index a95a6ed9586..6f490c9e95a 100644 --- a/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts @@ -39,6 +39,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; +import { ModuleApi } from "../../../../src/modules/Api"; import dis from "../../../../src/dispatcher/dispatcher"; import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; @@ -187,6 +188,76 @@ describe("ElementWidgetDriver", () => { expect(listener).toHaveBeenCalledWith(openIdUpdate); }); + it("approves capabilities via new widget lifecycle API", async () => { + const driver = mkDefaultDriver(); + + const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue( + requestedCapabilities, + ); + + const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities); + expect(approvedCapabilities).toEqual(requestedCapabilities); + }); + + it("falls back to legacy module API when new API returns undefined for capabilities", async () => { + const driver = mkDefaultDriver(); + + const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(undefined); + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation( + (lifecycleEvent, opts, widgetInfo, requested) => { + if (lifecycleEvent === WidgetLifecycle.CapabilitiesRequest) { + (opts as CapabilitiesOpts).approvedCapabilities = requested; + } + }, + ); + + const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities); + expect(approvedCapabilities).toEqual(requestedCapabilities); + }); + + it("approves identity via new widget lifecycle API", async () => { + const driver = mkDefaultDriver(); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(true); + + const listener = jest.fn(); + const observer = new SimpleObservable(); + observer.onUpdate(listener); + await driver.askOpenID(observer); + + const openIdUpdate: IOpenIDUpdate = { + state: OpenIDRequestState.Allowed, + token: await client.getOpenIdToken(), + }; + expect(listener).toHaveBeenCalledWith(openIdUpdate); + }); + + it("falls back to legacy module API when new API returns false for identity", async () => { + const driver = mkDefaultDriver(); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(false); + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.IdentityRequest) { + (opts as ApprovalOpts).approved = true; + } + }); + + const listener = jest.fn(); + const observer = new SimpleObservable(); + observer.onUpdate(listener); + await driver.askOpenID(observer); + + const openIdUpdate: IOpenIDUpdate = { + state: OpenIDRequestState.Allowed, + token: await client.getOpenIdToken(), + }; + expect(listener).toHaveBeenCalledWith(openIdUpdate); + }); + describe("sendToDevice", () => { const contentMap = { "@alice:example.org": { From 6815a60c1fd173484c155571808f6289077b79a2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 18 Feb 2026 18:57:06 +0000 Subject: [PATCH 3/7] Remove WidgetKind --- src/components/views/elements/AppTile.tsx | 5 ++-- src/modules/WidgetLifecycleApi.ts | 19 ++------------- src/stores/widgets/ElementWidgetDriver.ts | 4 ++-- .../modules/WidgetLifecycleApi-test.ts | 24 ++++--------------- 4 files changed, 11 insertions(+), 41 deletions(-) diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index e5497e46d16..65ebf0ce3db 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -18,7 +18,7 @@ import React, { type ReactNode, } from "react"; import classNames from "classnames"; -import { type IWidget, MatrixCapabilities, type ClientWidgetApi, WidgetKind as MatrixWidgetKind } from "matrix-widget-api"; +import { type IWidget, MatrixCapabilities, type ClientWidgetApi } from "matrix-widget-api"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -298,8 +298,7 @@ export default class AppTile extends React.Component { }; private checkPreloadApproval(): void { - const kind = this.props.room ? MatrixWidgetKind.Room : MatrixWidgetKind.Account; - const descriptor = toWidgetDescriptor(this.widget, kind, this.props.room?.roomId); + const descriptor = toWidgetDescriptor(this.widget, this.props.room?.roomId); ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor).then((approved) => { if (approved && !this.unmounted) { this.setState({ hasPermissionToLoad: true }); diff --git a/src/modules/WidgetLifecycleApi.ts b/src/modules/WidgetLifecycleApi.ts index c1ac75469b9..86d0dd510a2 100644 --- a/src/modules/WidgetLifecycleApi.ts +++ b/src/modules/WidgetLifecycleApi.ts @@ -5,36 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Widget, WidgetKind as MatrixWidgetKind } from "matrix-widget-api"; +import type { Widget } from "matrix-widget-api"; import type { CapabilitiesApprover, IdentityApprover, PreloadApprover, WidgetDescriptor, - WidgetKind, WidgetLifecycleApi as WidgetLifecycleApiInterface, } from "@element-hq/element-web-module-api"; -const toModuleWidgetKind = (kind: MatrixWidgetKind): WidgetKind => { - switch (kind) { - case MatrixWidgetKind.Account: - return "account"; - case MatrixWidgetKind.Modal: - return "modal"; - case MatrixWidgetKind.Room: - default: - return "room"; - } -}; - /** * 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 kind - The widget kind (room, account, or modal). * @param roomId - The room ID the widget belongs to, if applicable. */ -export const toWidgetDescriptor = (widget: Widget, kind: MatrixWidgetKind, roomId?: string): WidgetDescriptor => { +export const toWidgetDescriptor = (widget: Widget, roomId?: string): WidgetDescriptor => { return { id: widget.id, templateUrl: widget.templateUrl, @@ -42,7 +28,6 @@ export const toWidgetDescriptor = (widget: Widget, kind: MatrixWidgetKind, roomI type: widget.type, origin: widget.origin, roomId, - kind: toModuleWidgetKind(kind), }; }; diff --git a/src/stores/widgets/ElementWidgetDriver.ts b/src/stores/widgets/ElementWidgetDriver.ts index e44103566bd..62bfe15ad4c 100644 --- a/src/stores/widgets/ElementWidgetDriver.ts +++ b/src/stores/widgets/ElementWidgetDriver.ts @@ -255,7 +255,7 @@ export class ElementWidgetDriver extends WidgetDriver { // Try the new module API first, then fall back to legacy paths let approved: Set | undefined; approved = await ModuleApi.instance.widgetLifecycle.preapproveCapabilities( - toWidgetDescriptor(this.forWidget, this.forWidgetKind, this.inRoomId), + toWidgetDescriptor(this.forWidget, this.inRoomId), requested, ); if (!approved) { @@ -659,7 +659,7 @@ export class ElementWidgetDriver extends WidgetDriver { public async askOpenID(observer: SimpleObservable): Promise { // Try the new module API first, then fall back to legacy path const newApiApproved = await ModuleApi.instance.widgetLifecycle.preapproveIdentity( - toWidgetDescriptor(this.forWidget, this.forWidgetKind, this.inRoomId), + toWidgetDescriptor(this.forWidget, this.inRoomId), ); if (newApiApproved) { return observer.update({ diff --git a/test/unit-tests/modules/WidgetLifecycleApi-test.ts b/test/unit-tests/modules/WidgetLifecycleApi-test.ts index 6eeb667a97b..7cb6d2b1b67 100644 --- a/test/unit-tests/modules/WidgetLifecycleApi-test.ts +++ b/test/unit-tests/modules/WidgetLifecycleApi-test.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import type { WidgetDescriptor } from "@element-hq/element-web-module-api"; import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi"; -import { Widget, WidgetKind } from "matrix-widget-api"; +import { Widget } from "matrix-widget-api"; const mkDescriptor = (overrides: Partial = {}): WidgetDescriptor => ({ id: "test-widget", @@ -16,7 +16,6 @@ const mkDescriptor = (overrides: Partial = {}): WidgetDescript creatorUserId: "@alice:example.org", type: "m.custom", origin: "https://example.org", - kind: "room", roomId: "!room:example.org", ...overrides, }); @@ -29,14 +28,14 @@ describe("WidgetLifecycleApi", () => { }); describe("toWidgetDescriptor", () => { - it("converts a Widget to a WidgetDescriptor for a room widget", () => { + it("converts a Widget to a WidgetDescriptor", () => { const widget = new Widget({ id: "w1", creatorUserId: "@bob:example.org", type: "m.jitsi", url: "https://jitsi.example.org/meet?conf=$matrix_room_id", }); - const descriptor = toWidgetDescriptor(widget, WidgetKind.Room, "!room:example.org"); + const descriptor = toWidgetDescriptor(widget, "!room:example.org"); expect(descriptor).toEqual({ id: "w1", templateUrl: "https://jitsi.example.org/meet?conf=$matrix_room_id", @@ -44,32 +43,19 @@ describe("WidgetLifecycleApi", () => { type: "m.jitsi", origin: "https://jitsi.example.org", roomId: "!room:example.org", - kind: "room", }); }); - it("maps Account widget kind", () => { + it("omits roomId when not provided", () => { const widget = new Widget({ id: "w1", creatorUserId: "@bob:example.org", type: "m.custom", url: "https://example.org", }); - const descriptor = toWidgetDescriptor(widget, WidgetKind.Account); - expect(descriptor.kind).toBe("account"); + const descriptor = toWidgetDescriptor(widget); expect(descriptor.roomId).toBeUndefined(); }); - - it("maps Modal widget kind", () => { - const widget = new Widget({ - id: "w1", - creatorUserId: "@bob:example.org", - type: "m.custom", - url: "https://example.org", - }); - const descriptor = toWidgetDescriptor(widget, WidgetKind.Modal); - expect(descriptor.kind).toBe("modal"); - }); }); describe("registerPreloadApprover", () => { From 99c92e7654cfbe3e3af907973c17f8c11bc02bc8 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Feb 2026 16:57:31 +0000 Subject: [PATCH 4/7] Update module api to the one that includes the new widget lifecycle api --- pnpm-lock.yaml | 28 ++++++++++++++-------------- pnpm-workspace.yaml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831f60a1c1d..6feca0d1d73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@element-hq/element-web-module-api': - specifier: 1.9.1 - version: 1.9.1 + specifier: 1.10.0 + version: 1.10.0 '@element-hq/element-web-playwright-common': specifier: 2.2.7 version: 2.2.7 @@ -142,7 +142,7 @@ importers: version: 7.28.6 '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@element-hq/web-shared-components': specifier: workspace:* version: link:../../packages/shared-components @@ -283,7 +283,7 @@ importers: version: 1.0.3 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e4425570c7edab4cfb3d4478fdabd70ae86894d4 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5739b59faaf33fa55457dcaa76ffebe0506d1466 matrix-widget-api: specifier: ^1.16.1 version: 1.17.0 @@ -416,7 +416,7 @@ importers: version: 0.16.3 '@element-hq/element-web-playwright-common': specifier: 'catalog:' - version: 2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) + version: 2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) '@element-hq/element-web-playwright-common-local': specifier: workspace:* version: link:../../packages/playwright-common @@ -764,7 +764,7 @@ importers: dependencies: '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 @@ -2042,8 +2042,8 @@ packages: '@element-hq/element-call-embedded@0.16.3': resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==} - '@element-hq/element-web-module-api@1.9.1': - resolution: {integrity: sha512-eCHHBkaDWc7Ai10b2VmOAkWIQAsur+YZ2kpFrPFVG41wMXn0PbFL+n6wvpbN+mU5Mg7uVIqXhQn4jflHESBUrA==} + '@element-hq/element-web-module-api@1.10.0': + resolution: {integrity: sha512-XIl6E73dn0cmR/03TRCpq7epyFQAa93GUz1j7EBP2pv5Erh59gq788ajFM2XYl7W2afRU0aasvJoI3iZIHbRig==} engines: {node: '>=20.0.0'} peerDependencies: '@matrix-org/react-sdk-module-api': '*' @@ -7910,8 +7910,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e4425570c7edab4cfb3d4478fdabd70ae86894d4: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e4425570c7edab4cfb3d4478fdabd70ae86894d4} + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5739b59faaf33fa55457dcaa76ffebe0506d1466: + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5739b59faaf33fa55457dcaa76ffebe0506d1466} version: 41.0.0 engines: {node: '>=22.0.0'} @@ -12106,7 +12106,7 @@ snapshots: '@element-hq/element-call-embedded@0.16.3': {} - '@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': + '@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': dependencies: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -12115,10 +12115,10 @@ snapshots: '@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) matrix-web-i18n: 3.6.0 - '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': + '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': dependencies: '@axe-core/playwright': 4.11.1(playwright-core@1.58.2) - '@element-hq/element-web-module-api': 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + '@element-hq/element-web-module-api': 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@playwright/test': 1.58.2 '@testcontainers/postgresql': 11.11.0 glob: 13.0.6 @@ -18920,7 +18920,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e4425570c7edab4cfb3d4478fdabd70ae86894d4: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5739b59faaf33fa55457dcaa76ffebe0506d1466: dependencies: '@babel/runtime': 7.28.6 '@matrix-org/matrix-sdk-crypto-wasm': 17.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eab37724305..7d36230dc00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,7 @@ catalog: "@element-hq/element-web-playwright-common": 2.2.7 "@playwright/test": 1.58.2 # Module API - "@element-hq/element-web-module-api": 1.9.1 + "@element-hq/element-web-module-api": 1.10.0 # Compound "@vector-im/compound-design-tokens": 6.9.0 "@vector-im/compound-web": 8.3.6 From f1459d0dedc8f10e79bd607c53f6119d8e7d9bad Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Feb 2026 17:03:40 +0000 Subject: [PATCH 5/7] lint --- apps/web/src/modules/WidgetLifecycleApi.ts | 1 - apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/modules/WidgetLifecycleApi.ts b/apps/web/src/modules/WidgetLifecycleApi.ts index 86d0dd510a2..c1463439cd1 100644 --- a/apps/web/src/modules/WidgetLifecycleApi.ts +++ b/apps/web/src/modules/WidgetLifecycleApi.ts @@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details. */ import type { Widget } from "matrix-widget-api"; - import type { CapabilitiesApprover, IdentityApprover, diff --git a/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts index 7cb6d2b1b67..f2ad2de7272 100644 --- a/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts +++ b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { WidgetDescriptor } from "@element-hq/element-web-module-api"; +import { Widget } from "matrix-widget-api"; +import type { WidgetDescriptor } from "@element-hq/element-web-module-api"; import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi"; -import { Widget } from "matrix-widget-api"; const mkDescriptor = (overrides: Partial = {}): WidgetDescriptor => ({ id: "test-widget", From 47f3ed44fb9b9e5fe3e6a9c410e0a86363c904d3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Feb 2026 19:24:55 +0000 Subject: [PATCH 6/7] Make preload checks easier to understand - Have single code path for preload checks. - Remove duplicated logic for preapproveIdentity check - Fix headers --- .../src/components/views/elements/AppTile.tsx | 104 ++++++++++++------ apps/web/src/modules/WidgetLifecycleApi.ts | 2 +- .../src/stores/widgets/ElementWidgetDriver.ts | 20 ++-- .../modules/WidgetLifecycleApi-test.ts | 2 +- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/views/elements/AppTile.tsx b/apps/web/src/components/views/elements/AppTile.tsx index 65ebf0ce3db..3442bac1c37 100644 --- a/apps/web/src/components/views/elements/AppTile.tsx +++ b/apps/web/src/components/views/elements/AppTile.tsx @@ -193,10 +193,21 @@ export default class AppTile extends React.Component { 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; @@ -209,6 +220,36 @@ export default class AppTile extends React.Component { 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 { + // 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, @@ -271,9 +312,9 @@ export default class AppTile extends React.Component { // 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, @@ -282,32 +323,26 @@ export default class AppTile extends React.Component { } private onAllowedWidgetsChange = (): void => { - const hasPermissionToLoad = this.hasPermissionToLoad(this.props); - - 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.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => { + if (this.unmounted) return; - this.setState({ hasPermissionToLoad }); - }; + 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(); + } - private checkPreloadApproval(): void { - const descriptor = toWidgetDescriptor(this.widget, this.props.room?.roomId); - ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor).then((approved) => { - if (approved && !this.unmounted) { - this.setState({ hasPermissionToLoad: true }); + if (!this.state.hasPermissionToLoad && hasPermissionToLoad) { this.startWidget(); } - }).catch((err) => { - logger.error("New API preload approval check failed", err); + + this.setState({ hasPermissionToLoad }); }); - } + }; private isMixedContent(): boolean { const parentContentProtocol = window.location.protocol; @@ -341,12 +376,19 @@ export default class AppTile extends React.Component { 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 if (!this.state.hasPermissionToLoad && this.props.room) { - // Check the module API for preload approval (async) - this.checkPreloadApproval(); + } else { + this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => { + if (this.unmounted) return; + this.setState({ hasPermissionToLoad }); + if (hasPermissionToLoad) { + this.startWidget(); + } + }); } this.watchUserReady(); diff --git a/apps/web/src/modules/WidgetLifecycleApi.ts b/apps/web/src/modules/WidgetLifecycleApi.ts index c1463439cd1..f20c3c2be0b 100644 --- a/apps/web/src/modules/WidgetLifecycleApi.ts +++ b/apps/web/src/modules/WidgetLifecycleApi.ts @@ -1,5 +1,5 @@ /* -Copyright 2026 New Vector Ltd. +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. diff --git a/apps/web/src/stores/widgets/ElementWidgetDriver.ts b/apps/web/src/stores/widgets/ElementWidgetDriver.ts index 62bfe15ad4c..ff9989c372f 100644 --- a/apps/web/src/stores/widgets/ElementWidgetDriver.ts +++ b/apps/web/src/stores/widgets/ElementWidgetDriver.ts @@ -658,20 +658,18 @@ export class ElementWidgetDriver extends WidgetDriver { public async askOpenID(observer: SimpleObservable): Promise { // Try the new module API first, then fall back to legacy path - const newApiApproved = await ModuleApi.instance.widgetLifecycle.preapproveIdentity( + let approved: boolean | undefined = await ModuleApi.instance.widgetLifecycle.preapproveIdentity( toWidgetDescriptor(this.forWidget, this.inRoomId), ); - if (newApiApproved) { - return observer.update({ - state: OpenIDRequestState.Allowed, - token: await MatrixClientPeg.safeGet().getOpenIdToken(), - }); - } - // Legacy module API fallback - const legacyOpts: ApprovalOpts = { approved: undefined }; - ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget); - if (legacyOpts.approved) { + 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(), diff --git a/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts index f2ad2de7272..d817c4c9d3d 100644 --- a/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts +++ b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2026 New Vector Ltd. +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. From 2b0caa4bea2b47410a2afbdf20ca89a4108d12ab Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Feb 2026 19:30:25 +0000 Subject: [PATCH 7/7] lint --- apps/web/src/stores/widgets/ElementWidgetDriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/stores/widgets/ElementWidgetDriver.ts b/apps/web/src/stores/widgets/ElementWidgetDriver.ts index ff9989c372f..af549acda4b 100644 --- a/apps/web/src/stores/widgets/ElementWidgetDriver.ts +++ b/apps/web/src/stores/widgets/ElementWidgetDriver.ts @@ -668,7 +668,7 @@ export class ElementWidgetDriver extends WidgetDriver { ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget); approved = legacyOpts.approved; } - + if (approved) { return observer.update({ state: OpenIDRequestState.Allowed,