diff --git a/jest.config.ts b/jest.config.ts index 459bcf5f081..3403ad6a0c2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -40,8 +40,6 @@ const config: Config = { "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", "^fetch-mock$": "/node_modules/fetch-mock", - // Requires ESM which is incompatible with our current Jest setup - "^@element-hq/element-web-module-api$": "/__mocks__/empty.js", }, transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"], collectCoverageFrom: [ diff --git a/package.json b/package.json index a4ef8e9c332..aea5948c8ea 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "1.3.0", + "@element-hq/element-web-module-api": "1.4.1", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 6939fec099a..b5c7e081542 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -31,6 +31,7 @@ import { UIFeature } from "../../../settings/UIFeature"; import { ModuleRunner } from "../../../modules/ModuleRunner"; import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg"; import Field from "../elements/Field"; +import ModuleApi from "../../../modules/Api.ts"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -116,7 +117,7 @@ interface IState { reason?: string; } -export default class RoomPreviewBar extends React.Component { +class RoomPreviewBar extends React.Component { public static defaultProps = { onJoinClick() {}, }; @@ -747,3 +748,21 @@ export default class RoomPreviewBar extends React.Component { ); } } + +const WrappedRoomPreviewBar = (props: IProps): JSX.Element => { + const moduleRenderer = ModuleApi.customComponents.roomPreviewBarRenderer; + if (moduleRenderer) { + return moduleRenderer( + { + ...props, + roomId: props.room?.roomId ?? props.roomId, + roomAlias: props.room?.getCanonicalAlias() ?? props.roomAlias, + }, + (props) => , + ); + } + + return ; +}; + +export default WrappedRoomPreviewBar; diff --git a/src/modules/Api.ts b/src/modules/Api.ts index db7dd803344..1f72784bd66 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -21,7 +21,11 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts"; import { ConfigApi } from "./ConfigApi.ts"; import { I18nApi } from "./I18nApi.ts"; -import { CustomComponentsApi } from "./customComponentApi.ts"; +import { CustomComponentsApi } from "./customComponentApi"; +import { WatchableProfile } from "./Profile.ts"; +import { NavigationApi } from "./Navigation.ts"; +import { openDialog } from "./Dialog.tsx"; +import { overwriteAccountAuth } from "./Auth.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -57,6 +61,11 @@ class ModuleApi implements Api { legacyCustomisationsFactory(WidgetVariableCustomisations); /* eslint-enable @typescript-eslint/naming-convention */ + public readonly navigation = new NavigationApi(); + public readonly openDialog = openDialog; + public readonly overwriteAccountAuth = overwriteAccountAuth; + public readonly profile = new WatchableProfile(); + public readonly config = new ConfigApi(); public readonly i18n = new I18nApi(); public readonly customComponents = new CustomComponentsApi(); diff --git a/src/modules/Auth.ts b/src/modules/Auth.ts new file mode 100644 index 00000000000..c48a6de9cbf --- /dev/null +++ b/src/modules/Auth.ts @@ -0,0 +1,43 @@ +/* +Copyright 2025 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 AccountAuthInfo } from "@element-hq/element-web-module-api"; +import { sleep } from "matrix-js-sdk/src/utils"; + +import type { OverwriteLoginPayload } from "../dispatcher/payloads/OverwriteLoginPayload.ts"; +import { Action } from "../dispatcher/actions.ts"; +import defaultDispatcher from "../dispatcher/dispatcher.ts"; +import type { ActionPayload } from "../dispatcher/payloads.ts"; + +export async function overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise { + const { promise, resolve } = Promise.withResolvers(); + + const onAction = (payload: ActionPayload): void => { + if (payload.action === Action.OnLoggedIn) { + // We want to wait for the new login to complete before returning. + // See `Action.OnLoggedIn` in dispatcher. + resolve(); + } + }; + const dispatcherRef = defaultDispatcher.register(onAction); + + defaultDispatcher.dispatch( + { + action: Action.OverwriteLogin, + credentials: { + ...accountInfo, + guest: false, + }, + }, + true, + ); // require to be sync to match inherited interface behaviour + + // wait for login to complete + await promise; + defaultDispatcher.unregister(dispatcherRef); + await sleep(0); // wait for the next tick to ensure the login is fully processed +} diff --git a/src/modules/Dialog.tsx b/src/modules/Dialog.tsx new file mode 100644 index 00000000000..97d2839f0f0 --- /dev/null +++ b/src/modules/Dialog.tsx @@ -0,0 +1,52 @@ +/* +Copyright 2025 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 React, { type ComponentType, type JSX, useCallback } from "react"; +import { type DialogProps, type DialogOptions, type DialogHandle } from "@element-hq/element-web-module-api"; + +import Modal from "../Modal"; +import BaseDialog from "../components/views/dialogs/BaseDialog.tsx"; + +const OuterDialog = ({ + title, + Dialog, + props, + onFinished, +}: { + title: string; + Dialog: ComponentType & P>; + props: P; + onFinished(ok: boolean, model: M | null): void; +}): JSX.Element => { + const close = useCallback(() => onFinished(false, null), [onFinished]); + const submit = useCallback((model: M) => onFinished(true, model), [onFinished]); + return ( + + + + ); +}; + +export function openDialog( + initialOptions: DialogOptions, + Dialog: ComponentType

>, + props: P, +): DialogHandle { + const { close, finished } = Modal.createDialog(OuterDialog, { + title: initialOptions.title, + Dialog, + props, + }); + + return { + finished: finished.then(([ok, model]) => ({ + ok: ok ?? false, + model: model ?? null, + })), + close: () => close(false, null), + }; +} diff --git a/src/modules/Navigation.ts b/src/modules/Navigation.ts new file mode 100644 index 00000000000..466cacff5df --- /dev/null +++ b/src/modules/Navigation.ts @@ -0,0 +1,43 @@ +/* +Copyright 2025 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 NavigationApi as INavigationApi } from "@element-hq/element-web-module-api"; + +import { navigateToPermalink } from "../utils/permalinks/navigator.ts"; +import { parsePermalink } from "../utils/permalinks/Permalinks.ts"; +import { getCachedRoomIDForAlias } from "../RoomAliasCache.ts"; +import { MatrixClientPeg } from "../MatrixClientPeg.ts"; +import dispatcher from "../dispatcher/dispatcher.ts"; +import { Action } from "../dispatcher/actions.ts"; +import SettingsStore from "../settings/SettingsStore.ts"; + +export class NavigationApi implements INavigationApi { + public async toMatrixToLink(link: string, join = false): Promise { + navigateToPermalink(link); + + const parts = parsePermalink(link); + if (parts?.roomIdOrAlias && join) { + let roomId: string | undefined = parts.roomIdOrAlias; + if (roomId.startsWith("#")) { + roomId = getCachedRoomIDForAlias(parts.roomIdOrAlias); + if (!roomId) { + // alias resolution failed + const result = await MatrixClientPeg.safeGet().getRoomIdForAlias(parts.roomIdOrAlias); + roomId = result.room_id; + } + } + + if (roomId) { + dispatcher.dispatch({ + action: Action.JoinRoom, + canAskToJoin: SettingsStore.getValue("feature_ask_to_join"), + roomId, + }); + } + } + } +} diff --git a/src/modules/Profile.ts b/src/modules/Profile.ts new file mode 100644 index 00000000000..4df3eb7a048 --- /dev/null +++ b/src/modules/Profile.ts @@ -0,0 +1,32 @@ +/* +Copyright 2025 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 Profile, Watchable } from "@element-hq/element-web-module-api"; + +import { OwnProfileStore } from "../stores/OwnProfileStore.ts"; +import { UPDATE_EVENT } from "../stores/AsyncStore.ts"; + +export class WatchableProfile extends Watchable { + public constructor() { + super({}); + this.value = this.profile; + + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileChange); + } + + private get profile(): Profile { + return { + isGuest: OwnProfileStore.instance.matrixClient?.isGuest() ?? false, + userId: OwnProfileStore.instance.matrixClient?.getUserId() ?? undefined, + displayName: OwnProfileStore.instance.displayName ?? undefined, + }; + } + + private readonly onProfileChange = (): void => { + this.value = this.profile; + }; +} diff --git a/src/modules/customComponentApi.ts b/src/modules/customComponentApi.ts index db2f9ab58ac..5121acc7eac 100644 --- a/src/modules/customComponentApi.ts +++ b/src/modules/customComponentApi.ts @@ -12,9 +12,10 @@ import type { CustomComponentsApi as ICustomComponentsApi, CustomMessageRenderFunction, CustomMessageComponentProps as ModuleCustomMessageComponentProps, - OriginalComponentProps, + OriginalMessageComponentProps, CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints, MatrixEvent as ModuleMatrixEvent, + CustomRoomPreviewBarRenderFunction, } from "@element-hq/element-web-module-api"; import type React from "react"; @@ -72,6 +73,7 @@ export class CustomComponentsApi implements ICustomComponentsApi { ): void { this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints }); } + /** * Select the correct renderer based on the event information. * @param mxEvent The message event being rendered. @@ -100,7 +102,7 @@ export class CustomComponentsApi implements ICustomComponentsApi { */ public renderMessage( props: CustomMessageComponentProps, - originalComponent?: (props?: OriginalComponentProps) => React.JSX.Element, + originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element, ): React.JSX.Element | null { const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent); const renderer = moduleEv && this.selectRenderer(moduleEv); @@ -134,4 +136,21 @@ export class CustomComponentsApi implements ICustomComponentsApi { } return null; } + + private _roomPreviewBarRenderer?: CustomRoomPreviewBarRenderFunction; + + /** + * Get the custom room preview bar renderer, if any has been registered. + */ + public get roomPreviewBarRenderer(): CustomRoomPreviewBarRenderFunction | undefined { + return this._roomPreviewBarRenderer; + } + + /** + * Register a custom room preview bar renderer. + * @param renderer - the function that will render the custom room preview bar. + */ + public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void { + this._roomPreviewBarRenderer = renderer; + } } diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 4e1b89d8e8c..e3102e773ca 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -510,8 +510,8 @@ export class RoomViewStore extends EventEmitter { }); // take a copy of roomAlias & roomId as they may change by the time the join is complete - const { roomAlias, roomId = payload.roomId } = this.state; - const address = roomAlias || roomId!; + const { roomAlias, roomId } = this.state; + const address = payload.roomId || roomAlias || roomId!; const joinOpts: IJoinRoomOpts = { viaServers: this.state.viaServers || [], @@ -520,7 +520,6 @@ export class RoomViewStore extends EventEmitter { if (SettingsStore.getValue("feature_share_history_on_invite")) { joinOpts.acceptSharedHistory = true; } - try { const cli = MatrixClientPeg.safeGet(); await retry( diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index c401853c2fe..e7b7e958627 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -53,6 +53,7 @@ import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidg jest.mock("../../../../src/stores/OwnProfileStore", () => ({ OwnProfileStore: { instance: { + on: jest.fn(), isProfileInfoFetched: true, removeListener: jest.fn(), getHttpAvatarUrl: jest.fn().mockReturnValue("http://avatar_url"), diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index 1a21bda494b..039cd09631b 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -43,6 +43,7 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal jest.mock("../../../../../src/stores/OwnProfileStore", () => ({ OwnProfileStore: { instance: { + on: jest.fn(), isProfileInfoFetched: true, removeListener: jest.fn(), getHttpAvatarUrl: jest.fn().mockReturnValue("http://avatar_url"), diff --git a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx index c347ac91a9e..3c917202d1f 100644 --- a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx +++ b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx @@ -48,6 +48,7 @@ jest.mock("../../../../../src/settings/SettingsStore", () => ({ jest.mock("../../../../../src/stores/OwnProfileStore", () => ({ OwnProfileStore: { instance: { + on: jest.fn(), displayName: "Ernie", getHttpAvatarUrl: jest.fn().mockReturnValue("image.com/img"), }, diff --git a/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx b/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx index fb6691af786..a0c07a5b3fe 100644 --- a/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomPreviewBar-test.tsx @@ -16,6 +16,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import RoomPreviewBar from "../../../../../src/components/views/rooms/RoomPreviewBar"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import ModuleApi from "../../../../../src/modules/Api.ts"; jest.mock("../../../../../src/IdentityAuthClient", () => { return jest.fn().mockImplementation(() => { @@ -497,4 +498,12 @@ describe("", () => { expect(onCancelAskToJoin).toHaveBeenCalled(); }); }); + + it("should render Module roomPreviewBarRenderer if specified", () => { + jest.spyOn(ModuleApi.customComponents, "roomPreviewBarRenderer", "get").mockReturnValue(() => ( + <>Test component + )); + const { getByText } = render(); + expect(getByText("Test component")).toBeTruthy(); + }); }); diff --git a/test/unit-tests/modules/Auth-test.ts b/test/unit-tests/modules/Auth-test.ts new file mode 100644 index 00000000000..b5d66010a61 --- /dev/null +++ b/test/unit-tests/modules/Auth-test.ts @@ -0,0 +1,30 @@ +/* +Copyright 2025 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 defaultDispatcher from "../../../src/dispatcher/dispatcher.ts"; +import { overwriteAccountAuth } from "../../../src/modules/Auth.ts"; + +describe("overwriteAccountAuth", () => { + it("should call overwrite login with accountInfo", () => { + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + + const accountInfo = { + userId: "@user:server.com", + deviceId: "DEVICEID", + accessToken: "TOKEN", + homeserverUrl: "https://server.com", + }; + overwriteAccountAuth(accountInfo); + expect(spy).toHaveBeenCalledWith( + { + action: "overwrite_login", + credentials: expect.objectContaining(accountInfo), + }, + true, + ); + }); +}); diff --git a/test/unit-tests/modules/Dialog-test.tsx b/test/unit-tests/modules/Dialog-test.tsx new file mode 100644 index 00000000000..e3f285a48c1 --- /dev/null +++ b/test/unit-tests/modules/Dialog-test.tsx @@ -0,0 +1,23 @@ +/* +Copyright 2025 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 React from "react"; +import { screen } from "jest-matrix-react"; + +import { openDialog } from "../../../src/modules/Dialog.tsx"; + +describe("openDialog", () => { + it("should open a dialog with the expected title", async () => { + const Dialog = () => <>Dialog Content; + + const title = "Test Dialog"; + openDialog({ title }, Dialog, {}); + + await expect(screen.findByText("Test Dialog")).resolves.toBeInTheDocument(); + expect(screen.getByText("Dialog Content")).toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/modules/Navigation-test.ts b/test/unit-tests/modules/Navigation-test.ts new file mode 100644 index 00000000000..691d8975fc4 --- /dev/null +++ b/test/unit-tests/modules/Navigation-test.ts @@ -0,0 +1,43 @@ +/* +Copyright 2025 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 { mocked } from "jest-mock"; + +import * as navigator from "../../../src/utils/permalinks/navigator"; +import { NavigationApi } from "../../../src/modules/Navigation.ts"; +import { stubClient } from "../../test-utils"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher.ts"; + +describe("NavigationApi", () => { + const api = new NavigationApi(); + + describe("toMatrixToLink", () => { + it("should call navigateToPermalink with the correct parameters", async () => { + const link = "https://matrix.to/#/!roomId:server.com"; + const spy = jest.spyOn(navigator, "navigateToPermalink"); + + await api.toMatrixToLink(link); + expect(spy).toHaveBeenCalledWith(link); + }); + + it("should resolve the room alias to a room id when join=true", async () => { + const cli = stubClient(); + mocked(cli.getRoomIdForAlias).mockResolvedValue({ room_id: "!roomId:server.com", servers: [] }); + + const link = "https://matrix.to/#/#alias:server.com"; + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + + await api.toMatrixToLink(link, true); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + action: "join_room", + roomId: "!roomId:server.com", + }), + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index e833706f6af..b004f10c773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1682,10 +1682,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.14.1.tgz#358c537e147ff3d48028cfb65d414cfe89ac1371" integrity sha512-1ODnohNvg7bgR8tg+rIF81MYGChNXVD96lBWkCI96ygjGg7U+HqqA8sY0YsRN5oJ9aLDQPicSr09XwLEXSPmjQ== -"@element-hq/element-web-module-api@1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5" - integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g== +"@element-hq/element-web-module-api@1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.4.1.tgz#a46526d58985190f9989bf1686ea872687d3c6e1" + integrity sha512-A8yaQtX7QoKThzzZVU+VYOFhpiNyppEMuIQijK48RvhVp1nwmy0cTD6u/6Yn64saNwJjtna+Oy+Qzo/TfwwhxQ== "@element-hq/element-web-playwright-common@^1.4.4": version "1.4.4"