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"