From 42f8247c2ea952de0c5adcbdae355823bd790b4f Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 5 Nov 2025 07:24:26 +0000 Subject: [PATCH] Experimental Module API Additions (#30863) * Module API experiments * Move ResizerNotifier into SDKContext so we don't have to pass it into RoomView * Add the MultiRoomViewStore * Make RoomViewStore able to take a roomId prop * Different interface to add space panel items A bit less flexible but probably simpler and will help keep things actually consistent rather than just allowing modules to stick any JSX into the space panel (which means they also have to worry about styling if they *do* want it to be consistent). * Allow space panel items to be updated and manage which one is selected, allowing module "spaces" to be considered spaces * Remove fetchRoomFn from SpaceNotificationStore which didn't really seem to have any point as it was only called from one place * Switch to using module api via .instance * Fairly awful workaround to actually break the dependency nightmare * Add test for multiroomviewstore * add test * Make room names deterministic So the tests don't fail if you add other tests or run them individually * Add test for builtinsapi * Update module api * RVS is not needed as prop anymore Since it's passed through context * Add roomId to prop * Remove RoomViewStore from state This is now accessed through class field * Fix test * No need to pass RVS from LoggedInView * Add RoomContextType * Implement new builtins api * Add tests * Fix import * Fix circular dependency issue * Fix import * Add more tests * Improve comment * room-id is optional * Update license * Add implementation for AccountDataApi * Add implementation for Room * Add implementation for ClientApi * Create ClientApi in Api.ts * Write tests * Use nullish coalescing assignment * Implement openRoom in NavigationApi * Write tests * Add implementation for StoresApi * Write tests * Fix circular dependency * Add comments in lieu of type and fix else block * Change to class field --------- Co-authored-by: R Midhun Suresh --- package.json | 2 +- res/css/structures/_SpacePanel.pcss | 4 + src/PosthogTrackers.ts | 6 +- src/components/structures/LoggedInView.tsx | 41 ++++--- src/components/structures/MatrixChat.tsx | 11 +- src/components/structures/RoomView.tsx | 54 +++++---- src/components/views/spaces/SpacePanel.tsx | 25 +++++ .../views/spaces/SpaceTreeLevel.tsx | 5 +- src/contexts/RoomContext.ts | 12 +- src/contexts/SDKContext.ts | 9 ++ src/modules/AccountDataApi.ts | 54 +++++++++ src/modules/Api.ts | 8 ++ src/modules/BuiltinsApi.tsx | 75 +++++++++++++ src/modules/ClientApi.ts | 20 ++++ src/modules/ExtrasApi.ts | 50 +++++++++ src/modules/Navigation.ts | 45 ++++---- src/modules/StoresApi.ts | 106 ++++++++++++++++++ src/modules/models/Room.ts | 45 ++++++++ src/stores/MultiRoomViewStore.ts | 67 +++++++++++ src/stores/RoomViewStore.tsx | 8 +- .../notifications/SpaceNotificationState.ts | 6 +- src/stores/spaces/SpaceStore.ts | 17 +-- src/stores/spaces/index.ts | 12 +- src/vector/app.tsx | 7 ++ test/test-utils/room.ts | 6 +- test/test-utils/test-utils.ts | 1 + .../structures/MessagePanel-test.tsx | 7 +- .../components/structures/RoomView-test.tsx | 29 ++++- .../structures/ThreadPanel-test.tsx | 4 +- .../__snapshots__/RoomView-test.tsx.snap | 16 +-- .../audio_messages/RecordingPlayback-test.tsx | 8 +- .../views/beacon/RoomCallBanner-test.tsx | 7 +- .../context_menus/MessageContextMenu-test.tsx | 13 +-- .../views/messages/DateSeparator-test.tsx | 5 +- .../views/messages/MessageActionBar-test.tsx | 7 +- .../views/rooms/EditMessageComposer-test.tsx | 4 +- .../components/views/rooms/EventTile-test.tsx | 7 +- .../views/rooms/MessageComposer-test.tsx | 6 +- .../rooms/MessageComposerButtons-test.tsx | 4 +- .../views/rooms/NewRoomIntro-test.tsx | 4 +- .../rooms/RoomHeader/RoomHeader-test.tsx | 7 +- .../views/rooms/SendMessageComposer-test.tsx | 6 +- .../views/rooms/wysiwyg_composer/utils.ts | 4 +- .../unit-tests/modules/AccountDataApi-test.ts | 72 ++++++++++++ test/unit-tests/modules/BuiltinsApi-test.tsx | 53 +++++++++ test/unit-tests/modules/ClientApi-test.ts | 20 ++++ test/unit-tests/modules/Navigation-test.ts | 20 ++++ test/unit-tests/modules/StoresApi-test.ts | 84 ++++++++++++++ test/unit-tests/modules/models/Room-test.ts | 50 +++++++++ .../stores/MultiRoomViewStore-test.ts | 101 +++++++++++++++++ yarn.lock | 8 +- 51 files changed, 1088 insertions(+), 154 deletions(-) create mode 100644 src/modules/AccountDataApi.ts create mode 100644 src/modules/BuiltinsApi.tsx create mode 100644 src/modules/ClientApi.ts create mode 100644 src/modules/ExtrasApi.ts create mode 100644 src/modules/StoresApi.ts create mode 100644 src/modules/models/Room.ts create mode 100644 src/stores/MultiRoomViewStore.ts create mode 100644 test/unit-tests/modules/AccountDataApi-test.ts create mode 100644 test/unit-tests/modules/BuiltinsApi-test.tsx create mode 100644 test/unit-tests/modules/ClientApi-test.ts create mode 100644 test/unit-tests/modules/StoresApi-test.ts create mode 100644 test/unit-tests/modules/models/Room-test.ts create mode 100644 test/unit-tests/stores/MultiRoomViewStore-test.ts diff --git a/package.json b/package.json index e0306a1b5c..cffdcdab40 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "1.4.1", + "@element-hq/element-web-module-api": "1.5.0", "@element-hq/web-shared-components": "file:packages/shared-components", "@fontsource/inconsolata": "^5", "@fontsource/inter": "^5", diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 64044c4c5c..1aea3ae23b 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -211,6 +211,10 @@ Please see LICENSE files in the repository root for full details. } } + &.mx_SpaceButton_withIcon .mx_SpaceButton_icon { + background-color: $panel-actions; + } + &.mx_SpaceButton_home .mx_SpaceButton_icon::before { mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); } diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 5eddeecb2a..ae67b5f378 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -31,7 +31,7 @@ const notLoggedInMap: Record, ScreenName> = { [Views.LOCK_STOLEN]: "SessionLockStolen", }; -const loggedInPageTypeMap: Record = { +const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", [PageType.UserView]: "User", @@ -48,10 +48,10 @@ export default class PosthogTrackers { } private view: Views = Views.LOADING; - private pageType?: PageType; + private pageType?: PageType | string; private override?: ScreenName; - public trackPageChange(view: Views, pageType: PageType | undefined, durationMs: number): void { + public trackPageChange(view: Views, pageType: PageType | string | undefined, durationMs: number): void { this.view = view; this.pageType = pageType; if (this.override) return; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c6afb98a3e..8626944895 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -68,7 +68,8 @@ import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushR import { type ConfigOptions } from "../../SdkConfig"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; -import { SDKContext, SdkContextClass } from "../../contexts/SDKContext.ts"; +import { ModuleApi } from "../../modules/Api.ts"; +import { SDKContext } from "../../contexts/SDKContext.ts"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -679,6 +680,10 @@ class LoggedInView extends React.Component { public render(): React.ReactNode { let pageElement; + const moduleRenderer = this.props.page_type + ? ModuleApi.instance.navigation.locationRenderers.get(this.props.page_type) + : undefined; + switch (this.props.page_type) { case PageTypes.RoomView: pageElement = ( @@ -690,7 +695,6 @@ class LoggedInView extends React.Component { key={this.props.currentRoomId || "roomview"} justCreatedOpts={this.props.roomJustCreatedOpts} forceTimeline={this.props.forceTimeline} - roomViewStore={SdkContextClass.instance.roomViewStore} /> ); break; @@ -706,6 +710,13 @@ class LoggedInView extends React.Component { ); } break; + default: { + if (moduleRenderer) { + pageElement = moduleRenderer(); + } else { + console.warn(`Couldn't render page type "${this.props.page_type}"`); + } + } } const wrapperClasses = classNames({ @@ -747,20 +758,22 @@ class LoggedInView extends React.Component { )} {!useNewRoomList && } -
- -
+ {!moduleRenderer && ( +
+ +
+ )} - + {!moduleRenderer && }
{pageElement}
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a4df7a5fe9..0365b4b583 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -140,6 +140,7 @@ import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/ShareP import Markdown from "../../Markdown"; import { sanitizeHtmlParams } from "../../Linkify"; import { isOnlyAdmin } from "../../utils/membership"; +import { ModuleApi } from "../../modules/Api.ts"; // legacy export export { default as Views } from "../../Views"; @@ -175,9 +176,11 @@ interface IProps { interface IState { // the master view we are showing. view: Views; - // What the LoggedInView would be showing if visible + // What the LoggedInView would be showing if visible. + // A member of the enum for standard pages or a string for those provided by + // a module. // eslint-disable-next-line camelcase - page_type?: PageType; + page_type?: PageType | string; // The ID of the room we're viewing. This is either populated directly // in the case where we view a room by ID or by RoomView when it resolves // what ID an alias points at. @@ -1921,8 +1924,8 @@ export default class MatrixChat extends React.PureComponent { userId: userId, subAction: params?.action, }); - } else { - logger.info(`Ignoring showScreen for '${screen}'`); + } else if (ModuleApi.instance.navigation.locationRenderers.get(screen)) { + this.setState({ page_type: screen }); } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index d73b96a30d..ac3c01f215 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -44,6 +44,7 @@ import { type CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { debounce, throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; +import { type RoomViewProps } from "@element-hq/element-web-module-api"; import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -148,7 +149,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps { +interface IRoomProps extends RoomViewProps { threepidInvite?: IThreepidInvite; oobData?: IOOBData; @@ -158,19 +159,17 @@ interface IRoomProps { // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; + /** - * The RoomViewStore instance for the room to be displayed. + * Only necessary if RoomView should get it's RoomViewStore through the MultiRoomViewStore. + * Omitting this will mean that RoomView renders for the room held in SDKContext.RoomViewStore. */ - roomViewStore: RoomViewStore; + roomId?: string; } export { MainSplitContentType }; export interface IRoomState { - /** - * The RoomViewStore instance for the room we are displaying - */ - roomViewStore: RoomViewStore; room?: Room; roomId?: string; roomAlias?: string; @@ -389,6 +388,8 @@ export class RoomView extends React.Component { private messagePanel: TimelinePanel | null = null; private roomViewBody = createRef(); + private roomViewStore: RoomViewStore; + public static contextType = SDKContext; declare public context: React.ContextType; @@ -401,9 +402,14 @@ export class RoomView extends React.Component { throw new Error("Unable to create RoomView without MatrixClient"); } + if (props.roomId) { + this.roomViewStore = this.context.multiRoomViewStore.getRoomViewStoreForRoom(props.roomId); + } else { + this.roomViewStore = context.roomViewStore; + } + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { - roomViewStore: props.roomViewStore, roomId: undefined, roomLoading: true, peekLoading: false, @@ -535,7 +541,7 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room): MainSplitContentType => { - if (this.state.roomViewStore.isViewingCall() || isVideoRoom(room)) { + if (this.roomViewStore.isViewingCall() || isVideoRoom(room)) { return MainSplitContentType.Call; } if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { @@ -549,8 +555,8 @@ export class RoomView extends React.Component { return; } - const roomLoadError = this.state.roomViewStore.getRoomLoadError() ?? undefined; - if (!initial && !roomLoadError && this.state.roomId !== this.state.roomViewStore.getRoomId()) { + const roomLoadError = this.roomViewStore.getRoomLoadError() ?? undefined; + if (!initial && !roomLoadError && this.state.roomId !== this.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -564,7 +570,7 @@ export class RoomView extends React.Component { // it was, it means we're about to be unmounted. return; } - const roomViewStore = this.state.roomViewStore; + const roomViewStore = this.roomViewStore; const roomId = roomViewStore.getRoomId() ?? null; const roomAlias = roomViewStore.getRoomAlias() ?? undefined; const roomLoading = roomViewStore.isRoomLoading(); @@ -611,7 +617,7 @@ export class RoomView extends React.Component { newState.showRightPanel = false; } - const initialEventId = this.state.roomViewStore.getInitialEventId() ?? this.state.initialEventId; + const initialEventId = this.roomViewStore.getInitialEventId() ?? this.state.initialEventId; if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -637,13 +643,13 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: this.state.roomViewStore.isInitialEventHighlighted(), - scroll_into_view: this.state.roomViewStore.initialEventScrollIntoView(), + highlighted: this.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = this.state.roomViewStore.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = this.state.roomViewStore.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.roomViewStore.initialEventScrollIntoView(); } } @@ -903,7 +909,7 @@ export class RoomView extends React.Component { this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); } // Start listening for RoomViewStore updates - this.state.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); @@ -1020,7 +1026,7 @@ export class RoomView extends React.Component { window.removeEventListener("beforeunload", this.onPageUnload); - this.state.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); @@ -1048,6 +1054,8 @@ export class RoomView extends React.Component { // clean up if this was a local room this.context.client?.store.removeRoom(this.state.room.roomId); } + + if (this.props.roomId) this.context.multiRoomViewStore.removeRoomViewStore(this.props.roomId); } private onRightPanelStoreUpdate = (): void => { @@ -2070,7 +2078,7 @@ export class RoomView extends React.Component { if (!this.state.room || !this.context?.client) return null; const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId()); return ( - + { private renderLocalRoomView(localRoom: LocalRoom): ReactNode { return ( - + { private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode { return ( - + { } return ( - +
{showChatEffects && this.roomView.current && ( diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 984b33bb12..c0d8faedfb 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -68,6 +68,8 @@ import { ThreadsActivityCentre } from "./threads-activity-centre/"; import AccessibleButton from "../elements/AccessibleButton"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; import { KeyboardShortcut } from "../settings/KeyboardShortcut"; +import { ModuleApi } from "../../../modules/Api.ts"; +import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { @@ -290,6 +292,8 @@ const InnerSpacePanel = React.memo( const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; + const moduleSpaceItems = useModuleSpacePanelItems(ModuleApi.instance.extras); + const metaSpacesSection = metaSpaces .filter((key) => !(key === MetaSpace.VideoRooms && !SettingsStore.getValue("feature_video_rooms"))) .map((key) => { @@ -341,6 +345,27 @@ const InnerSpacePanel = React.memo( ))} {children} + {moduleSpaceItems.map((item) => ( +
  • + { + SpaceStore.instance.setActiveSpace(item.spaceKey); + item.onSelected?.(); + }} + /> +
  • + ))} {shouldShowComponent(UIComponent.CreateSpaces) && ( )} diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index d03ac8a1e2..f2c41c367f 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -52,6 +52,7 @@ type ButtonProps = Omit< className?: string; selected?: boolean; label: string; + icon?: JSX.Element; contextMenuTooltip?: string; notificationState?: NotificationState; isNarrow?: boolean; @@ -65,6 +66,7 @@ export const SpaceButton = ({ space, spaceKey: _spaceKey, className, + icon, selected, label, contextMenuTooltip, @@ -84,7 +86,7 @@ export const SpaceButton = ({ let avatar = (
    -
    +
    {icon}
    ); if (space) { @@ -143,6 +145,7 @@ export const SpaceButton = ({ mx_SpaceButton_active: selected, mx_SpaceButton_hasMenuOpen: menuDisplayed, mx_SpaceButton_narrow: isNarrow, + mx_SpaceButton_withIcon: Boolean(icon), })} aria-label={label} title={!isNarrow || menuDisplayed ? undefined : label} diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 566e1e5d19..c4a4d45c5d 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -10,6 +10,7 @@ import { createContext } from "react"; import { type IRoomState } from "../components/structures/RoomView"; import { Layout } from "../settings/enums/Layout"; +import { type RoomViewStore } from "../stores/RoomViewStore"; export enum TimelineRenderingType { Room = "Room", @@ -29,11 +30,12 @@ export enum MainSplitContentType { Call, } -const RoomContext = createContext< - IRoomState & { - threadId?: string; - } ->({ +export interface RoomContextType extends IRoomState { + threadId?: string; + roomViewStore: RoomViewStore; +} + +const RoomContext = createContext({ roomLoading: true, peekLoading: false, shouldPeek: true, diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts index 4e7aa9e94f..bb59610771 100644 --- a/src/contexts/SDKContext.ts +++ b/src/contexts/SDKContext.ts @@ -25,6 +25,7 @@ import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore"; import { OidcClientStore } from "../stores/oidc/OidcClientStore"; import WidgetStore from "../stores/WidgetStore"; import ResizeNotifier from "../utils/ResizeNotifier"; +import { MultiRoomViewStore } from "../stores/MultiRoomViewStore"; // This context is available to components under MatrixChat, // the context must not be used by components outside a SdkContextClass tree. @@ -66,6 +67,7 @@ export class SdkContextClass { protected _UserProfilesStore?: UserProfilesStore; protected _OidcClientStore?: OidcClientStore; protected _ResizeNotifier?: ResizeNotifier; + protected _MultiRoomViewStore?: MultiRoomViewStore; /** * Automatically construct stores which need to be created eagerly so they can register with @@ -183,6 +185,13 @@ export class SdkContextClass { return this._ResizeNotifier; } + public get multiRoomViewStore(): MultiRoomViewStore { + if (!this._MultiRoomViewStore) { + this._MultiRoomViewStore = new MultiRoomViewStore(defaultDispatcher, this); + } + return this._MultiRoomViewStore; + } + public onLoggedOut(): void { this._UserProfilesStore = undefined; } diff --git a/src/modules/AccountDataApi.ts b/src/modules/AccountDataApi.ts new file mode 100644 index 0000000000..cf1dba513d --- /dev/null +++ b/src/modules/AccountDataApi.ts @@ -0,0 +1,54 @@ +/* +Copyright 2025 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 { Watchable, type AccountDataApi as IAccountDataApi } from "@element-hq/element-web-module-api"; +import { ClientEvent, type MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; + +export class AccountDataApi implements IAccountDataApi { + public get(eventType: string): Watchable { + const cli = MatrixClientPeg.safeGet(); + return new AccountDataWatchable(cli, eventType); + } + + public async set(eventType: string, content: any): Promise { + const cli = MatrixClientPeg.safeGet(); + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + await cli.setAccountData(eventType, content); + } + + public async delete(eventType: string): Promise { + const cli = MatrixClientPeg.safeGet(); + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + await cli.deleteAccountData(eventType); + } +} + +class AccountDataWatchable extends Watchable { + public constructor( + private cli: MatrixClient, + private eventType: string, + ) { + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + super(cli.getAccountData(eventType)?.getContent()); + } + + private onAccountData = (event: MatrixEvent): void => { + if (event.getType() === this.eventType) { + this.value = event.getContent(); + } + }; + + protected onFirstWatch(): void { + this.cli.on(ClientEvent.AccountData, this.onAccountData); + } + + protected onLastWatch(): void { + this.cli.off(ClientEvent.AccountData, this.onAccountData); + } +} diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 49082daef2..2ff85c968f 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -26,6 +26,10 @@ import { WatchableProfile } from "./Profile.ts"; import { NavigationApi } from "./Navigation.ts"; import { openDialog } from "./Dialog.tsx"; import { overwriteAccountAuth } from "./Auth.ts"; +import { ElementWebExtrasApi } from "./ExtrasApi.ts"; +import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx"; +import { ClientApi } from "./ClientApi.ts"; +import { StoresApi } from "./StoresApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -79,7 +83,11 @@ export class ModuleApi implements Api { public readonly config = new ConfigApi(); public readonly i18n = new I18nApi(); public readonly customComponents = new CustomComponentsApi(); + public readonly extras = new ElementWebExtrasApi(); + public readonly builtins = new ElementWebBuiltinsApi(); public readonly rootNode = document.getElementById("matrixchat")!; + public readonly client = new ClientApi(); + public readonly stores = new StoresApi(); public createRoot(element: Element): Root { return createRoot(element); diff --git a/src/modules/BuiltinsApi.tsx b/src/modules/BuiltinsApi.tsx new file mode 100644 index 0000000000..b1225f56cb --- /dev/null +++ b/src/modules/BuiltinsApi.tsx @@ -0,0 +1,75 @@ +/* +Copyright 2025 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 React from "react"; +import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; +import type { Room } from "matrix-js-sdk/src/matrix"; + +interface RoomViewPropsWithRoomId extends RoomViewProps { + roomId?: string; +} + +interface RoomAvatarProps { + room: Room; + size?: string; +} + +interface Components { + roomView: React.ComponentType; + roomAvatar: React.ComponentType; +} + +export class ElementWebBuiltinsApi implements BuiltinsApi { + private _roomView?: React.ComponentType; + private _roomAvatar?: React.ComponentType; + + /** + * Sets the components used by the API. + * + * This only really exists here because referencing these components directly causes a nightmare of + * circular dependencies that break the whole app, so instead we avoid referencing it here + * and pass it in from somewhere it's already referenced (see related comment in app.tsx). + * + * @param component The components used by the api, see {@link Components} + */ + public setComponents(components: Components): void { + this._roomView = components.roomView; + this._roomAvatar = components.roomAvatar; + } + + public getRoomViewComponent(): React.ComponentType { + if (!this._roomView) { + throw new Error("No RoomView component has been set"); + } + + return this._roomView; + } + + public getRoomAvatarComponent(): React.ComponentType { + if (!this._roomAvatar) { + throw new Error("No RoomAvatar component has been set"); + } + + return this._roomAvatar; + } + + public renderRoomView(roomId: string): React.ReactNode { + const Component = this.getRoomViewComponent(); + return ; + } + + public renderRoomAvatar(roomId: string, size?: string): React.ReactNode { + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (!room) { + throw new Error(`No room such room: ${roomId}`); + } + const Component = this.getRoomAvatarComponent(); + return ; + } +} diff --git a/src/modules/ClientApi.ts b/src/modules/ClientApi.ts new file mode 100644 index 0000000000..7b5ccd2828 --- /dev/null +++ b/src/modules/ClientApi.ts @@ -0,0 +1,20 @@ +/* +Copyright 2025 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 { ClientApi as IClientApi, Room } from "@element-hq/element-web-module-api"; +import { Room as ModuleRoom } from "./models/Room"; +import { AccountDataApi } from "./AccountDataApi"; +import { MatrixClientPeg } from "../MatrixClientPeg"; + +export class ClientApi implements IClientApi { + public readonly accountData = new AccountDataApi(); + + public getRoom(roomId: string): Room | null { + const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId); + if (sdkRoom) return new ModuleRoom(sdkRoom); + return null; + } +} diff --git a/src/modules/ExtrasApi.ts b/src/modules/ExtrasApi.ts new file mode 100644 index 0000000000..0119c29cf4 --- /dev/null +++ b/src/modules/ExtrasApi.ts @@ -0,0 +1,50 @@ +/* +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 { useState } from "react"; +import { type SpacePanelItemProps, type ExtrasApi } from "@element-hq/element-web-module-api"; +import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; + +import { useTypedEventEmitter } from "../hooks/useEventEmitter"; + +export interface ModuleSpacePanelItem extends SpacePanelItemProps { + spaceKey: string; +} + +enum ExtrasApiEvent { + SpacePanelItemsChanged = "SpacePanelItemsChanged", +} + +interface EmittedEvents { + [ExtrasApiEvent.SpacePanelItemsChanged]: () => void; +} + +export class ElementWebExtrasApi extends TypedEventEmitter implements ExtrasApi { + public spacePanelItems = new Map(); + + public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void { + this.spacePanelItems.set(spacekey, item); + this.emit(ExtrasApiEvent.SpacePanelItemsChanged); + } +} + +export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] { + const getItems = (): ModuleSpacePanelItem[] => { + return Array.from(api.spacePanelItems.entries()).map(([spaceKey, item]) => ({ + spaceKey, + ...item, + })); + }; + + const [items, setItems] = useState(getItems); + + useTypedEventEmitter(api, ExtrasApiEvent.SpacePanelItemsChanged, () => { + setItems(getItems()); + }); + + return items; +} diff --git a/src/modules/Navigation.ts b/src/modules/Navigation.ts index 0e7724727d..fff87156b6 100644 --- a/src/modules/Navigation.ts +++ b/src/modules/Navigation.ts @@ -5,8 +5,11 @@ 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 NavigationApi as INavigationApi } from "@element-hq/element-web-module-api"; - +import type { + LocationRenderFunction, + NavigationApi as INavigationApi, + OpenRoomOptions, +} from "@element-hq/element-web-module-api"; import { navigateToPermalink } from "../utils/permalinks/navigator.ts"; import { parsePermalink } from "../utils/permalinks/Permalinks.ts"; import dispatcher from "../dispatcher/dispatcher.ts"; @@ -14,28 +17,32 @@ import { Action } from "../dispatcher/actions.ts"; import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts"; export class NavigationApi implements INavigationApi { + public locationRenderers = new Map(); + public async toMatrixToLink(link: string, join = false): Promise { navigateToPermalink(link); const parts = parsePermalink(link); if (parts?.roomIdOrAlias) { - if (parts.roomIdOrAlias.startsWith("#")) { - dispatcher.dispatch({ - action: Action.ViewRoom, - room_alias: parts.roomIdOrAlias, - via_servers: parts.viaServers ?? undefined, - auto_join: join, - metricsTrigger: undefined, - }); - } else { - dispatcher.dispatch({ - action: Action.ViewRoom, - room_id: parts.roomIdOrAlias, - via_servers: parts.viaServers ?? undefined, - auto_join: join, - metricsTrigger: undefined, - }); - } + this.openRoom(parts.roomIdOrAlias, { + viaServers: parts.viaServers ?? undefined, + autoJoin: join, + }); } } + + public registerLocationRenderer(path: string, renderer: LocationRenderFunction): void { + this.locationRenderers.set(path, renderer); + } + + public openRoom(roomIdOrAlias: string, opts: OpenRoomOptions = {}): void { + const key = roomIdOrAlias.startsWith("#") ? "room_alias" : "room_id"; + dispatcher.dispatch({ + action: Action.ViewRoom, + [key]: roomIdOrAlias, + via_servers: opts.viaServers, + auto_join: opts.autoJoin, + metricsTrigger: undefined, + }); + } } diff --git a/src/modules/StoresApi.ts b/src/modules/StoresApi.ts new file mode 100644 index 0000000000..f1d6add95e --- /dev/null +++ b/src/modules/StoresApi.ts @@ -0,0 +1,106 @@ +/* +Copyright 2025 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 StoresApi as IStoresApi, + type RoomListStoreApi as IRoomListStore, + type Room, + Watchable, +} from "@element-hq/element-web-module-api"; + +import type { RoomListStoreV3Class, RoomListStoreV3Event } from "../stores/room-list-v3/RoomListStoreV3"; +import { Room as ModuleRoom } from "./models/Room"; + +interface RlsEvents { + LISTS_LOADED_EVENT: RoomListStoreV3Event.ListsLoaded; + LISTS_UPDATE_EVENT: RoomListStoreV3Event.ListsUpdate; +} + +export class RoomListStoreApi implements IRoomListStore { + private rls?: RoomListStoreV3Class; + private LISTS_LOADED_EVENT?: RoomListStoreV3Event.ListsLoaded; + private LISTS_UPDATE_EVENT?: RoomListStoreV3Event.ListsUpdate; + public readonly moduleLoadPromise: Promise; + + public constructor() { + this.moduleLoadPromise = this.init(); + } + + /** + * Load the RLS through a dynamic import. This is necessary to prevent + * circular dependency issues. + */ + private async init(): Promise { + const module = await import("../stores/room-list-v3/RoomListStoreV3"); + this.rls = module.default.instance; + this.LISTS_LOADED_EVENT = module.LISTS_LOADED_EVENT; + this.LISTS_UPDATE_EVENT = module.LISTS_UPDATE_EVENT; + } + + public getRooms(): RoomsWatchable { + return new RoomsWatchable(this.roomListStore, this.events); + } + + private get events(): RlsEvents { + if (!this.LISTS_LOADED_EVENT || !this.LISTS_UPDATE_EVENT) { + throw new Error("Event type was not loaded correctly, did you forget to await waitForReady()?"); + } + return { LISTS_LOADED_EVENT: this.LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT: this.LISTS_UPDATE_EVENT }; + } + + private get roomListStore(): RoomListStoreV3Class { + if (!this.rls) { + throw new Error("rls is undefined, did you forget to await waitForReady()?"); + } + return this.rls; + } + + public async waitForReady(): Promise { + // Wait for the module to load first + await this.moduleLoadPromise; + + // Check if RLS is already loaded + if (!this.roomListStore.isLoadingRooms) return; + + // Await a promise that resolves when RLS has loaded + const { promise, resolve } = Promise.withResolvers(); + const { LISTS_LOADED_EVENT } = this.events; + this.roomListStore.once(LISTS_LOADED_EVENT, resolve); + await promise; + } +} + +class RoomsWatchable extends Watchable { + public constructor( + private readonly rls: RoomListStoreV3Class, + private readonly events: RlsEvents, + ) { + super(rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom))); + } + + private onRlsUpdate = (): void => { + this.value = this.rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)); + }; + + protected onFirstWatch(): void { + this.rls.on(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate); + } + + protected onLastWatch(): void { + this.rls.off(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate); + } +} + +export class StoresApi implements IStoresApi { + private roomListStoreApi?: IRoomListStore; + + public get roomListStore(): IRoomListStore { + if (!this.roomListStoreApi) { + this.roomListStoreApi = new RoomListStoreApi(); + } + return this.roomListStoreApi; + } +} diff --git a/src/modules/models/Room.ts b/src/modules/models/Room.ts new file mode 100644 index 0000000000..e317c0dc03 --- /dev/null +++ b/src/modules/models/Room.ts @@ -0,0 +1,45 @@ +/* +Copyright 2025 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 Room as IRoom, Watchable } from "@element-hq/element-web-module-api"; +import { RoomEvent, type Room as SdkRoom } from "matrix-js-sdk/src/matrix"; + +export class Room implements IRoom { + public name: Watchable; + + public constructor(private sdkRoom: SdkRoom) { + this.name = new WatchableName(sdkRoom); + } + + public getLastActiveTimestamp(): number { + return this.sdkRoom.getLastActiveTimestamp(); + } + + public get id(): string { + return this.sdkRoom.roomId; + } +} + +/** + * A custom watchable for room name. + */ +class WatchableName extends Watchable { + public constructor(private sdkRoom: SdkRoom) { + super(sdkRoom.name); + } + + private onNameUpdate = (): void => { + super.value = this.sdkRoom.name; + }; + protected onFirstWatch(): void { + this.sdkRoom.on(RoomEvent.Name, this.onNameUpdate); + } + + protected onLastWatch(): void { + this.sdkRoom.off(RoomEvent.Name, this.onNameUpdate); + } +} diff --git a/src/stores/MultiRoomViewStore.ts b/src/stores/MultiRoomViewStore.ts new file mode 100644 index 0000000000..bc2f090e6f --- /dev/null +++ b/src/stores/MultiRoomViewStore.ts @@ -0,0 +1,67 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; + +import { RoomViewStore } from "./RoomViewStore"; +import { type MatrixDispatcher } from "../dispatcher/dispatcher"; +import { type SdkContextClass } from "../contexts/SDKContext"; +import { Action } from "../dispatcher/actions"; + +/** + * Acts as a cache of many RoomViewStore instances, creating them as necessary + * given a room ID. + */ +export class MultiRoomViewStore { + /** + * Map from room-id to RVS instance. + */ + private stores: Map = new Map(); + + public constructor( + private dispatcher: MatrixDispatcher, + private sdkContextClass: SdkContextClass, + ) {} + + /** + * Get a RVS instance for the room identified by the given roomId. + */ + public getRoomViewStoreForRoom(roomId: string): RoomViewStore { + // Get existing store / create new store + const store = this.stores.has(roomId) + ? this.stores.get(roomId)! + : new RoomViewStore(this.dispatcher, this.sdkContextClass, roomId); + + // RoomView component does not render the room unless you call viewRoom + store.viewRoom({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + }); + + // Cache the store, okay to do even if the store is already in the map + this.stores.set(roomId, store); + + return store; + } + + /** + * Remove a RVS instance that was created by {@link getRoomViewStoreForRoom}. + */ + public removeRoomViewStore(roomId: string): void { + const didRemove = this.stores.delete(roomId); + if (!didRemove) { + logger.warn(`removeRoomViewStore called with ${roomId} but no store exists for this room.`); + } + } + + public dispose(): void { + for (const id of this.stores.keys()) { + this.removeRoomViewStore(id); + } + } +} diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 418a7a7d4f..ee7c2ad9bc 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -153,6 +153,7 @@ export class RoomViewStore extends EventEmitter { public constructor( dis: MatrixDispatcher, private readonly stores: SdkContextClass, + private readonly lockedToRoomId?: string, ) { super(); this.resetDispatcher(dis); @@ -187,7 +188,7 @@ export class RoomViewStore extends EventEmitter { const lastRoomId = this.state.roomId; this.state = Object.assign(this.state, newState); - if (lastRoomId !== this.state.roomId) { + if (!this.lockedToRoomId && lastRoomId !== this.state.roomId) { if (lastRoomId) this.emitForRoom(lastRoomId, false); if (this.state.roomId) this.emitForRoom(this.state.roomId, true); @@ -204,6 +205,9 @@ export class RoomViewStore extends EventEmitter { } private onDispatch(payload: ActionPayload): void { + if (this.lockedToRoomId && payload.room_id && this.lockedToRoomId !== payload.room_id) { + return; + } // eslint-disable-line @typescript-eslint/naming-convention switch (payload.action) { // view_room: @@ -324,7 +328,7 @@ export class RoomViewStore extends EventEmitter { } } - private async viewRoom(payload: ViewRoomPayload): Promise { + public async viewRoom(payload: ViewRoomPayload): Promise { if (payload.room_id) { const room = MatrixClientPeg.safeGet().getRoom(payload.room_id); diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts index 4ea24b7e10..0faf8bc3e6 100644 --- a/src/stores/notifications/SpaceNotificationState.ts +++ b/src/stores/notifications/SpaceNotificationState.ts @@ -12,15 +12,15 @@ import { NotificationLevel } from "./NotificationLevel"; import { arrayDiff } from "../../utils/arrays"; import { type RoomNotificationState } from "./RoomNotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState"; -import { type FetchRoomFn } from "./ListNotificationState"; import { DefaultTagID } from "../room-list/models"; import RoomListStore from "../room-list/RoomListStore"; +import { RoomNotificationStateStore } from "./RoomNotificationStateStore"; export class SpaceNotificationState extends NotificationState { public rooms: Room[] = []; // exposed only for tests private states: { [spaceId: string]: RoomNotificationState } = {}; - public constructor(private getRoomFn: FetchRoomFn) { + public constructor() { super(); } @@ -39,7 +39,7 @@ export class SpaceNotificationState extends NotificationState { state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); } for (const newRoom of diff.added) { - const state = this.getRoomFn(newRoom); + const state = RoomNotificationStateStore.instance.getRoomState(newRoom); state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); this.states[newRoom.roomId] = state; } diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index bfb98f6b02..c0b9a92159 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -27,7 +27,6 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import RoomListStore from "../room-list/RoomListStore"; import SettingsStore from "../../settings/SettingsStore"; import DMRoomMap from "../../utils/DMRoomMap"; -import { type FetchRoomFn } from "../notifications/ListNotificationState"; import { SpaceNotificationState } from "../notifications/SpaceNotificationState"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; import { DefaultTagID } from "../room-list/models"; @@ -63,6 +62,7 @@ import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePage import { type SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { ModuleApi } from "../../modules/Api.ts"; const ACTIVE_SPACE_LS_KEY = "mx_active_space"; @@ -111,10 +111,6 @@ export const getChildOrder = ( return [validOrder(order) ?? NaN, ts, roomId]; // NaN has lodash sort it at the end in asc }; -const getRoomFn: FetchRoomFn = (room: Room) => { - return RoomNotificationStateStore.instance.getRoomState(room); -}; - type SpaceStoreActions = | SettingUpdatedPayload | ViewRoomPayload @@ -258,7 +254,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!space || !this.matrixClient || space === this.activeSpace) return; let cliSpace: Room | null = null; - if (!isMetaSpace(space)) { + if (ModuleApi.instance.extras.spacePanelItems.has(space)) { + // it's a "space" provided by a module: that's good enough + } else if (!isMetaSpace(space)) { cliSpace = this.matrixClient.getRoom(space); if (!cliSpace?.isSpaceRoom()) return; } else if (!this.enabledMetaSpaces.includes(space)) { @@ -293,6 +291,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { context_switch: true, metricsTrigger: "WebSpaceContextSwitch", }); + } else if (ModuleApi.instance.extras.spacePanelItems.has(space)) { + // module will handle this } else { defaultDispatcher.dispatch({ action: Action.ViewHomePage, @@ -1214,7 +1214,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY) as MetaSpace; const valid = lastSpaceId && - (!isMetaSpace(lastSpaceId) ? this.matrixClient.getRoom(lastSpaceId) : enabledMetaSpaces[lastSpaceId]); + (ModuleApi.instance.extras.spacePanelItems.has(lastSpaceId) || + (!isMetaSpace(lastSpaceId) ? this.matrixClient.getRoom(lastSpaceId) : enabledMetaSpaces[lastSpaceId])); if (valid) { // don't context switch here as it may break permalinks this.setActiveSpace(lastSpaceId, false); @@ -1369,7 +1370,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.notificationStateMap.get(key)!; } - const state = new SpaceNotificationState(getRoomFn); + const state = new SpaceNotificationState(); this.notificationStateMap.set(key, state); return state; } diff --git a/src/stores/spaces/index.ts b/src/stores/spaces/index.ts index 5a775692c9..fb642b638d 100644 --- a/src/stores/spaces/index.ts +++ b/src/stores/spaces/index.ts @@ -6,7 +6,7 @@ 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 Room, type HierarchyRoom } from "matrix-js-sdk/src/matrix"; +import { type HierarchyRoom } from "matrix-js-sdk/src/matrix"; import { _t } from "../../languageHandler"; @@ -42,7 +42,15 @@ export const getMetaSpaceName = (spaceKey: MetaSpace, allRoomsInHome = false): s } }; -export type SpaceKey = MetaSpace | Room["roomId"]; +/** + * This can be: + * - a MetaSpace + * - space ID (ie. a room ID) + * - A 'custom' space from a module + * Unfortunately we can't type the last set as we don't know what modules will define, + * so we can't do better than string here. + */ +export type SpaceKey = string; export interface ISuggestedRoom extends HierarchyRoom { viaServers: string[]; diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 0d440ff963..870b51aff8 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -30,6 +30,9 @@ import { ModuleRunner } from "../modules/ModuleRunner"; import { parseQs } from "./url_utils"; import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing"; import { UserFriendlyError } from "../languageHandler"; +import { ModuleApi } from "../modules/Api"; +import { RoomView } from "../components/structures/RoomView"; +import RoomAvatar from "../components/views/avatars/RoomAvatar"; logger.log(`Application is running in ${process.env.NODE_ENV} mode`); @@ -53,6 +56,10 @@ function onTokenLoginCompleted(): void { } export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { + // XXX: This lives here because certain components import so many things that importing it in a sensible place (eg. + // the builtins module or init.tsx) causes a circular dependency. + ModuleApi.instance.builtins.setComponents({ roomView: RoomView, roomAvatar: RoomAvatar }); + initRouting(); const platform = PlatformPeg.get(); diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index 09c5bd9823..ecce6818b0 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -10,8 +10,8 @@ import { type MockedObject } from "jest-mock"; import { type EventTimeline, EventType, type MatrixClient, type MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import { type IRoomState, MainSplitContentType } from "../../src/components/structures/RoomView"; -import { TimelineRenderingType } from "../../src/contexts/RoomContext"; +import { MainSplitContentType } from "../../src/components/structures/RoomView"; +import { type RoomContextType, TimelineRenderingType } from "../../src/contexts/RoomContext"; import { Layout } from "../../src/settings/enums/Layout"; import { mkEvent } from "./test-utils"; import { SdkContextClass } from "../../src/contexts/SDKContext"; @@ -43,7 +43,7 @@ export const makeRoomWithStateEvents = ( return room1; }; -export function getRoomContext(room: Room, override: Partial): IRoomState { +export function getRoomContext(room: Room, override: Partial): RoomContextType { return { roomViewStore: SdkContextClass.instance.roomViewStore, room, diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d96ee1d045..8f70d089dd 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -650,6 +650,7 @@ export function mkStubRoom( getJoinedMembers: jest.fn().mockReturnValue([]), getLiveTimeline: jest.fn().mockReturnValue(stubTimeline), getLastLiveEvent: jest.fn().mockReturnValue(undefined), + getLastActiveTimestamp: jest.fn().mockReturnValue(1183140000), getMember: jest.fn().mockReturnValue({ userId: "@member:domain.bla", name: "Member", diff --git a/test/unit-tests/components/structures/MessagePanel-test.tsx b/test/unit-tests/components/structures/MessagePanel-test.tsx index 0f3b40e3f0..71234bea3b 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -15,7 +15,7 @@ import { render } from "jest-matrix-react"; import MessagePanel, { shouldFormContinuation } from "../../../../src/components/structures/MessagePanel"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; +import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import * as TestUtilsMatrix from "../../../test-utils"; import { @@ -29,7 +29,6 @@ import { mockClientPushProcessor, } from "../../../test-utils"; import type ResizeNotifier from "../../../../src/utils/ResizeNotifier"; -import { type IRoomState } from "../../../../src/components/structures/RoomView"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts"; @@ -92,9 +91,9 @@ describe("MessagePanel", function () { showAvatarChanges: false, showDisplaynameChanges: true, showHiddenEvents: false, - } as unknown as IRoomState; + } as unknown as RoomContextType; - const getComponent = (props = {}, roomContext: Partial = {}) => ( + const getComponent = (props = {}, roomContext: Partial = {}) => ( diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index e7a99733f9..7739022345 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -89,7 +89,6 @@ describe("RoomView", () => { let cli: MockedObject; let room: Room; let rooms: Map; - let roomCount = 0; let stores: SdkContextClass; let crypto: CryptoApi; @@ -100,7 +99,9 @@ describe("RoomView", () => { mockPlatformPeg({ reload: () => {} }); cli = mocked(stubClient()); - room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); + const roomName = (expect.getState().currentTestName ?? "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + + room = new Room(`!${roomName}:example.org`, cli, "@alice:example.org"); jest.spyOn(room, "findPredecessor"); room.getPendingEvents = () => []; rooms = new Map(); @@ -158,7 +159,6 @@ describe("RoomView", () => { threepidInvite={undefined as any} forceTimeline={false} ref={ref} - roomViewStore={stores.roomViewStore} /> , @@ -197,7 +197,6 @@ describe("RoomView", () => { threepidInvite={undefined} forceTimeline={false} onRegistered={jest.fn()} - roomViewStore={stores.roomViewStore} /> , @@ -211,6 +210,26 @@ describe("RoomView", () => { return ref.current!; }; + it("gets a room view store from MultiRoomViewStore when given a room ID", async () => { + stores.multiRoomViewStore.getRoomViewStoreForRoom = jest.fn().mockReturnValue(stores.roomViewStore); + + const ref = createRef(); + render( + + + + + , + ); + + expect(stores.multiRoomViewStore.getRoomViewStoreForRoom).toHaveBeenCalledWith("!room:example.dummy"); + }); + it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => { const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase"); await renderRoomView(false); @@ -707,7 +726,7 @@ describe("RoomView", () => { }); it("should switch rooms when edit is clicked on a search result for a different room", async () => { - const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); + const room2 = new Room(`!roomswitchtest:example.org`, cli, "@alice:example.org"); rooms.set(room2.roomId, room2); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index e2596b072f..591d990e93 100644 --- a/test/unit-tests/components/structures/ThreadPanel-test.tsx +++ b/test/unit-tests/components/structures/ThreadPanel-test.tsx @@ -26,8 +26,8 @@ import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalink import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; -import { type IRoomState } from "../../../../src/components/structures/RoomView"; import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; +import type { RoomContextType } from "../../../../src/contexts/RoomContext.ts"; jest.mock("../../../../src/utils/Feedback"); @@ -79,7 +79,7 @@ describe("ThreadPanel", () => { mockRoom.getLastLiveEvent.mockReturnValue(mockEvent); const roomContextObject = { room: mockRoom, - } as unknown as IRoomState; + } as unknown as RoomContextType; const { container } = render( diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 1849e3d97f..5fc72fb6ef 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -731,12 +731,12 @@ exports[`RoomView invites renders an invite room 1`] = ` class="mx_RoomPreviewBar_message" >

    - Do you want to join !2:example.org? + Do you want to join !roomviewinvitesrendersaninviteroom:example.org?

    - !12:example.org + !roomviewshouldnotdisplaythetimelinewhentheroomencryptionisloading:example.org

    @@ -1029,7 +1029,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo aria-label="Open room settings" aria-live="off" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" - data-color="5" + data-color="1" data-testid="avatar-img" data-type="round" role="button" @@ -1056,7 +1056,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo - !12:example.org + !roomviewshouldnotdisplaythetimelinewhentheroomencryptionisloading:example.org @@ -1419,7 +1419,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` aria-label="Open room settings" aria-live="off" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" - data-color="4" + data-color="2" data-testid="avatar-img" data-type="round" role="button" @@ -1446,7 +1446,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` - !17:example.org + !roomviewvideoroomsshouldrenderjoinedvideoroomview:example.org diff --git a/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx b/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx index 243fea38b2..7f794a6ad1 100644 --- a/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/unit-tests/components/views/audio_messages/RecordingPlayback-test.tsx @@ -15,10 +15,9 @@ import RecordingPlayback, { PlaybackLayout, } from "../../../../../src/components/views/audio_messages/RecordingPlayback"; import { Playback } from "../../../../../src/audio/Playback"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { createAudioContext } from "../../../../../src/audio/compat"; import { flushPromises } from "../../../../test-utils"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../src/WorkerManager", () => ({ @@ -54,7 +53,10 @@ describe("", () => { const mockChannelData = new Float32Array(); - const defaultRoom = { roomId: "!room:server.org", timelineRenderingType: TimelineRenderingType.File } as IRoomState; + const defaultRoom = { + roomId: "!room:server.org", + timelineRenderingType: TimelineRenderingType.File, + } as RoomContextType; const getComponent = (props: React.ComponentProps, room = defaultRoom) => render( diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 908f8bdf6f..f48ad2e353 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -31,8 +31,7 @@ import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMe import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; -import RoomContext from "../../../../../src/contexts/RoomContext"; +import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext"; describe("", () => { let client: Mocked; @@ -51,7 +50,7 @@ describe("", () => { emit: jest.fn(), }; - let roomContext: IRoomState; + let roomContext: RoomContextType; beforeEach(() => { stubClient(); @@ -79,7 +78,7 @@ describe("", () => { ...RoomContext, roomId: room.roomId, roomViewStore: mockRoomViewStore, - } as unknown as IRoomState; + } as unknown as RoomContextType; }); afterEach(async () => { diff --git a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx index 4b26b361ad..cd9246267d 100644 --- a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx @@ -27,8 +27,7 @@ import { mocked } from "jest-mock"; import userEvent from "@testing-library/user-event"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; +import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { canEditContent } from "../../../../../src/utils/EventUtils"; import { copyPlaintext, getSelectedText } from "../../../../../src/utils/strings"; import MessageContextMenu from "../../../../../src/components/views/context_menus/MessageContextMenu"; @@ -711,18 +710,18 @@ describe("MessageContextMenu", () => { }); }); -function createRightClickMenuWithContent(eventContent: object, context?: Partial): RenderResult { +function createRightClickMenuWithContent(eventContent: object, context?: Partial): RenderResult { return createMenuWithContent(eventContent, { rightClick: true }, context); } -function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial): RenderResult { +function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial): RenderResult { return createMenu(mxEvent, { rightClick: true }, context); } function createMenuWithContent( eventContent: object, props?: Partial, - context?: Partial, + context?: Partial, ): RenderResult { // XXX: We probably shouldn't be assuming all events are going to be message events, but considering this // test is for the Message context menu, it's a fairly safe assumption. @@ -739,7 +738,7 @@ function makeDefaultRoom(): Room { function createMenu( mxEvent: MatrixEvent, props?: Partial, - context: Partial = {}, + context: Partial = {}, beacons: Map = new Map(), room: Room = makeDefaultRoom(), ): RenderResult { @@ -755,7 +754,7 @@ function createMenu( client.getRoom = jest.fn().mockReturnValue(room); return render( - + , ); diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 30e48305be..f51b62933c 100644 --- a/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ b/test/unit-tests/components/views/messages/DateSeparator-test.tsx @@ -26,8 +26,7 @@ import { } from "../../../../test-utils"; import DateSeparator from "../../../../../src/components/views/messages/DateSeparator"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; -import RoomContext from "../../../../../src/contexts/RoomContext"; +import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext"; jest.mock("../../../../../src/settings/SettingsStore"); @@ -50,7 +49,7 @@ describe("DateSeparator", () => { ...RoomContext, roomId, roomViewStore: mockRoomViewStore, - } as unknown as IRoomState; + } as unknown as RoomContextType; const mockClient = getMockClientWithEventEmitter({ timestampToEvent: jest.fn(), diff --git a/test/unit-tests/components/views/messages/MessageActionBar-test.tsx b/test/unit-tests/components/views/messages/MessageActionBar-test.tsx index f8baf7505e..cab8dd4c11 100644 --- a/test/unit-tests/components/views/messages/MessageActionBar-test.tsx +++ b/test/unit-tests/components/views/messages/MessageActionBar-test.tsx @@ -29,8 +29,7 @@ import { makeBeaconInfoEvent, } from "../../../../test-utils"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; -import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; +import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import dispatcher from "../../../../../src/dispatcher/dispatcher"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { Action } from "../../../../../src/dispatcher/actions"; @@ -115,8 +114,8 @@ describe("", () => { canSendMessages: true, canReact: true, room, - } as unknown as IRoomState; - const getComponent = (props = {}, roomContext: Partial = {}) => + } as unknown as RoomContextType; + const getComponent = (props = {}, roomContext: Partial = {}) => render( diff --git a/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx index 86dd8566d1..39ae0a4b6f 100644 --- a/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx @@ -27,12 +27,12 @@ import { import DocumentOffset from "../../../../../src/editor/offset"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import Autocompleter, { type IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter"; import NotifProvider from "../../../../../src/autocomplete/NotifProvider"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; +import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; describe("", () => { const userId = "@alice:server.org"; @@ -75,7 +75,7 @@ describe("", () => { const defaultRoomContext = getRoomContext(room, {}); - const getComponent = (editState: EditorStateTransfer, roomContext: IRoomState = defaultRoomContext) => + const getComponent = (editState: EditorStateTransfer, roomContext: RoomContextType = defaultRoomContext) => render(, { wrapper: ({ children }) => ( diff --git a/test/unit-tests/components/views/rooms/EventTile-test.tsx b/test/unit-tests/components/views/rooms/EventTile-test.tsx index 6a04055b33..db8e2c44c3 100644 --- a/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -30,14 +30,13 @@ import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils"; import { mkThread } from "../../../../test-utils/threads"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import dis from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import PinningUtils from "../../../../../src/utils/PinningUtils"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; @@ -52,7 +51,7 @@ describe("EventTile", () => { /** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */ function WrappedEventTile(props: { - roomContext: IRoomState; + roomContext: RoomContextType; eventTilePropertyOverrides?: Partial; }) { return ( @@ -71,7 +70,7 @@ describe("EventTile", () => { function getComponent( overrides: Partial = {}, renderingType: TimelineRenderingType = TimelineRenderingType.Room, - roomContext: Partial = {}, + roomContext: Partial = {}, ) { const context = getRoomContext(room, { timelineRenderingType: renderingType, diff --git a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index febe303f41..bfd9c88448 100644 --- a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -24,7 +24,6 @@ import { import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../../src/utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { LocalRoom } from "../../../../../src/models/LocalRoom"; @@ -36,6 +35,7 @@ import { addTextToComposerRTL } from "../../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore"; import { Action } from "../../../../../src/dispatcher/actions"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; +import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; const openStickerPicker = async (): Promise => { await userEvent.click(screen.getByLabelText("More options")); @@ -155,7 +155,7 @@ describe("MessageComposer", () => { }); describe("when receiving a »reply_to_event«", () => { - let roomContext: IRoomState; + let roomContext: RoomContextType; let resizeNotifier: ResizeNotifier; beforeEach(() => { @@ -458,7 +458,7 @@ function wrapAndRender( canSendMessages, tombstone, narrow, - } as unknown as IRoomState; + } as unknown as RoomContextType; const defaultProps = { room, diff --git a/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx b/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx index 634a72866d..58a58185d0 100644 --- a/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx @@ -11,10 +11,10 @@ import { render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-utils"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; +import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; describe("MessageComposerButtons", () => { // @ts-ignore - we're deliberately not implementing the whole interface here, but @@ -50,7 +50,7 @@ describe("MessageComposerButtons", () => { function wrapAndRender(component: React.ReactElement, narrow: boolean) { const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; - const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { narrow }); + const defaultRoomContext: RoomContextType = getRoomContext(mockRoom, { narrow }); return render( diff --git a/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx b/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx index 59d87bc3cd..8d31e6d84c 100644 --- a/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx +++ b/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx @@ -22,16 +22,16 @@ import { } from "../../../../test-utils"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import NewRoomIntro from "../../../../../src/components/views/rooms/NewRoomIntro"; -import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { DirectoryMember } from "../../../../../src/utils/direct-messages"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => { render( - + , diff --git a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index 86c4a9f352..dceff385f1 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -41,8 +41,7 @@ import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/ import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext"; -import { type IRoomState } from "../../../../../../src/components/structures/RoomView"; -import RoomContext from "../../../../../../src/contexts/RoomContext"; +import RoomContext, { type RoomContextType } from "../../../../../../src/contexts/RoomContext"; import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases"; import LegacyCallHandler from "../../../../../../src/LegacyCallHandler"; @@ -85,7 +84,7 @@ describe("RoomHeader", () => { emit: jest.fn(), }; - let roomContext: IRoomState; + let roomContext: RoomContextType; function getWrapper(): RenderOptions { return { @@ -121,7 +120,7 @@ describe("RoomHeader", () => { ...RoomContext, roomId: ROOM_ID, roomViewStore: mockRoomViewStore, - } as unknown as IRoomState; + } as unknown as RoomContextType; }); afterEach(() => { diff --git a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index 46794f47c3..fcc215a73f 100644 --- a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -17,7 +17,7 @@ import SendMessageComposer, { isQuickReaction, } from "../../../../../src/components/views/rooms/SendMessageComposer"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import EditorModel from "../../../../../src/editor/model"; import { createPartCreator } from "../../../editor/mock"; import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils"; @@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import DocumentOffset from "../../../../../src/editor/offset"; import { Layout } from "../../../../../src/settings/enums/Layout"; -import { type IRoomState, MainSplitContentType } from "../../../../../src/components/structures/RoomView"; +import { MainSplitContentType } from "../../../../../src/components/structures/RoomView"; import { mockPlatformPeg } from "../../../../test-utils/platform"; import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { addTextToComposer } from "../../../../test-utils/composer"; @@ -37,7 +37,7 @@ jest.mock("../../../../../src/utils/local-room", () => ({ })); describe("", () => { - const defaultRoomContext: IRoomState = { + const defaultRoomContext: RoomContextType = { roomViewStore: SdkContextClass.instance.roomViewStore, roomLoading: true, peekLoading: false, diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils.ts b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils.ts index 3a9e18fd0a..816f9ab672 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils.ts +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils.ts @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import { type EventTimeline, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../../test-utils"; -import { type IRoomState } from "../../../../../../src/components/structures/RoomView"; import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer"; +import type { RoomContextType } from "../../../../../../src/contexts/RoomContext"; export function createMocks(eventContent = "Replying to this new content") { const mockClient = stubClient(); @@ -31,7 +31,7 @@ export function createMocks(eventContent = "Replying to this ne return eventId === mockEvent.getId() ? mockEvent : null; }); - const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { + const defaultRoomContext: RoomContextType = getRoomContext(mockRoom, { liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline, }); diff --git a/test/unit-tests/modules/AccountDataApi-test.ts b/test/unit-tests/modules/AccountDataApi-test.ts new file mode 100644 index 0000000000..d7b72d7bce --- /dev/null +++ b/test/unit-tests/modules/AccountDataApi-test.ts @@ -0,0 +1,72 @@ +/* +Copyright 2025 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 { ClientEvent } from "matrix-js-sdk/src/matrix"; + +import { AccountDataApi } from "../../../src/modules/AccountDataApi"; +import { mkEvent, stubClient } from "../../test-utils/test-utils"; + +describe("AccountDataApi", () => { + describe("AccountDataWatchable", () => { + it("should return content of account data event on get()", () => { + const cli = stubClient(); + const api = new AccountDataApi(); + // Mock cli to return a event + const content = { foo: "bar" }; + const event = mkEvent({ content, type: "m.test", user: "@foobar:matrix.org", event: true }); + cli.getAccountData = () => event; + expect(api.get("m.test").value).toStrictEqual(content); + }); + + it("should update value on event", () => { + const cli = stubClient(); + const api = new AccountDataApi(); + // Mock cli to return a event + const content = { foo: "bar" }; + const event = mkEvent({ content, type: "m.test", user: "@foobar:matrix.org", event: true }); + cli.getAccountData = () => event; + + const watchable = api.get("m.test"); + expect(watchable.value).toStrictEqual(content); + + const fn = jest.fn(); + watchable.watch(fn); + + // Let's say that the account data event changed + const event2 = mkEvent({ + content: { foo: "abc" }, + type: "m.test", + user: "@foobar:matrix.org", + event: true, + }); + cli.emit(ClientEvent.AccountData, event2); + // Watchable value should have been updated + expect(watchable.value).toStrictEqual({ foo: "abc" }); + // Watched callbacks should be called + expect(fn).toHaveBeenCalledTimes(1); + + // Make sure unwatch removed the event listener + cli.off = jest.fn(); + watchable.unwatch(fn); + expect(cli.off).toHaveBeenCalledTimes(1); + }); + }); + + it("should set account data via js-sdk on set()", async () => { + const cli = stubClient(); + const api = new AccountDataApi(); + await api.set("m.test", { foo: "bar" }); + expect(cli.setAccountData).toHaveBeenCalledTimes(1); + }); + + it("should delete account data via js-sdk on set()", async () => { + const cli = stubClient(); + const api = new AccountDataApi(); + await api.delete("m.test"); + expect(cli.deleteAccountData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx new file mode 100644 index 0000000000..2b3b1139a5 --- /dev/null +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -0,0 +1,53 @@ +/* +Copyright 2025 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 React from "react"; +import { render } from "jest-matrix-react"; + +import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi.tsx"; +import { stubClient } from "../../test-utils/test-utils"; + +const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => { + return ( +
    + Avatar, {room.roomId}, {size} +
    + ); +}; + +describe("ElementWebBuiltinsApi", () => { + it("returns the RoomView component thats been set", () => { + const builtinsApi = new ElementWebBuiltinsApi(); + const sentinel = {}; + builtinsApi.setComponents({ roomView: sentinel, roomAvatar: Avatar } as any); + expect(builtinsApi.getRoomViewComponent()).toBe(sentinel); + }); + + it("returns rendered RoomView component", () => { + const builtinsApi = new ElementWebBuiltinsApi(); + const RoomView = () =>
    hello world
    ; + builtinsApi.setComponents({ roomView: RoomView, roomAvatar: Avatar } as any); + const { container } = render(<> {builtinsApi.renderRoomView("!foo:m.org")}); + expect(container).toHaveTextContent("hello world"); + }); + + it("returns rendered RoomAvatar component", () => { + stubClient(); + const builtinsApi = new ElementWebBuiltinsApi(); + builtinsApi.setComponents({ roomView: {}, roomAvatar: Avatar } as any); + const { container } = render(<> {builtinsApi.renderRoomAvatar("!foo:m.org", "50")}); + expect(container).toHaveTextContent("Avatar"); + expect(container).toHaveTextContent("!foo:m.org"); + expect(container).toHaveTextContent("50"); + }); + + it("should throw error if called before components are set", () => { + stubClient(); + const builtinsApi = new ElementWebBuiltinsApi(); + expect(() => builtinsApi.renderRoomAvatar("!foo:m.org")).toThrow("No RoomAvatar component has been set"); + expect(() => builtinsApi.renderRoomView("!foo:m.org")).toThrow("No RoomView component has been set"); + }); +}); diff --git a/test/unit-tests/modules/ClientApi-test.ts b/test/unit-tests/modules/ClientApi-test.ts new file mode 100644 index 0000000000..22f55d7d37 --- /dev/null +++ b/test/unit-tests/modules/ClientApi-test.ts @@ -0,0 +1,20 @@ +/* +Copyright 2025 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 { ClientApi } from "../../../src/modules/ClientApi"; +import { Room } from "../../../src/modules/models/Room"; +import { stubClient } from "../../test-utils/test-utils"; + +describe("ClientApi", () => { + it("should return module room from getRoom()", () => { + stubClient(); + const client = new ClientApi(); + const moduleRoom = client.getRoom("!foo:matrix.org"); + expect(moduleRoom).toBeInstanceOf(Room); + expect(moduleRoom?.id).toStrictEqual("!foo:matrix.org"); + }); +}); diff --git a/test/unit-tests/modules/Navigation-test.ts b/test/unit-tests/modules/Navigation-test.ts index 3fafdf0fa6..ee0a70e9cf 100644 --- a/test/unit-tests/modules/Navigation-test.ts +++ b/test/unit-tests/modules/Navigation-test.ts @@ -37,5 +37,25 @@ describe("NavigationApi", () => { }), ); }); + + it("should dispatch correct action on openRoom", () => { + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + // Non alias + api.openRoom("!foo:m.org"); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + action: "view_room", + room_id: "!foo:m.org", + }), + ); + // Alias + api.openRoom("#bar:m.org"); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + action: "view_room", + room_alias: "#bar:m.org", + }), + ); + }); }); }); diff --git a/test/unit-tests/modules/StoresApi-test.ts b/test/unit-tests/modules/StoresApi-test.ts new file mode 100644 index 0000000000..ba8a0c83da --- /dev/null +++ b/test/unit-tests/modules/StoresApi-test.ts @@ -0,0 +1,84 @@ +/* +Copyright 2025 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 { waitFor } from "jest-matrix-react"; + +import { type RoomListStoreApi, StoresApi } from "../../../src/modules/StoresApi"; +import RoomListStoreV3, { + LISTS_LOADED_EVENT, + LISTS_UPDATE_EVENT, +} from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import { mkRoom, stubClient } from "../../test-utils/test-utils"; +import { Room } from "../../../src/modules/models/Room"; +import {} from "../../../src/stores/room-list/algorithms/Algorithm"; + +describe("StoresApi", () => { + describe("RoomListStoreApi", () => { + it("should return promise that resolves when RLS is ready", async () => { + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true); + const store = new StoresApi(); + let hasResolved = false; + // The following async function will set hasResolved to false + // only when waitForReady resolves. + (async () => { + await store.roomListStore.waitForReady(); + hasResolved = true; + })(); + // Shouldn't have resolved yet. + expect(hasResolved).toStrictEqual(false); + + // Wait for the module to load so that we can test the listener. + await (store.roomListStore as RoomListStoreApi).moduleLoadPromise; + // Emit the loaded event. + RoomListStoreV3.instance.emit(LISTS_LOADED_EVENT); + // Should resolve now. + await waitFor(() => { + expect(hasResolved).toStrictEqual(true); + }); + }); + + describe("getRooms()", () => { + it("should return rooms from RLS", async () => { + const cli = stubClient(); + const room1 = mkRoom(cli, "!foo1:m.org"); + const room2 = mkRoom(cli, "!foo2:m.org"); + const room3 = mkRoom(cli, "!foo3:m.org"); + jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue([room1, room2, room3]); + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); + + const store = new StoresApi(); + await store.roomListStore.waitForReady(); + const watchable = store.roomListStore.getRooms(); + expect(watchable.value).toHaveLength(3); + expect(watchable.value[0]).toBeInstanceOf(Room); + }); + + it("should update from RLS", async () => { + const cli = stubClient(); + const room1 = mkRoom(cli, "!foo1:m.org"); + const room2 = mkRoom(cli, "!foo2:m.org"); + const rooms = [room1, room2]; + + jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue(rooms); + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); + + const store = new StoresApi(); + await store.roomListStore.waitForReady(); + const watchable = store.roomListStore.getRooms(); + const fn = jest.fn(); + watchable.watch(fn); + expect(watchable.value).toHaveLength(2); + + const room3 = mkRoom(cli, "!foo3:m.org"); + rooms.push(room3); + RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT); + expect(fn).toHaveBeenCalledTimes(1); + expect(watchable.value).toHaveLength(3); + }); + }); + }); +}); diff --git a/test/unit-tests/modules/models/Room-test.ts b/test/unit-tests/modules/models/Room-test.ts new file mode 100644 index 0000000000..d149c8cdf0 --- /dev/null +++ b/test/unit-tests/modules/models/Room-test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2025 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 { Room } from "../../../../src/modules/models/Room"; +import { mkRoom, stubClient } from "../../../test-utils"; + +describe("Room", () => { + it("should return id from sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + const room = new Room(sdkRoom); + expect(room.id).toStrictEqual("!foo:m.org"); + }); + + it("should return last timestamp from sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + const room = new Room(sdkRoom); + expect(room.getLastActiveTimestamp()).toStrictEqual(sdkRoom.getLastActiveTimestamp()); + }); + + describe("watchableName", () => { + it("should return name from sdkRoom", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + sdkRoom.name = "Foo Name"; + const room = new Room(sdkRoom); + expect(room.name.value).toStrictEqual("Foo Name"); + }); + + it("should add/remove event listener on sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + sdkRoom.name = "Foo Name"; + + const room = new Room(sdkRoom); + const fn = jest.fn(); + + room.name.watch(fn); + expect(sdkRoom.on).toHaveBeenCalledTimes(1); + + room.name.unwatch(fn); + expect(sdkRoom.off).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/unit-tests/stores/MultiRoomViewStore-test.ts b/test/unit-tests/stores/MultiRoomViewStore-test.ts new file mode 100644 index 0000000000..429ec3c08a --- /dev/null +++ b/test/unit-tests/stores/MultiRoomViewStore-test.ts @@ -0,0 +1,101 @@ +/* +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 { MultiRoomViewStore } from "../../../src/stores/MultiRoomViewStore"; +import { RoomViewStore } from "../../../src/stores/RoomViewStore"; +import { Action } from "../../../src/dispatcher/actions"; +import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import { TestSdkContext } from "../TestSdkContext"; + +jest.mock("../../../src/stores/RoomViewStore"); + +describe("MultiRoomViewStore", () => { + let multiRoomViewStore: MultiRoomViewStore; + let mockDispatcher: MatrixDispatcher; + let mockSdkContext: TestSdkContext; + let mockRoomViewStore: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create mock dispatcher + mockDispatcher = { + dispatch: jest.fn(), + register: jest.fn(), + unregister: jest.fn(), + } as unknown as MatrixDispatcher; + + // Create mock SDK context + mockSdkContext = new TestSdkContext(); + + // Create mock RoomViewStore instance + mockRoomViewStore = { + viewRoom: jest.fn(), + dispose: jest.fn(), + } as any; + + (RoomViewStore as jest.MockedClass).mockImplementation(() => mockRoomViewStore as any); + + // Create the MultiRoomViewStore instance + multiRoomViewStore = new MultiRoomViewStore(mockDispatcher, mockSdkContext); + }); + + describe("getRoomViewStoreForRoom", () => { + it("should create a new RoomViewStore for a room that doesn't exist in cache", () => { + const roomId = "!room1:example.com"; + + const result = multiRoomViewStore.getRoomViewStoreForRoom(roomId); + + expect(RoomViewStore).toHaveBeenCalledWith(mockDispatcher, mockSdkContext, roomId); + expect(mockRoomViewStore.viewRoom).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + }); + expect(result).toBe(mockRoomViewStore); + }); + + it("should return existing RoomViewStore for a room that exists in cache", () => { + const roomId = "!room1:example.com"; + + // First call creates the store + const firstResult = multiRoomViewStore.getRoomViewStoreForRoom(roomId); + + jest.clearAllMocks(); + + // Should return the same store + const secondResult = multiRoomViewStore.getRoomViewStoreForRoom(roomId); + + expect(RoomViewStore).not.toHaveBeenCalled(); + expect(mockRoomViewStore.viewRoom).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + }); + expect(secondResult).toBe(firstResult); + expect(secondResult).toBe(mockRoomViewStore); + }); + }); + + describe("removeRoomViewStore", () => { + it("should remove an existing RoomViewStore from cache", () => { + const roomId = "!room1:example.com"; + + multiRoomViewStore.getRoomViewStoreForRoom(roomId); + multiRoomViewStore.removeRoomViewStore(roomId); + + // New store should be created now + jest.clearAllMocks(); + (RoomViewStore as jest.MockedClass).mockImplementation( + () => mockRoomViewStore as any, + ); + + multiRoomViewStore.getRoomViewStoreForRoom(roomId); + expect(RoomViewStore).toHaveBeenCalledWith(mockDispatcher, mockSdkContext, roomId); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4737854f94..20e11c7b12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1579,10 +1579,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.16.1.tgz#28bdbde426051cc2a3228a36e7196e0a254569d3" integrity sha512-g3v/QFuNy8YVRGrKC5SxjIYvgBh6biOHgejhJT2Jk/yjOOUEuP0y2PBaADm+suPD9BB/Vk1jPxFk2uEIpEzhpA== -"@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-module-api@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.5.0.tgz#077a528917f4eb558059a2a5286b9bb6a2fb1690" + integrity sha512-WI/iMADRouXp9WhQy5jov6Z4eKKlHEPh20DKoCsKZ9dWaYcW/MiBhzi09PZxay+o0RLZXA6aDPxpxaIX3lZXag== "@element-hq/element-web-playwright-common@^2.0.0": version "2.0.0"