diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 64044c4c5c1..1aea3ae23bf 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 5eddeecb2ae..ae67b5f378e 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 907e40dede1..86269448952 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -68,6 +68,7 @@ import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushR import { type ConfigOptions } from "../../SdkConfig"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; +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) @@ -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 = ( @@ -705,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({ @@ -746,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 a4df7a5fe9e..c3c358d66bc 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. @@ -1922,7 +1925,9 @@ export default class MatrixChat extends React.PureComponent { subAction: params?.action, }); } else { - logger.info(`Ignoring showScreen for '${screen}'`); + 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 10647ee86b5..020b226a85a 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"; @@ -134,6 +135,7 @@ import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog"; import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusMessageSearchPayload.ts"; import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; +import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -147,7 +149,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps { +interface IRoomProps extends RoomViewProps { threepidInvite?: IThreepidInvite; oobData?: IOOBData; @@ -380,6 +382,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; @@ -392,6 +396,12 @@ 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 = { roomId: undefined, @@ -525,7 +535,7 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room): MainSplitContentType => { - if (this.context.roomViewStore.isViewingCall() || isVideoRoom(room)) { + if (this.roomViewStore.isViewingCall() || isVideoRoom(room)) { return MainSplitContentType.Call; } if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { @@ -539,8 +549,8 @@ export class RoomView extends React.Component { return; } - const roomLoadError = this.context.roomViewStore.getRoomLoadError() ?? undefined; - if (!initial && !roomLoadError && this.state.roomId !== this.context.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 @@ -555,29 +565,29 @@ export class RoomView extends React.Component { return; } - const roomId = this.context.roomViewStore.getRoomId() ?? null; + const roomId = this.roomViewStore.getRoomId() ?? null; const room = this.context.client?.getRoom(roomId ?? undefined) ?? undefined; const newState: Partial = { roomId: roomId ?? undefined, - roomAlias: this.context.roomViewStore.getRoomAlias() ?? undefined, - roomLoading: this.context.roomViewStore.isRoomLoading(), + roomAlias: this.roomViewStore.getRoomAlias() ?? undefined, + roomLoading: this.roomViewStore.isRoomLoading(), roomLoadError, - joining: this.context.roomViewStore.isJoining(), - replyToEvent: this.context.roomViewStore.getQuotingEvent() ?? undefined, + joining: this.roomViewStore.isJoining(), + replyToEvent: this.roomViewStore.getQuotingEvent() ?? undefined, // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && this.roomViewStore.shouldPeek(), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: this.context.roomViewStore.getWasContextSwitch(), + wasContextSwitch: this.roomViewStore.getWasContextSwitch(), mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: roomId ? this.context.rightPanelStore.isOpenForRoom(roomId) : false, - promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), - viewRoomOpts: this.context.roomViewStore.getViewRoomOpts(), + promptAskToJoin: this.roomViewStore.promptAskToJoin(), + viewRoomOpts: this.roomViewStore.getViewRoomOpts(), }; if ( @@ -593,7 +603,7 @@ export class RoomView extends React.Component { newState.showRightPanel = false; } - const initialEventId = this.context.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 @@ -619,13 +629,13 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: this.context.roomViewStore.isInitialEventHighlighted(), - scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), + highlighted: this.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.roomViewStore.initialEventScrollIntoView(); } } @@ -885,7 +895,7 @@ export class RoomView extends React.Component { this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); } // Start listening for RoomViewStore updates - this.context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); @@ -1002,7 +1012,7 @@ export class RoomView extends React.Component { window.removeEventListener("beforeunload", this.onPageUnload); - this.context.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); @@ -1030,6 +1040,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 => { diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 984b33bb12e..c0d8faedfb6 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 d03ac8a1e20..f2c41c367f6 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/SDKContext.ts b/src/contexts/SDKContext.ts index 4e7aa9e94f1..bb596107717 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/Api.ts b/src/modules/Api.ts index 49082daef2e..e463f6c085f 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -26,6 +26,8 @@ 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.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -79,6 +81,8 @@ 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 createRoot(element: Element): Root { diff --git a/src/modules/BuiltinsApi.ts b/src/modules/BuiltinsApi.ts new file mode 100644 index 00000000000..64c2dc4728d --- /dev/null +++ b/src/modules/BuiltinsApi.ts @@ -0,0 +1,33 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api"; + +export class ElementWebBuiltinsApi implements BuiltinsApi { + private _roomView?: React.ComponentType; + + /** + * Sets the components used to render a RoomView + * + * This only really exists here because referencing RoomView 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 RoomView component + */ + public setRoomViewComponent(component: React.ComponentType): void { + this._roomView = component; + } + + public getRoomViewComponent(): React.ComponentType { + if (!this._roomView) { + throw new Error("No RoomView component has been set"); + } + + return this._roomView; + } +} diff --git a/src/modules/ExtrasApi.ts b/src/modules/ExtrasApi.ts new file mode 100644 index 00000000000..0119c29cf49 --- /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 0e7724727d3..52bdb5aee9b 100644 --- a/src/modules/Navigation.ts +++ b/src/modules/Navigation.ts @@ -5,7 +5,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 NavigationApi as INavigationApi } from "@element-hq/element-web-module-api"; +import { type LocationRenderFunction, type NavigationApi as INavigationApi } from "@element-hq/element-web-module-api"; import { navigateToPermalink } from "../utils/permalinks/navigator.ts"; import { parsePermalink } from "../utils/permalinks/Permalinks.ts"; @@ -14,6 +14,8 @@ 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); @@ -38,4 +40,8 @@ export class NavigationApi implements INavigationApi { } } } + + public registerLocationRenderer(path: string, renderer: LocationRenderFunction): void { + this.locationRenderers.set(path, renderer); + } } diff --git a/src/stores/MultiRoomViewStore.ts b/src/stores/MultiRoomViewStore.ts new file mode 100644 index 00000000000..bc2f090e6f3 --- /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 418a7a7d4f4..ee7c2ad9bc2 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 4ea24b7e10c..0faf8bc3e6a 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 bfb98f6b02a..c0b9a921595 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 5a775692c97..d05435b587a 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,7 @@ export const getMetaSpaceName = (spaceKey: MetaSpace, allRoomsInHome = false): s } }; -export type SpaceKey = MetaSpace | Room["roomId"]; +export type SpaceKey = string; export interface ISuggestedRoom extends HierarchyRoom { viaServers: string[]; diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 0d440ff963a..21b21e9252a 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -30,6 +30,8 @@ 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"; logger.log(`Application is running in ${process.env.NODE_ENV} mode`); @@ -53,6 +55,11 @@ function onTokenLoginCompleted(): void { } export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { + // XXX: This lives here because RoomVew import so many things that importing it in a sensible place (eg. + // the builtins module or init.tsx) causes a circular dependency. Instead, we reference RoomView here where we + // already reference it indirectly via MatrixChat. + ModuleApi.instance.builtins.setRoomViewComponent(RoomView); + initRouting(); const platform = PlatformPeg.get(); diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index b0efc27ddad..77390223454 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(); @@ -209,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); @@ -705,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/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index d8ffc523084..faa7179934a 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1099,12 +1099,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

    @@ -1397,7 +1397,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" @@ -1424,7 +1424,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo - !12:example.org + !roomviewshouldnotdisplaythetimelinewhentheroomencryptionisloading:example.org @@ -1787,7 +1787,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" @@ -1814,7 +1814,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` - !17:example.org + !roomviewvideoroomsshouldrenderjoinedvideoroomview:example.org diff --git a/test/unit-tests/modules/BuiltinsApi-test.ts b/test/unit-tests/modules/BuiltinsApi-test.ts new file mode 100644 index 00000000000..38ddec0c560 --- /dev/null +++ b/test/unit-tests/modules/BuiltinsApi-test.ts @@ -0,0 +1,17 @@ +/* +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 { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi"; + +describe("ElementWebBuiltinsApi", () => { + it("returns the RoomView component thats been set", () => { + const builtinsApi = new ElementWebBuiltinsApi(); + const sentinel = {}; + builtinsApi.setRoomViewComponent(sentinel as any); + expect(builtinsApi.getRoomViewComponent()).toBe(sentinel); + }); +}); diff --git a/test/unit-tests/stores/MultiRoomViewStore-test.ts b/test/unit-tests/stores/MultiRoomViewStore-test.ts new file mode 100644 index 00000000000..429ec3c08a5 --- /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); + }); + }); +});