Compare commits

...

44 Commits

Author SHA1 Message Date
R Midhun Suresh
6dc1431270 Write tests 2025-10-30 18:33:07 +05:30
R Midhun Suresh
f4e8e79af8 Implement openRoom in NavigationApi 2025-10-30 18:32:53 +05:30
R Midhun Suresh
507eaa02df Use nullish coalescing assignment 2025-10-30 18:02:19 +05:30
R Midhun Suresh
b94d40f166 Write tests 2025-10-30 17:59:12 +05:30
R Midhun Suresh
c2d68f8dc0 Create ClientApi in Api.ts 2025-10-30 17:13:57 +05:30
R Midhun Suresh
3be766d79c Add implementation for ClientApi 2025-10-30 17:13:39 +05:30
R Midhun Suresh
335491eabc Add implementation for Room 2025-10-30 17:13:14 +05:30
R Midhun Suresh
2449557aa8 Add implementation for AccountDataApi 2025-10-30 17:12:32 +05:30
R Midhun Suresh
eebf227cf4 Update license 2025-10-30 16:56:37 +05:30
R Midhun Suresh
ebc9e3ace6 room-id is optional 2025-10-30 16:48:45 +05:30
R Midhun Suresh
61306a1e4a Improve comment 2025-10-30 16:31:59 +05:30
R Midhun Suresh
a9fed64637 Add more tests 2025-10-30 16:29:50 +05:30
R Midhun Suresh
8a875e8c6d Fix import 2025-10-30 16:12:23 +05:30
R Midhun Suresh
620ba9231d Fix circular dependency issue 2025-10-30 16:10:34 +05:30
R Midhun Suresh
f2104b5ec0 Fix import 2025-10-30 16:10:34 +05:30
R Midhun Suresh
1c0738be0f Add tests 2025-10-30 16:10:33 +05:30
R Midhun Suresh
c78461db0b Implement new builtins api 2025-10-30 16:10:31 +05:30
R Midhun Suresh
2b05d51e41 Add RoomContextType 2025-10-30 16:08:08 +05:30
R Midhun Suresh
6f6b3bdd8f No need to pass RVS from LoggedInView 2025-10-30 15:44:07 +05:30
R Midhun Suresh
da11cff6ff Fix test 2025-10-30 15:33:25 +05:30
R Midhun Suresh
302b6567ea Remove RoomViewStore from state
This is now accessed through class field
2025-10-30 15:32:40 +05:30
R Midhun Suresh
b8c79f46ee Add roomId to prop 2025-10-30 15:31:56 +05:30
R Midhun Suresh
0e8a617beb RVS is not needed as prop anymore
Since it's passed through context
2025-10-30 15:25:44 +05:30
David Baker
a94328a125 Update module api 2025-10-29 15:45:41 +00:00
David Baker
4d7d06bfc0 Merge remote-tracking branch 'origin/develop' into dbkr/module_experiments 2025-10-29 15:44:22 +00:00
David Baker
c31d4fea8d Merge branch 'develop' into dbkr/module_experiments 2025-10-21 11:04:34 +01:00
David Baker
a5f3876a38 Add test for builtinsapi 2025-10-17 17:05:45 +01:00
David Baker
206905c2f5 Make room names deterministic
So the tests don't fail if you add other tests or run them individually
2025-10-17 16:51:59 +01:00
David Baker
51499fa106 add test 2025-10-17 16:28:05 +01:00
David Baker
1ebead1c8a Add test for multiroomviewstore 2025-10-17 16:07:02 +01:00
David Baker
738eac9b90 Fairly awful workaround
to actually break the dependency nightmare
2025-10-17 11:22:00 +01:00
David Baker
2dd743dea0 Switch to using module api via .instance 2025-10-16 19:14:34 +01:00
David Baker
ced886aa07 Merge remote-tracking branch 'origin/develop' into dbkr/module_experiments 2025-10-16 11:25:03 +01:00
David Baker
de5a75777f Merge remote-tracking branch 'origin/develop' into dbkr/module_experiments 2025-10-15 11:37:21 +01:00
David Baker
809b41aa59 Merge remote-tracking branch 'origin/develop' into dbkr/module_experiments 2025-10-13 17:00:24 +01:00
David Baker
b6b1658805 Merge remote-tracking branch 'origin/develop' into dbkr/module_experiments 2025-10-02 15:20:40 +01:00
David Baker
afa340eb18 Remove fetchRoomFn from SpaceNotificationStore
which didn't really seem to have any point as it was only called from
one place
2025-09-26 12:09:07 +01:00
David Baker
7ac4a4a2d4 Merge branch 'develop' into dbkr/module_experiments 2025-09-26 09:51:17 +01:00
David Baker
66bf1dd469 Allow space panel items to be updated
and manage which one is selected, allowing module "spaces" to be
considered spaces
2025-09-25 17:37:48 +01:00
David Baker
9ae447f14f Different interface to add space panel items
A bit less flexible but probably simpler and will help keep things
actually consistent rather than just allowing modules to stick any
JSX into the space panel (which means they also have to worry about
styling if they *do* want it to be consistent).
2025-09-25 11:54:54 +01:00
David Baker
a02a5ac849 Make RoomViewStore able to take a roomId prop 2025-09-24 16:35:28 +01:00
David Baker
e4dee7ab63 Add the MultiRoomViewStore 2025-09-24 16:31:57 +01:00
David Baker
9129c35407 Move ResizerNotifier into SDKContext
so we don't have to pass it into RoomView
2025-09-24 10:56:10 +01:00
David Baker
4b701b55b1 Module API experiments 2025-09-23 19:17:19 +01:00
49 changed files with 896 additions and 154 deletions

View File

@@ -81,7 +81,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.4.1", "@element-hq/element-web-module-api": "1.5.0",
"@fontsource/inconsolata": "^5", "@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5", "@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7", "@formatjs/intl-segmenter": "^11.5.7",

View File

@@ -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 { &.mx_SpaceButton_home .mx_SpaceButton_icon::before {
mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg"); mask-image: url("@vector-im/compound-design-tokens/icons/home-solid.svg");
} }

View File

@@ -31,7 +31,7 @@ const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN>, ScreenName> = {
[Views.LOCK_STOLEN]: "SessionLockStolen", [Views.LOCK_STOLEN]: "SessionLockStolen",
}; };
const loggedInPageTypeMap: Record<PageType, ScreenName> = { const loggedInPageTypeMap: Record<PageType | string, ScreenName> = {
[PageType.HomePage]: "Home", [PageType.HomePage]: "Home",
[PageType.RoomView]: "Room", [PageType.RoomView]: "Room",
[PageType.UserView]: "User", [PageType.UserView]: "User",
@@ -48,10 +48,10 @@ export default class PosthogTrackers {
} }
private view: Views = Views.LOADING; private view: Views = Views.LOADING;
private pageType?: PageType; private pageType?: PageType | string;
private override?: ScreenName; 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.view = view;
this.pageType = pageType; this.pageType = pageType;
if (this.override) return; if (this.override) return;

View File

@@ -68,7 +68,8 @@ import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushR
import { type ConfigOptions } from "../../SdkConfig"; import { type ConfigOptions } from "../../SdkConfig";
import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; import { MatrixClientContextProvider } from "./MatrixClientContextProvider";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation"; 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) // 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. // so each pinned message may trigger a request. Limit the number per room for sanity.
@@ -679,6 +680,10 @@ class LoggedInView extends React.Component<IProps, IState> {
public render(): React.ReactNode { public render(): React.ReactNode {
let pageElement; let pageElement;
const moduleRenderer = this.props.page_type
? ModuleApi.instance.navigation.locationRenderers.get(this.props.page_type)
: undefined;
switch (this.props.page_type) { switch (this.props.page_type) {
case PageTypes.RoomView: case PageTypes.RoomView:
pageElement = ( pageElement = (
@@ -690,7 +695,6 @@ class LoggedInView extends React.Component<IProps, IState> {
key={this.props.currentRoomId || "roomview"} key={this.props.currentRoomId || "roomview"}
justCreatedOpts={this.props.roomJustCreatedOpts} justCreatedOpts={this.props.roomJustCreatedOpts}
forceTimeline={this.props.forceTimeline} forceTimeline={this.props.forceTimeline}
roomViewStore={SdkContextClass.instance.roomViewStore}
/> />
); );
break; break;
@@ -706,6 +710,13 @@ class LoggedInView extends React.Component<IProps, IState> {
); );
} }
break; break;
default: {
if (moduleRenderer) {
pageElement = moduleRenderer();
} else {
console.warn(`Couldn't render page type "${this.props.page_type}"`);
}
}
} }
const wrapperClasses = classNames({ const wrapperClasses = classNames({
@@ -747,6 +758,7 @@ class LoggedInView extends React.Component<IProps, IState> {
)} )}
<SpacePanel /> <SpacePanel />
{!useNewRoomList && <BackdropPanel backgroundImage={this.state.backgroundImage} />} {!useNewRoomList && <BackdropPanel backgroundImage={this.state.backgroundImage} />}
{!moduleRenderer && (
<div <div
className="mx_LeftPanel_wrapper--user" className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer} ref={this._resizeContainer}
@@ -758,9 +770,10 @@ class LoggedInView extends React.Component<IProps, IState> {
resizeNotifier={this.context.resizeNotifier} resizeNotifier={this.context.resizeNotifier}
/> />
</div> </div>
)}
</div> </div>
</div> </div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" /> {!moduleRenderer && <ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />}
<div className="mx_RoomView_wrapper">{pageElement}</div> <div className="mx_RoomView_wrapper">{pageElement}</div>
</div> </div>
</div> </div>

View File

@@ -140,6 +140,7 @@ import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/ShareP
import Markdown from "../../Markdown"; import Markdown from "../../Markdown";
import { sanitizeHtmlParams } from "../../Linkify"; import { sanitizeHtmlParams } from "../../Linkify";
import { isOnlyAdmin } from "../../utils/membership"; import { isOnlyAdmin } from "../../utils/membership";
import { ModuleApi } from "../../modules/Api.ts";
// legacy export // legacy export
export { default as Views } from "../../Views"; export { default as Views } from "../../Views";
@@ -175,9 +176,11 @@ interface IProps {
interface IState { interface IState {
// the master view we are showing. // the master view we are showing.
view: Views; 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 // 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 // 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 // in the case where we view a room by ID or by RoomView when it resolves
// what ID an alias points at. // what ID an alias points at.
@@ -1922,7 +1925,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params?.action, subAction: params?.action,
}); });
} else { } else {
logger.info(`Ignoring showScreen for '${screen}'`); if (ModuleApi.instance.navigation.locationRenderers.get(screen)) {
this.setState({ page_type: screen });
}
} }
} }

View File

@@ -44,6 +44,7 @@ import { type CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { debounce, throttle } from "lodash"; import { debounce, throttle } from "lodash";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; 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 shouldHideEvent from "../../shouldHideEvent";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
@@ -148,7 +149,7 @@ if (DEBUG) {
debuglog = logger.log.bind(console); debuglog = logger.log.bind(console);
} }
interface IRoomProps { interface IRoomProps extends RoomViewProps {
threepidInvite?: IThreepidInvite; threepidInvite?: IThreepidInvite;
oobData?: IOOBData; 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) // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
onRegistered?(credentials: IMatrixClientCreds): void; 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 { MainSplitContentType };
export interface IRoomState { export interface IRoomState {
/**
* The RoomViewStore instance for the room we are displaying
*/
roomViewStore: RoomViewStore;
room?: Room; room?: Room;
roomId?: string; roomId?: string;
roomAlias?: string; roomAlias?: string;
@@ -389,6 +388,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private messagePanel: TimelinePanel | null = null; private messagePanel: TimelinePanel | null = null;
private roomViewBody = createRef<HTMLDivElement>(); private roomViewBody = createRef<HTMLDivElement>();
private roomViewStore: RoomViewStore;
public static contextType = SDKContext; public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>; declare public context: React.ContextType<typeof SDKContext>;
@@ -401,9 +402,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
throw new Error("Unable to create RoomView without MatrixClient"); 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(); const llMembers = context.client.hasLazyLoadMembersEnabled();
this.state = { this.state = {
roomViewStore: props.roomViewStore,
roomId: undefined, roomId: undefined,
roomLoading: true, roomLoading: true,
peekLoading: false, peekLoading: false,
@@ -535,7 +541,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}; };
private getMainSplitContentType = (room: Room): MainSplitContentType => { private getMainSplitContentType = (room: Room): MainSplitContentType => {
if (this.state.roomViewStore.isViewingCall() || isVideoRoom(room)) { if (this.roomViewStore.isViewingCall() || isVideoRoom(room)) {
return MainSplitContentType.Call; return MainSplitContentType.Call;
} }
if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) {
@@ -549,8 +555,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return; return;
} }
const roomLoadError = this.state.roomViewStore.getRoomLoadError() ?? undefined; const roomLoadError = this.roomViewStore.getRoomLoadError() ?? undefined;
if (!initial && !roomLoadError && this.state.roomId !== this.state.roomViewStore.getRoomId()) { if (!initial && !roomLoadError && this.state.roomId !== this.roomViewStore.getRoomId()) {
// RoomView explicitly does not support changing what room // RoomView explicitly does not support changing what room
// is being viewed: instead it should just be re-mounted when // is being viewed: instead it should just be re-mounted when
// switching rooms. Therefore, if the room ID changes, we // switching rooms. Therefore, if the room ID changes, we
@@ -564,7 +570,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// it was, it means we're about to be unmounted. // it was, it means we're about to be unmounted.
return; return;
} }
const roomViewStore = this.state.roomViewStore; const roomViewStore = this.roomViewStore;
const roomId = roomViewStore.getRoomId() ?? null; const roomId = roomViewStore.getRoomId() ?? null;
const roomAlias = roomViewStore.getRoomAlias() ?? undefined; const roomAlias = roomViewStore.getRoomAlias() ?? undefined;
const roomLoading = roomViewStore.isRoomLoading(); const roomLoading = roomViewStore.isRoomLoading();
@@ -611,7 +617,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
newState.showRightPanel = false; newState.showRightPanel = false;
} }
const initialEventId = this.state.roomViewStore.getInitialEventId() ?? this.state.initialEventId; const initialEventId = this.roomViewStore.getInitialEventId() ?? this.state.initialEventId;
if (initialEventId) { if (initialEventId) {
let initialEvent = room?.findEventById(initialEventId); let initialEvent = room?.findEventById(initialEventId);
// The event does not exist in the current sync data // The event does not exist in the current sync data
@@ -637,13 +643,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
action: Action.ShowThread, action: Action.ShowThread,
rootEvent: thread.rootEvent, rootEvent: thread.rootEvent,
initialEvent, initialEvent,
highlighted: this.state.roomViewStore.isInitialEventHighlighted(), highlighted: this.roomViewStore.isInitialEventHighlighted(),
scroll_into_view: this.state.roomViewStore.initialEventScrollIntoView(), scroll_into_view: this.roomViewStore.initialEventScrollIntoView(),
}); });
} else { } else {
newState.initialEventId = initialEventId; newState.initialEventId = initialEventId;
newState.isInitialEventHighlighted = this.state.roomViewStore.isInitialEventHighlighted(); newState.isInitialEventHighlighted = this.roomViewStore.isInitialEventHighlighted();
newState.initialEventScrollIntoView = this.state.roomViewStore.initialEventScrollIntoView(); newState.initialEventScrollIntoView = this.roomViewStore.initialEventScrollIntoView();
} }
} }
@@ -903,7 +909,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); this.context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
} }
// Start listening for RoomViewStore updates // 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); this.context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
@@ -1020,7 +1026,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
window.removeEventListener("beforeunload", this.onPageUnload); 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); this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
@@ -1048,6 +1054,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// clean up if this was a local room // clean up if this was a local room
this.context.client?.store.removeRoom(this.state.room.roomId); this.context.client?.store.removeRoom(this.state.room.roomId);
} }
if (this.props.roomId) this.context.multiRoomViewStore.removeRoomViewStore(this.props.roomId);
} }
private onRightPanelStoreUpdate = (): void => { private onRightPanelStoreUpdate = (): void => {
@@ -2070,7 +2078,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room || !this.context?.client) return null; if (!this.state.room || !this.context?.client) return null;
const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId()); const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId());
return ( return (
<ScopedRoomContextProvider {...this.state}> <ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<LocalRoomCreateLoader <LocalRoomCreateLoader
localRoom={localRoom} localRoom={localRoom}
names={names} names={names}
@@ -2082,7 +2090,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private renderLocalRoomView(localRoom: LocalRoom): ReactNode { private renderLocalRoomView(localRoom: LocalRoom): ReactNode {
return ( return (
<ScopedRoomContextProvider {...this.state}> <ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<LocalRoomView <LocalRoomView
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
localRoom={localRoom} localRoom={localRoom}
@@ -2098,7 +2106,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode { private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode {
return ( return (
<ScopedRoomContextProvider {...this.state}> <ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<WaitingForThirdPartyRoomView <WaitingForThirdPartyRoomView
resizeNotifier={this.context.resizeNotifier} resizeNotifier={this.context.resizeNotifier}
roomView={this.roomView} roomView={this.roomView}
@@ -2640,7 +2648,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
return ( return (
<ScopedRoomContextProvider {...this.state}> <ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<div className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}> <div className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
{showChatEffects && this.roomView.current && ( {showChatEffects && this.roomView.current && (
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} /> <EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />

View File

@@ -68,6 +68,8 @@ import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation"; import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
import { KeyboardShortcut } from "../settings/KeyboardShortcut"; import { KeyboardShortcut } from "../settings/KeyboardShortcut";
import { ModuleApi } from "../../../modules/Api.ts";
import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts";
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
@@ -290,6 +292,8 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces(); const [invites, metaSpaces, actualSpaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : []; const activeSpaces = activeSpace ? [activeSpace] : [];
const moduleSpaceItems = useModuleSpacePanelItems(ModuleApi.instance.extras);
const metaSpacesSection = metaSpaces const metaSpacesSection = metaSpaces
.filter((key) => !(key === MetaSpace.VideoRooms && !SettingsStore.getValue("feature_video_rooms"))) .filter((key) => !(key === MetaSpace.VideoRooms && !SettingsStore.getValue("feature_video_rooms")))
.map((key) => { .map((key) => {
@@ -341,6 +345,27 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
</Draggable> </Draggable>
))} ))}
{children} {children}
{moduleSpaceItems.map((item) => (
<li
key={item.spaceKey}
className={classNames("mx_SpaceItem", {
collapsed: isPanelCollapsed,
})}
role="treeitem"
aria-selected={false} // TODO
>
<SpaceButton
{...item}
isNarrow={isPanelCollapsed}
size="32px"
selected={activeSpace === item.spaceKey}
onClick={() => {
SpaceStore.instance.setActiveSpace(item.spaceKey);
item.onSelected?.();
}}
/>
</li>
))}
{shouldShowComponent(UIComponent.CreateSpaces) && ( {shouldShowComponent(UIComponent.CreateSpaces) && (
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} /> <CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
)} )}

View File

@@ -52,6 +52,7 @@ type ButtonProps<T extends keyof HTMLElementTagNameMap> = Omit<
className?: string; className?: string;
selected?: boolean; selected?: boolean;
label: string; label: string;
icon?: JSX.Element;
contextMenuTooltip?: string; contextMenuTooltip?: string;
notificationState?: NotificationState; notificationState?: NotificationState;
isNarrow?: boolean; isNarrow?: boolean;
@@ -65,6 +66,7 @@ export const SpaceButton = <T extends keyof HTMLElementTagNameMap>({
space, space,
spaceKey: _spaceKey, spaceKey: _spaceKey,
className, className,
icon,
selected, selected,
label, label,
contextMenuTooltip, contextMenuTooltip,
@@ -84,7 +86,7 @@ export const SpaceButton = <T extends keyof HTMLElementTagNameMap>({
let avatar = ( let avatar = (
<div className="mx_SpaceButton_avatarPlaceholder"> <div className="mx_SpaceButton_avatarPlaceholder">
<div className="mx_SpaceButton_icon" /> <div className="mx_SpaceButton_icon">{icon}</div>
</div> </div>
); );
if (space) { if (space) {
@@ -143,6 +145,7 @@ export const SpaceButton = <T extends keyof HTMLElementTagNameMap>({
mx_SpaceButton_active: selected, mx_SpaceButton_active: selected,
mx_SpaceButton_hasMenuOpen: menuDisplayed, mx_SpaceButton_hasMenuOpen: menuDisplayed,
mx_SpaceButton_narrow: isNarrow, mx_SpaceButton_narrow: isNarrow,
mx_SpaceButton_withIcon: Boolean(icon),
})} })}
aria-label={label} aria-label={label}
title={!isNarrow || menuDisplayed ? undefined : label} title={!isNarrow || menuDisplayed ? undefined : label}

View File

@@ -10,6 +10,7 @@ import { createContext } from "react";
import { type IRoomState } from "../components/structures/RoomView"; import { type IRoomState } from "../components/structures/RoomView";
import { Layout } from "../settings/enums/Layout"; import { Layout } from "../settings/enums/Layout";
import { type RoomViewStore } from "../stores/RoomViewStore";
export enum TimelineRenderingType { export enum TimelineRenderingType {
Room = "Room", Room = "Room",
@@ -29,11 +30,12 @@ export enum MainSplitContentType {
Call, Call,
} }
const RoomContext = createContext< export interface RoomContextType extends IRoomState {
IRoomState & {
threadId?: string; threadId?: string;
roomViewStore: RoomViewStore;
} }
>({
const RoomContext = createContext<RoomContextType>({
roomLoading: true, roomLoading: true,
peekLoading: false, peekLoading: false,
shouldPeek: true, shouldPeek: true,

View File

@@ -25,6 +25,7 @@ import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore";
import { OidcClientStore } from "../stores/oidc/OidcClientStore"; import { OidcClientStore } from "../stores/oidc/OidcClientStore";
import WidgetStore from "../stores/WidgetStore"; import WidgetStore from "../stores/WidgetStore";
import ResizeNotifier from "../utils/ResizeNotifier"; import ResizeNotifier from "../utils/ResizeNotifier";
import { MultiRoomViewStore } from "../stores/MultiRoomViewStore";
// This context is available to components under MatrixChat, // This context is available to components under MatrixChat,
// the context must not be used by components outside a SdkContextClass tree. // the context must not be used by components outside a SdkContextClass tree.
@@ -66,6 +67,7 @@ export class SdkContextClass {
protected _UserProfilesStore?: UserProfilesStore; protected _UserProfilesStore?: UserProfilesStore;
protected _OidcClientStore?: OidcClientStore; protected _OidcClientStore?: OidcClientStore;
protected _ResizeNotifier?: ResizeNotifier; protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore;
/** /**
* Automatically construct stores which need to be created eagerly so they can register with * Automatically construct stores which need to be created eagerly so they can register with
@@ -183,6 +185,13 @@ export class SdkContextClass {
return this._ResizeNotifier; return this._ResizeNotifier;
} }
public get multiRoomViewStore(): MultiRoomViewStore {
if (!this._MultiRoomViewStore) {
this._MultiRoomViewStore = new MultiRoomViewStore(defaultDispatcher, this);
}
return this._MultiRoomViewStore;
}
public onLoggedOut(): void { public onLoggedOut(): void {
this._UserProfilesStore = undefined; this._UserProfilesStore = undefined;
} }

View File

@@ -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<unknown> {
const cli = MatrixClientPeg.safeGet();
return new AccountDataWatchable(cli, eventType);
}
public async set(eventType: string, content: any): Promise<void> {
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<void> {
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<unknown> {
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);
}
}

View File

@@ -26,6 +26,9 @@ import { WatchableProfile } from "./Profile.ts";
import { NavigationApi } from "./Navigation.ts"; import { NavigationApi } from "./Navigation.ts";
import { openDialog } from "./Dialog.tsx"; import { openDialog } from "./Dialog.tsx";
import { overwriteAccountAuth } from "./Auth.ts"; import { overwriteAccountAuth } from "./Auth.ts";
import { ElementWebExtrasApi } from "./ExtrasApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => { const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false; let used = false;
@@ -79,7 +82,10 @@ export class ModuleApi implements Api {
public readonly config = new ConfigApi(); public readonly config = new ConfigApi();
public readonly i18n = new I18nApi(); public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi(); public readonly customComponents = new CustomComponentsApi();
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly rootNode = document.getElementById("matrixchat")!; public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public createRoot(element: Element): Root { public createRoot(element: Element): Root {
return createRoot(element); return createRoot(element);

View File

@@ -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<RoomViewPropsWithRoomId>;
roomAvatar: React.ComponentType<RoomAvatarProps>;
}
export class ElementWebBuiltinsApi implements BuiltinsApi {
private _roomView?: React.ComponentType<RoomViewPropsWithRoomId>;
private _roomAvatar?: React.ComponentType<RoomAvatarProps>;
/**
* 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<RoomViewPropsWithRoomId> {
if (!this._roomView) {
throw new Error("No RoomView component has been set");
}
return this._roomView;
}
public getRoomAvatarComponent(): React.ComponentType<RoomAvatarProps> {
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 <Component roomId={roomId} />;
}
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 <Component room={room} size={size} />;
}
}

25
src/modules/ClientApi.ts Normal file
View File

@@ -0,0 +1,25 @@
/*
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 {
private accountDataApi?: AccountDataApi;
public getRoom(roomId: string): Room | null {
const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId);
if (sdkRoom) return new ModuleRoom(sdkRoom);
return null;
}
public get accountData(): AccountDataApi {
this.accountDataApi ??= new AccountDataApi();
return this.accountDataApi;
}
}

50
src/modules/ExtrasApi.ts Normal file
View File

@@ -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<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
public spacePanelItems = new Map<string, SpacePanelItemProps>();
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<ModuleSpacePanelItem[]>(getItems);
useTypedEventEmitter(api, ExtrasApiEvent.SpacePanelItemsChanged, () => {
setItems(getItems());
});
return items;
}

View File

@@ -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. 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 { navigateToPermalink } from "../utils/permalinks/navigator.ts";
import { parsePermalink } from "../utils/permalinks/Permalinks.ts"; import { parsePermalink } from "../utils/permalinks/Permalinks.ts";
import dispatcher from "../dispatcher/dispatcher.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"; import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
export class NavigationApi implements INavigationApi { export class NavigationApi implements INavigationApi {
public locationRenderers = new Map<string, LocationRenderFunction>();
public async toMatrixToLink(link: string, join = false): Promise<void> { public async toMatrixToLink(link: string, join = false): Promise<void> {
navigateToPermalink(link); navigateToPermalink(link);
const parts = parsePermalink(link); const parts = parsePermalink(link);
if (parts?.roomIdOrAlias) { if (parts?.roomIdOrAlias) {
if (parts.roomIdOrAlias.startsWith("#")) { this.openRoom(parts.roomIdOrAlias, {
dispatcher.dispatch<ViewRoomPayload>({ viaServers: parts.viaServers ?? undefined,
action: Action.ViewRoom, autoJoin: join,
room_alias: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: join,
metricsTrigger: undefined,
}); });
} else { }
}
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<ViewRoomPayload>({ dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom, action: Action.ViewRoom,
room_id: parts.roomIdOrAlias, [key]: roomIdOrAlias,
via_servers: parts.viaServers ?? undefined, via_servers: opts.viaServers,
auto_join: join, auto_join: opts.autoJoin,
metricsTrigger: undefined, metricsTrigger: undefined,
}); });
} }
} }
}
}

View File

@@ -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<string>;
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<string> {
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);
}
}

View File

@@ -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<string, RoomViewStore> = 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);
}
}
}

View File

@@ -153,6 +153,7 @@ export class RoomViewStore extends EventEmitter {
public constructor( public constructor(
dis: MatrixDispatcher, dis: MatrixDispatcher,
private readonly stores: SdkContextClass, private readonly stores: SdkContextClass,
private readonly lockedToRoomId?: string,
) { ) {
super(); super();
this.resetDispatcher(dis); this.resetDispatcher(dis);
@@ -187,7 +188,7 @@ export class RoomViewStore extends EventEmitter {
const lastRoomId = this.state.roomId; const lastRoomId = this.state.roomId;
this.state = Object.assign(this.state, newState); 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 (lastRoomId) this.emitForRoom(lastRoomId, false);
if (this.state.roomId) this.emitForRoom(this.state.roomId, true); if (this.state.roomId) this.emitForRoom(this.state.roomId, true);
@@ -204,6 +205,9 @@ export class RoomViewStore extends EventEmitter {
} }
private onDispatch(payload: ActionPayload): void { private onDispatch(payload: ActionPayload): void {
if (this.lockedToRoomId && payload.room_id && this.lockedToRoomId !== payload.room_id) {
return;
}
// eslint-disable-line @typescript-eslint/naming-convention // eslint-disable-line @typescript-eslint/naming-convention
switch (payload.action) { switch (payload.action) {
// view_room: // view_room:
@@ -324,7 +328,7 @@ export class RoomViewStore extends EventEmitter {
} }
} }
private async viewRoom(payload: ViewRoomPayload): Promise<void> { public async viewRoom(payload: ViewRoomPayload): Promise<void> {
if (payload.room_id) { if (payload.room_id) {
const room = MatrixClientPeg.safeGet().getRoom(payload.room_id); const room = MatrixClientPeg.safeGet().getRoom(payload.room_id);

View File

@@ -12,15 +12,15 @@ import { NotificationLevel } from "./NotificationLevel";
import { arrayDiff } from "../../utils/arrays"; import { arrayDiff } from "../../utils/arrays";
import { type RoomNotificationState } from "./RoomNotificationState"; import { type RoomNotificationState } from "./RoomNotificationState";
import { NotificationState, NotificationStateEvents } from "./NotificationState"; import { NotificationState, NotificationStateEvents } from "./NotificationState";
import { type FetchRoomFn } from "./ListNotificationState";
import { DefaultTagID } from "../room-list/models"; import { DefaultTagID } from "../room-list/models";
import RoomListStore from "../room-list/RoomListStore"; import RoomListStore from "../room-list/RoomListStore";
import { RoomNotificationStateStore } from "./RoomNotificationStateStore";
export class SpaceNotificationState extends NotificationState { export class SpaceNotificationState extends NotificationState {
public rooms: Room[] = []; // exposed only for tests public rooms: Room[] = []; // exposed only for tests
private states: { [spaceId: string]: RoomNotificationState } = {}; private states: { [spaceId: string]: RoomNotificationState } = {};
public constructor(private getRoomFn: FetchRoomFn) { public constructor() {
super(); super();
} }
@@ -39,7 +39,7 @@ export class SpaceNotificationState extends NotificationState {
state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); state.off(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
} }
for (const newRoom of diff.added) { for (const newRoom of diff.added) {
const state = this.getRoomFn(newRoom); const state = RoomNotificationStateStore.instance.getRoomState(newRoom);
state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate); state.on(NotificationStateEvents.Update, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state; this.states[newRoom.roomId] = state;
} }

View File

@@ -27,7 +27,6 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import RoomListStore from "../room-list/RoomListStore"; import RoomListStore from "../room-list/RoomListStore";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import DMRoomMap from "../../utils/DMRoomMap"; import DMRoomMap from "../../utils/DMRoomMap";
import { type FetchRoomFn } from "../notifications/ListNotificationState";
import { SpaceNotificationState } from "../notifications/SpaceNotificationState"; import { SpaceNotificationState } from "../notifications/SpaceNotificationState";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../room-list/models"; 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 SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; import { type AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
import { SdkContextClass } from "../../contexts/SDKContext"; import { SdkContextClass } from "../../contexts/SDKContext";
import { ModuleApi } from "../../modules/Api.ts";
const ACTIVE_SPACE_LS_KEY = "mx_active_space"; 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 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 = type SpaceStoreActions =
| SettingUpdatedPayload | SettingUpdatedPayload
| ViewRoomPayload | ViewRoomPayload
@@ -258,7 +254,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
if (!space || !this.matrixClient || space === this.activeSpace) return; if (!space || !this.matrixClient || space === this.activeSpace) return;
let cliSpace: Room | null = null; 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); cliSpace = this.matrixClient.getRoom(space);
if (!cliSpace?.isSpaceRoom()) return; if (!cliSpace?.isSpaceRoom()) return;
} else if (!this.enabledMetaSpaces.includes(space)) { } else if (!this.enabledMetaSpaces.includes(space)) {
@@ -293,6 +291,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
context_switch: true, context_switch: true,
metricsTrigger: "WebSpaceContextSwitch", metricsTrigger: "WebSpaceContextSwitch",
}); });
} else if (ModuleApi.instance.extras.spacePanelItems.has(space)) {
// module will handle this
} else { } else {
defaultDispatcher.dispatch<ViewHomePagePayload>({ defaultDispatcher.dispatch<ViewHomePagePayload>({
action: Action.ViewHomePage, action: Action.ViewHomePage,
@@ -1214,7 +1214,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY) as MetaSpace; const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY) as MetaSpace;
const valid = const valid =
lastSpaceId && 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) { if (valid) {
// don't context switch here as it may break permalinks // don't context switch here as it may break permalinks
this.setActiveSpace(lastSpaceId, false); this.setActiveSpace(lastSpaceId, false);
@@ -1369,7 +1370,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
return this.notificationStateMap.get(key)!; return this.notificationStateMap.get(key)!;
} }
const state = new SpaceNotificationState(getRoomFn); const state = new SpaceNotificationState();
this.notificationStateMap.set(key, state); this.notificationStateMap.set(key, state);
return state; return state;
} }

View File

@@ -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. 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"; 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 { export interface ISuggestedRoom extends HierarchyRoom {
viaServers: string[]; viaServers: string[];

View File

@@ -30,6 +30,9 @@ import { ModuleRunner } from "../modules/ModuleRunner";
import { parseQs } from "./url_utils"; import { parseQs } from "./url_utils";
import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing"; import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing";
import { UserFriendlyError } from "../languageHandler"; 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`); 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<MatrixChat>): Promise<ReactElement> { export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<MatrixChat>): Promise<ReactElement> {
// 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(); initRouting();
const platform = PlatformPeg.get(); const platform = PlatformPeg.get();

View File

@@ -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 { type EventTimeline, EventType, type MatrixClient, type MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { type IRoomState, MainSplitContentType } from "../../src/components/structures/RoomView"; import { MainSplitContentType } from "../../src/components/structures/RoomView";
import { TimelineRenderingType } from "../../src/contexts/RoomContext"; import { type RoomContextType, TimelineRenderingType } from "../../src/contexts/RoomContext";
import { Layout } from "../../src/settings/enums/Layout"; import { Layout } from "../../src/settings/enums/Layout";
import { mkEvent } from "./test-utils"; import { mkEvent } from "./test-utils";
import { SdkContextClass } from "../../src/contexts/SDKContext"; import { SdkContextClass } from "../../src/contexts/SDKContext";
@@ -43,7 +43,7 @@ export const makeRoomWithStateEvents = (
return room1; return room1;
}; };
export function getRoomContext(room: Room, override: Partial<IRoomState>): IRoomState { export function getRoomContext(room: Room, override: Partial<RoomContextType>): RoomContextType {
return { return {
roomViewStore: SdkContextClass.instance.roomViewStore, roomViewStore: SdkContextClass.instance.roomViewStore,
room, room,

View File

@@ -650,6 +650,7 @@ export function mkStubRoom(
getJoinedMembers: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]),
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline), getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
getLastLiveEvent: jest.fn().mockReturnValue(undefined), getLastLiveEvent: jest.fn().mockReturnValue(undefined),
getLastActiveTimestamp: jest.fn().mockReturnValue(1183140000),
getMember: jest.fn().mockReturnValue({ getMember: jest.fn().mockReturnValue({
userId: "@member:domain.bla", userId: "@member:domain.bla",
name: "Member", name: "Member",

View File

@@ -15,7 +15,7 @@ import { render } from "jest-matrix-react";
import MessagePanel, { shouldFormContinuation } from "../../../../src/components/structures/MessagePanel"; import MessagePanel, { shouldFormContinuation } from "../../../../src/components/structures/MessagePanel";
import SettingsStore from "../../../../src/settings/SettingsStore"; 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 DMRoomMap from "../../../../src/utils/DMRoomMap";
import * as TestUtilsMatrix from "../../../test-utils"; import * as TestUtilsMatrix from "../../../test-utils";
import { import {
@@ -29,7 +29,6 @@ import {
mockClientPushProcessor, mockClientPushProcessor,
} from "../../../test-utils"; } from "../../../test-utils";
import type ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import type ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import { type IRoomState } from "../../../../src/components/structures/RoomView";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx";
import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts"; import { SdkContextClass } from "../../../../src/contexts/SDKContext.ts";
@@ -92,9 +91,9 @@ describe("MessagePanel", function () {
showAvatarChanges: false, showAvatarChanges: false,
showDisplaynameChanges: true, showDisplaynameChanges: true,
showHiddenEvents: false, showHiddenEvents: false,
} as unknown as IRoomState; } as unknown as RoomContextType;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) => ( const getComponent = (props = {}, roomContext: Partial<RoomContextType> = {}) => (
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}> <ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
<MessagePanel {...defaultProps} {...props} /> <MessagePanel {...defaultProps} {...props} />
</ScopedRoomContextProvider> </ScopedRoomContextProvider>

View File

@@ -89,7 +89,6 @@ describe("RoomView", () => {
let cli: MockedObject<MatrixClient>; let cli: MockedObject<MatrixClient>;
let room: Room; let room: Room;
let rooms: Map<string, Room>; let rooms: Map<string, Room>;
let roomCount = 0;
let stores: SdkContextClass; let stores: SdkContextClass;
let crypto: CryptoApi; let crypto: CryptoApi;
@@ -100,7 +99,9 @@ describe("RoomView", () => {
mockPlatformPeg({ reload: () => {} }); mockPlatformPeg({ reload: () => {} });
cli = mocked(stubClient()); 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"); jest.spyOn(room, "findPredecessor");
room.getPendingEvents = () => []; room.getPendingEvents = () => [];
rooms = new Map(); rooms = new Map();
@@ -158,7 +159,6 @@ describe("RoomView", () => {
threepidInvite={undefined as any} threepidInvite={undefined as any}
forceTimeline={false} forceTimeline={false}
ref={ref} ref={ref}
roomViewStore={stores.roomViewStore}
/> />
</SDKContext.Provider> </SDKContext.Provider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
@@ -197,7 +197,6 @@ describe("RoomView", () => {
threepidInvite={undefined} threepidInvite={undefined}
forceTimeline={false} forceTimeline={false}
onRegistered={jest.fn()} onRegistered={jest.fn()}
roomViewStore={stores.roomViewStore}
/> />
</SDKContext.Provider> </SDKContext.Provider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
@@ -211,6 +210,26 @@ describe("RoomView", () => {
return ref.current!; 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<RoomView>();
render(
<MatrixClientContext.Provider value={cli}>
<SDKContext.Provider value={stores}>
<RoomView
threepidInvite={undefined as any}
forceTimeline={false}
ref={ref}
roomId="!room:example.dummy"
/>
</SDKContext.Provider>
</MatrixClientContext.Provider>,
);
expect(stores.multiRoomViewStore.getRoomViewStoreForRoom).toHaveBeenCalledWith("!room:example.dummy");
});
it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => { it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => {
const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase"); const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase");
await renderRoomView(false); 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 () => { 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); rooms.set(room2.roomId, room2);
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join);

View File

@@ -26,8 +26,8 @@ import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalink
import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../../test-utils"; import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads"; import { mkThread } from "../../../test-utils/threads";
import { type IRoomState } from "../../../../src/components/structures/RoomView";
import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../src/contexts/RoomContext.ts";
jest.mock("../../../../src/utils/Feedback"); jest.mock("../../../../src/utils/Feedback");
@@ -79,7 +79,7 @@ describe("ThreadPanel", () => {
mockRoom.getLastLiveEvent.mockReturnValue(mockEvent); mockRoom.getLastLiveEvent.mockReturnValue(mockEvent);
const roomContextObject = { const roomContextObject = {
room: mockRoom, room: mockRoom,
} as unknown as IRoomState; } as unknown as RoomContextType;
const { container } = render( const { container } = render(
<ScopedRoomContextProvider {...roomContextObject}> <ScopedRoomContextProvider {...roomContextObject}>
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>

View File

@@ -1099,12 +1099,12 @@ exports[`RoomView invites renders an invite room 1`] = `
class="mx_RoomPreviewBar_message" class="mx_RoomPreviewBar_message"
> >
<h3> <h3>
Do you want to join !2:example.org? Do you want to join !roomviewinvitesrendersaninviteroom:example.org?
</h3> </h3>
<p> <p>
<span <span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4" data-color="2"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="presentation" role="presentation"
@@ -1185,7 +1185,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="5" data-color="1"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1212,7 +1212,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!12:example.org !roomviewshouldnotdisplaythetimelinewhentheroomencryptionisloading:example.org
</span> </span>
</div> </div>
</div> </div>
@@ -1397,7 +1397,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="5" data-color="1"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1424,7 +1424,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!12:example.org !roomviewshouldnotdisplaythetimelinewhentheroomencryptionisloading:example.org
</span> </span>
</div> </div>
</div> </div>
@@ -1787,7 +1787,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4" data-color="2"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1814,7 +1814,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!17:example.org !roomviewvideoroomsshouldrenderjoinedvideoroomview:example.org
</span> </span>
</div> </div>
</div> </div>

View File

@@ -15,10 +15,9 @@ import RecordingPlayback, {
PlaybackLayout, PlaybackLayout,
} from "../../../../../src/components/views/audio_messages/RecordingPlayback"; } from "../../../../../src/components/views/audio_messages/RecordingPlayback";
import { Playback } from "../../../../../src/audio/Playback"; 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 { createAudioContext } from "../../../../../src/audio/compat";
import { flushPromises } from "../../../../test-utils"; import { flushPromises } from "../../../../test-utils";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
jest.mock("../../../../../src/WorkerManager", () => ({ jest.mock("../../../../../src/WorkerManager", () => ({
@@ -54,7 +53,10 @@ describe("<RecordingPlayback />", () => {
const mockChannelData = new Float32Array(); 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<typeof RecordingPlayback>, room = defaultRoom) => const getComponent = (props: React.ComponentProps<typeof RecordingPlayback>, room = defaultRoom) =>
render( render(
<ScopedRoomContextProvider {...room}> <ScopedRoomContextProvider {...room}>

View File

@@ -31,8 +31,7 @@ import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMe
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { ConnectionState } from "../../../../../src/models/Call"; import { ConnectionState } from "../../../../../src/models/Call";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext";
import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
describe("<RoomCallBanner />", () => { describe("<RoomCallBanner />", () => {
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
@@ -51,7 +50,7 @@ describe("<RoomCallBanner />", () => {
emit: jest.fn(), emit: jest.fn(),
}; };
let roomContext: IRoomState; let roomContext: RoomContextType;
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
@@ -79,7 +78,7 @@ describe("<RoomCallBanner />", () => {
...RoomContext, ...RoomContext,
roomId: room.roomId, roomId: room.roomId,
roomViewStore: mockRoomViewStore, roomViewStore: mockRoomViewStore,
} as unknown as IRoomState; } as unknown as RoomContextType;
}); });
afterEach(async () => { afterEach(async () => {

View File

@@ -27,8 +27,7 @@ import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import { canEditContent } from "../../../../../src/utils/EventUtils"; import { canEditContent } from "../../../../../src/utils/EventUtils";
import { copyPlaintext, getSelectedText } from "../../../../../src/utils/strings"; import { copyPlaintext, getSelectedText } from "../../../../../src/utils/strings";
import MessageContextMenu from "../../../../../src/components/views/context_menus/MessageContextMenu"; import MessageContextMenu from "../../../../../src/components/views/context_menus/MessageContextMenu";
@@ -711,18 +710,18 @@ describe("MessageContextMenu", () => {
}); });
}); });
function createRightClickMenuWithContent(eventContent: object, context?: Partial<IRoomState>): RenderResult { function createRightClickMenuWithContent(eventContent: object, context?: Partial<RoomContextType>): RenderResult {
return createMenuWithContent(eventContent, { rightClick: true }, context); return createMenuWithContent(eventContent, { rightClick: true }, context);
} }
function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial<IRoomState>): RenderResult { function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial<RoomContextType>): RenderResult {
return createMenu(mxEvent, { rightClick: true }, context); return createMenu(mxEvent, { rightClick: true }, context);
} }
function createMenuWithContent( function createMenuWithContent(
eventContent: object, eventContent: object,
props?: Partial<MessageContextMenu["props"]>, props?: Partial<MessageContextMenu["props"]>,
context?: Partial<IRoomState>, context?: Partial<RoomContextType>,
): RenderResult { ): RenderResult {
// XXX: We probably shouldn't be assuming all events are going to be message events, but considering this // 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. // test is for the Message context menu, it's a fairly safe assumption.
@@ -739,7 +738,7 @@ function makeDefaultRoom(): Room {
function createMenu( function createMenu(
mxEvent: MatrixEvent, mxEvent: MatrixEvent,
props?: Partial<MessageContextMenu["props"]>, props?: Partial<MessageContextMenu["props"]>,
context: Partial<IRoomState> = {}, context: Partial<RoomContextType> = {},
beacons: Map<BeaconIdentifier, Beacon> = new Map(), beacons: Map<BeaconIdentifier, Beacon> = new Map(),
room: Room = makeDefaultRoom(), room: Room = makeDefaultRoom(),
): RenderResult { ): RenderResult {
@@ -754,7 +753,7 @@ function createMenu(
client.getRoom = jest.fn().mockReturnValue(room); client.getRoom = jest.fn().mockReturnValue(room);
return render( return render(
<ScopedRoomContextProvider {...(context as IRoomState)}> <ScopedRoomContextProvider {...(context as RoomContextType)}>
<MessageContextMenu mxEvent={mxEvent} onFinished={jest.fn()} {...props} /> <MessageContextMenu mxEvent={mxEvent} onFinished={jest.fn()} {...props} />
</ScopedRoomContextProvider>, </ScopedRoomContextProvider>,
); );

View File

@@ -26,8 +26,7 @@ import {
} from "../../../../test-utils"; } from "../../../../test-utils";
import DateSeparator from "../../../../../src/components/views/messages/DateSeparator"; import DateSeparator from "../../../../../src/components/views/messages/DateSeparator";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext";
import { type IRoomState } from "../../../../../src/components/structures/RoomView"; import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
jest.mock("../../../../../src/settings/SettingsStore"); jest.mock("../../../../../src/settings/SettingsStore");
@@ -50,7 +49,7 @@ describe("DateSeparator", () => {
...RoomContext, ...RoomContext,
roomId, roomId,
roomViewStore: mockRoomViewStore, roomViewStore: mockRoomViewStore,
} as unknown as IRoomState; } as unknown as RoomContextType;
const mockClient = getMockClientWithEventEmitter({ const mockClient = getMockClientWithEventEmitter({
timestampToEvent: jest.fn(), timestampToEvent: jest.fn(),

View File

@@ -29,8 +29,7 @@ import {
makeBeaconInfoEvent, makeBeaconInfoEvent,
} from "../../../../test-utils"; } from "../../../../test-utils";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import RoomContext, { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import dispatcher from "../../../../../src/dispatcher/dispatcher"; import dispatcher from "../../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
@@ -115,8 +114,8 @@ describe("<MessageActionBar />", () => {
canSendMessages: true, canSendMessages: true,
canReact: true, canReact: true,
room, room,
} as unknown as IRoomState; } as unknown as RoomContextType;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) => const getComponent = (props = {}, roomContext: Partial<RoomContextType> = {}) =>
render( render(
<ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}> <ScopedRoomContextProvider {...defaultRoomContext} {...roomContext}>
<MessageActionBar {...defaultProps} {...props} /> <MessageActionBar {...defaultProps} {...props} />

View File

@@ -27,12 +27,12 @@ import {
import DocumentOffset from "../../../../../src/editor/offset"; import DocumentOffset from "../../../../../src/editor/offset";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import Autocompleter, { type IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter"; import Autocompleter, { type IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter";
import NotifProvider from "../../../../../src/autocomplete/NotifProvider"; import NotifProvider from "../../../../../src/autocomplete/NotifProvider";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
describe("<EditMessageComposer/>", () => { describe("<EditMessageComposer/>", () => {
const userId = "@alice:server.org"; const userId = "@alice:server.org";
@@ -75,7 +75,7 @@ describe("<EditMessageComposer/>", () => {
const defaultRoomContext = getRoomContext(room, {}); const defaultRoomContext = getRoomContext(room, {});
const getComponent = (editState: EditorStateTransfer, roomContext: IRoomState = defaultRoomContext) => const getComponent = (editState: EditorStateTransfer, roomContext: RoomContextType = defaultRoomContext) =>
render(<EditMessageComposerWithMatrixClient editState={editState} />, { render(<EditMessageComposerWithMatrixClient editState={editState} />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>

View File

@@ -30,14 +30,13 @@ import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing";
import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; 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 { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils"; import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils";
import { mkThread } from "../../../../test-utils/threads"; import { mkThread } from "../../../../test-utils/threads";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import dis from "../../../../../src/dispatcher/dispatcher"; import dis from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import PinningUtils from "../../../../../src/utils/PinningUtils"; import PinningUtils from "../../../../../src/utils/PinningUtils";
import { Layout } from "../../../../../src/settings/enums/Layout"; import { Layout } from "../../../../../src/settings/enums/Layout";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; 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. */ /** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */
function WrappedEventTile(props: { function WrappedEventTile(props: {
roomContext: IRoomState; roomContext: RoomContextType;
eventTilePropertyOverrides?: Partial<EventTileProps>; eventTilePropertyOverrides?: Partial<EventTileProps>;
}) { }) {
return ( return (
@@ -71,7 +70,7 @@ describe("EventTile", () => {
function getComponent( function getComponent(
overrides: Partial<EventTileProps> = {}, overrides: Partial<EventTileProps> = {},
renderingType: TimelineRenderingType = TimelineRenderingType.Room, renderingType: TimelineRenderingType = TimelineRenderingType.Room,
roomContext: Partial<IRoomState> = {}, roomContext: Partial<RoomContextType> = {},
) { ) {
const context = getRoomContext(room, { const context = getRoomContext(room, {
timelineRenderingType: renderingType, timelineRenderingType: renderingType,

View File

@@ -24,7 +24,6 @@ import {
import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer"; import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier"; import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { LocalRoom } from "../../../../../src/models/LocalRoom"; 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 UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
import { Action } from "../../../../../src/dispatcher/actions"; import { Action } from "../../../../../src/dispatcher/actions";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
const openStickerPicker = async (): Promise<void> => { const openStickerPicker = async (): Promise<void> => {
await userEvent.click(screen.getByLabelText("More options")); await userEvent.click(screen.getByLabelText("More options"));
@@ -155,7 +155,7 @@ describe("MessageComposer", () => {
}); });
describe("when receiving a »reply_to_event«", () => { describe("when receiving a »reply_to_event«", () => {
let roomContext: IRoomState; let roomContext: RoomContextType;
let resizeNotifier: ResizeNotifier; let resizeNotifier: ResizeNotifier;
beforeEach(() => { beforeEach(() => {
@@ -458,7 +458,7 @@ function wrapAndRender(
canSendMessages, canSendMessages,
tombstone, tombstone,
narrow, narrow,
} as unknown as IRoomState; } as unknown as RoomContextType;
const defaultProps = { const defaultProps = {
room, room,

View File

@@ -11,10 +11,10 @@ import { render, screen, waitFor } from "jest-matrix-react";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-utils"; import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-utils";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
describe("MessageComposerButtons", () => { describe("MessageComposerButtons", () => {
// @ts-ignore - we're deliberately not implementing the whole interface here, but // @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) { function wrapAndRender(component: React.ReactElement, narrow: boolean) {
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { narrow }); const defaultRoomContext: RoomContextType = getRoomContext(mockRoom, { narrow });
return render( return render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>

View File

@@ -22,16 +22,16 @@ import {
} from "../../../../test-utils"; } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import NewRoomIntro from "../../../../../src/components/views/rooms/NewRoomIntro"; import NewRoomIntro from "../../../../../src/components/views/rooms/NewRoomIntro";
import { type IRoomState } from "../../../../../src/components/structures/RoomView";
import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { DirectoryMember } from "../../../../../src/utils/direct-messages"; import { DirectoryMember } from "../../../../../src/utils/direct-messages";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => { const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => {
render( render(
<MatrixClientContext.Provider value={client}> <MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...({ room, roomId: room.roomId } as unknown as IRoomState)}> <ScopedRoomContextProvider {...({ room, roomId: room.roomId } as unknown as RoomContextType)}>
<NewRoomIntro /> <NewRoomIntro />
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,

View File

@@ -41,8 +41,7 @@ import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/
import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext";
import { type IRoomState } from "../../../../../../src/components/structures/RoomView"; import RoomContext, { type RoomContextType } from "../../../../../../src/contexts/RoomContext";
import RoomContext from "../../../../../../src/contexts/RoomContext";
import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../../../../src/stores/right-panel/RightPanelStorePhases";
import LegacyCallHandler from "../../../../../../src/LegacyCallHandler"; import LegacyCallHandler from "../../../../../../src/LegacyCallHandler";
@@ -85,7 +84,7 @@ describe("RoomHeader", () => {
emit: jest.fn(), emit: jest.fn(),
}; };
let roomContext: IRoomState; let roomContext: RoomContextType;
function getWrapper(): RenderOptions { function getWrapper(): RenderOptions {
return { return {
@@ -121,7 +120,7 @@ describe("RoomHeader", () => {
...RoomContext, ...RoomContext,
roomId: ROOM_ID, roomId: ROOM_ID,
roomViewStore: mockRoomViewStore, roomViewStore: mockRoomViewStore,
} as unknown as IRoomState; } as unknown as RoomContextType;
}); });
afterEach(() => { afterEach(() => {

View File

@@ -17,7 +17,7 @@ import SendMessageComposer, {
isQuickReaction, isQuickReaction,
} from "../../../../../src/components/views/rooms/SendMessageComposer"; } from "../../../../../src/components/views/rooms/SendMessageComposer";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; 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 EditorModel from "../../../../../src/editor/model";
import { createPartCreator } from "../../../editor/mock"; import { createPartCreator } from "../../../editor/mock";
import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils"; import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils";
@@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import DocumentOffset from "../../../../../src/editor/offset"; import DocumentOffset from "../../../../../src/editor/offset";
import { Layout } from "../../../../../src/settings/enums/Layout"; 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 { mockPlatformPeg } from "../../../../test-utils/platform";
import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
import { addTextToComposer } from "../../../../test-utils/composer"; import { addTextToComposer } from "../../../../test-utils/composer";
@@ -37,7 +37,7 @@ jest.mock("../../../../../src/utils/local-room", () => ({
})); }));
describe("<SendMessageComposer/>", () => { describe("<SendMessageComposer/>", () => {
const defaultRoomContext: IRoomState = { const defaultRoomContext: RoomContextType = {
roomViewStore: SdkContextClass.instance.roomViewStore, roomViewStore: SdkContextClass.instance.roomViewStore,
roomLoading: true, roomLoading: true,
peekLoading: false, peekLoading: false,

View File

@@ -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 { type EventTimeline, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../../test-utils"; import { getRoomContext, mkEvent, mkStubRoom, stubClient } from "../../../../../test-utils";
import { type IRoomState } from "../../../../../../src/components/structures/RoomView";
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer"; import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
import type { RoomContextType } from "../../../../../../src/contexts/RoomContext";
export function createMocks(eventContent = "Replying <strong>to</strong> this new content") { export function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
const mockClient = stubClient(); const mockClient = stubClient();
@@ -31,7 +31,7 @@ export function createMocks(eventContent = "Replying <strong>to</strong> this ne
return eventId === mockEvent.getId() ? mockEvent : null; return eventId === mockEvent.getId() ? mockEvent : null;
}); });
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { const defaultRoomContext: RoomContextType = getRoomContext(mockRoom, {
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline, liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
}); });

View File

@@ -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);
});
});

View File

@@ -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 (
<div>
Avatar, {room.roomId}, {size}
</div>
);
};
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 = () => <div>hello world</div>;
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");
});
});

View File

@@ -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");
});
});

View File

@@ -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",
}),
);
});
}); });
}); });

View File

@@ -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);
});
});
});

View File

@@ -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<RoomViewStore>;
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<typeof RoomViewStore>).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<typeof RoomViewStore>).mockImplementation(
() => mockRoomViewStore as any,
);
multiRoomViewStore.getRoomViewStoreForRoom(roomId);
expect(RoomViewStore).toHaveBeenCalledWith(mockDispatcher, mockSdkContext, roomId);
});
});
});

View File

@@ -1602,10 +1602,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.16.1.tgz#28bdbde426051cc2a3228a36e7196e0a254569d3" 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== integrity sha512-g3v/QFuNy8YVRGrKC5SxjIYvgBh6biOHgejhJT2Jk/yjOOUEuP0y2PBaADm+suPD9BB/Vk1jPxFk2uEIpEzhpA==
"@element-hq/element-web-module-api@1.4.1": "@element-hq/element-web-module-api@1.5.0":
version "1.4.1" version "1.5.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.4.1.tgz#a46526d58985190f9989bf1686ea872687d3c6e1" resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.5.0.tgz#077a528917f4eb558059a2a5286b9bb6a2fb1690"
integrity sha512-A8yaQtX7QoKThzzZVU+VYOFhpiNyppEMuIQijK48RvhVp1nwmy0cTD6u/6Yn64saNwJjtna+Oy+Qzo/TfwwhxQ== integrity sha512-WI/iMADRouXp9WhQy5jov6Z4eKKlHEPh20DKoCsKZ9dWaYcW/MiBhzi09PZxay+o0RLZXA6aDPxpxaIX3lZXag==
"@element-hq/element-web-playwright-common@^2.0.0": "@element-hq/element-web-playwright-common@^2.0.0":
version "2.0.0" version "2.0.0"
@@ -4085,13 +4085,14 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm": "@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0" version "0.0.0"
uid ""
"@vector-im/matrix-wysiwyg@2.40.0": "@vector-im/matrix-wysiwyg@2.40.0":
version "2.40.0" version "2.40.0"
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c"
integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug==
dependencies: dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
"@vitest/expect@3.2.4": "@vitest/expect@3.2.4":
version "3.2.4" version "3.2.4"