Files
Michael TelatynskiandGitHub b5fd249e38 Remove more usages of singleton store getter in favour of contexts (#34099)
* Expose SDKContextClass via window for debugging

* Remove stores from window if they are exposed via sdkContext

* Avoid usages of global store instance where React context is accessible

* Remove more usages of singleton store getter in favour of contexts

* Remove more usages of singleton store getter in favour of contexts

* Fix tests by adding SDKContext.Provider

* Fix tests by adding SDKContext.Provider

* Fix tests by adding SDKContext.Provider

* Fix tests by adding SDKContext.Provider

* Fix tests

* Fix tests

* Fix tests

* Iterate

* Fix bad merge

* Iterate

* Fix tests

* Iterate

* Iterate

* Iterate

* Iterate

* Iterate

* Improve coverage

* Improve coverage
2026-07-14 12:58:34 +00:00

569 lines
23 KiB
TypeScript

/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
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, type RenderResult } from "jest-matrix-react";
import {
ConditionKind,
EventType,
type IPushRule,
MatrixEvent,
ClientEvent,
PushRuleKind,
ProfileKeyTimezone,
ProfileKeyMSC4175Timezone,
SyncState,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { logger } from "matrix-js-sdk/src/logger";
import userEvent from "@testing-library/user-event";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import LoggedInView from "../../../../src/components/structures/LoggedInView";
import { SDKContext } from "../../../../src/contexts/SDKContext";
import { StandardActions } from "../../../../src/notifications/StandardActions";
import ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsRooms,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test-utils";
import { TestSDKContext } from "../../TestSDKContext";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Action } from "../../../../src/dispatcher/actions";
import Modal from "../../../../src/Modal";
import { SETTINGS } from "../../../../src/settings/Settings";
import ToastStore from "../../../../src/stores/ToastStore";
import { ModuleApi } from "../../../../src/modules/Api";
import { fireEvent } from "@testing-library/dom";
describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsRooms([]),
getAccountData: jest.fn(),
getSyncState: jest.fn().mockReturnValue(null),
getSyncStateData: jest.fn().mockReturnValue(null),
getMediaHandler: jest.fn(),
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getCrypto: jest.fn().mockReturnValue(undefined),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
matrixRTC: {
on: jest.fn(),
},
getAuthMetadata: jest.fn().mockRejectedValue(new Error("Legacy auth")),
hasLazyLoadMembersEnabled: jest.fn(),
isInitialSyncComplete: jest.fn(),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSDKContext();
const defaultProps = {
matrixClient: mockClient,
onRegistered: jest.fn(),
resizeNotifier: new ResizeNotifier(),
hideToSRUsers: false,
config: {
brand: "Test",
},
currentRoomId: "",
currentUserId: "@bob:server",
};
const getComponent = (props = {}): RenderResult =>
render(<LoggedInView {...defaultProps} {...props} />, {
wrapper: ({ children }) => <SDKContext.Provider value={mockSdkContext}>{children}</SDKContext.Provider>,
});
beforeEach(() => {
jest.clearAllMocks();
mockClient.getMediaHandler.mockReturnValue(mediaHandler);
mockClient.setPushRuleActions.mockReset().mockResolvedValue({});
// @ts-expect-error
mockClient.pushProcessor = new PushProcessor(mockClient);
mockSdkContext._client = mockClient;
});
describe("synced push rules", () => {
const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules });
const oneToOneRule = {
conditions: [
{ kind: ConditionKind.RoomMemberCount, is: "2" },
{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" },
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const oneToOneRuleDisabled = {
...oneToOneRule,
enabled: false,
};
const groupRule = {
conditions: [{ kind: ConditionKind.EventMatch, key: "type", pattern: "m.room.message" }],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".m.rule.message",
default: true,
enabled: true,
} as IPushRule;
const pollStartOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_NOTIFY,
rule_id: ".org.matrix.msc3930.rule.poll_start_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollEndOneToOne = {
conditions: [
{
kind: ConditionKind.RoomMemberCount,
is: "2",
},
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.end",
},
],
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_end_one_to_one",
default: true,
enabled: true,
} as IPushRule;
const pollStartGroup = {
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3381.poll.start",
},
],
actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
rule_id: ".org.matrix.msc3930.rule.poll_start",
default: true,
enabled: true,
} as IPushRule;
beforeEach(() => {
mockClient.getAccountData.mockImplementation((eventType: string) =>
eventType === EventType.PushRules ? pushRulesEvent : undefined,
);
setPushRules([]);
// stub out error logger to avoid littering console
jest.spyOn(logger, "error")
.mockClear()
.mockImplementation(() => {});
mockClient.setPushRuleActions.mockClear();
mockClient.setPushRuleEnabled.mockClear();
});
const setPushRules = (rules: IPushRule[] = []): void => {
const pushRules = {
global: {
underride: [...rules],
},
};
mockClient.pushRules = pushRules;
};
describe("on mount", () => {
it("handles when user has no push rules event in account data", () => {
mockClient.getAccountData.mockReturnValue(undefined);
getComponent();
expect(mockClient.getAccountData).toHaveBeenCalledWith(EventType.PushRules);
expect(logger.error).not.toHaveBeenCalled();
});
it("handles when user doesnt have a push rule defined in vector definitions", () => {
// synced push rules uses VectorPushRulesDefinitions
// rules defined there may not exist in m.push_rules
// mock push rules with group rule, but missing oneToOne rule
setPushRules([pollStartOneToOne, groupRule, pollStartGroup]);
getComponent();
// just called once for one-to-one
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(1);
// set to match primary rule (groupRule)
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules", () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
// only called for rules not in sync with their primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules when primary rule is disabled", async () => {
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollStartOneToOne.rule_id,
false,
);
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
PushRuleKind.Underride,
pollEndOneToOne.rule_id,
false,
);
});
it("catches and logs errors while updating a rule", async () => {
mockClient.setPushRuleActions.mockRejectedValueOnce("oups").mockResolvedValueOnce({});
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollStartOneToOne, // on
pollEndOneToOne, // loud
// poll group rules are synced with groupRule
groupRule, // on
pollStartGroup, // loud
]);
getComponent();
await flushPromises();
expect(mockClient.setPushRuleActions).toHaveBeenCalledTimes(2);
// both calls made
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollStartGroup.rule_id,
StandardActions.ACTION_NOTIFY,
);
// second primary rule still updated after first rule failed
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
expect(logger.error).toHaveBeenCalledWith(
"Failed to fully synchronise push rules for .m.rule.room_one_to_one",
"oups",
);
});
});
describe("on changes to account_data", () => {
it("ignores other account data events", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
const someOtherAccountData = new MatrixEvent({ type: "my-test-account-data " });
mockClient.emit(ClientEvent.AccountData, someOtherAccountData);
// didnt check rule sync
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
it("updates all mismatched rules from synced rules on a change to push rules account data", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
getComponent();
mockClient.setPushRuleActions.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleActions).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
StandardActions.ACTION_NOTIFY,
);
});
it("updates all mismatched rules from synced rules on a change to push rules account data when primary rule is disabled", async () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRuleDisabled, // off
pollEndOneToOne, // loud
]);
getComponent();
await flushPromises();
mockClient.setPushRuleEnabled.mockClear();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// set to match primary rule
expect(mockClient.setPushRuleEnabled).toHaveBeenCalledWith(
"global",
"underride",
pollEndOneToOne.rule_id,
false,
);
});
it("stops listening to account data events on unmount", () => {
// setup a push rule state with mismatched rules
setPushRules([
// poll 1-1 rules are synced with oneToOneRule
oneToOneRule, // on
pollEndOneToOne, // loud
]);
const { unmount } = getComponent();
mockClient.setPushRuleActions.mockClear();
unmount();
mockClient.emit(ClientEvent.AccountData, pushRulesEvent);
// not called
expect(mockClient.setPushRuleActions).not.toHaveBeenCalled();
});
});
});
it("should fire FocusMessageSearch on Ctrl+F when enabled", async () => {
jest.spyOn(defaultDispatcher, "fire");
await SettingsStore.setValue("ctrlFForSearch", null, SettingLevel.DEVICE, true);
getComponent();
await userEvent.keyboard("{Control>}f{/Control}");
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusMessageSearch);
});
it("should go home on home shortcut", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
it("should ignore home shortcut if dialogs are open", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
jest.spyOn(Modal, "hasDialogs").mockReturnValue(true);
getComponent();
await userEvent.keyboard("{Control>}{Alt>}h</Alt>{/Control}");
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage });
});
it("should open spotlight when Ctrl+k is fired", async () => {
jest.spyOn(defaultDispatcher, "fire");
getComponent();
await userEvent.keyboard("{Control>}k{/Control}");
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.OpenSpotlight);
});
describe("timezone updates", () => {
const userTimezone = "Europe/London";
const originalController = SETTINGS["userTimezonePublish"].controller;
beforeEach(async () => {
SETTINGS["userTimezonePublish"].controller = undefined;
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone);
});
afterEach(() => {
SETTINGS["userTimezonePublish"].controller = originalController;
});
it("does not update the timezone when userTimezonePublish is off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone);
expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled();
});
it("should set the user timezone when userTimezonePublish is enabled", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone, userTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone, userTimezone);
});
it("should set the user timezone when the timezone is changed", async () => {
const newTimezone = "Europe/Paris";
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone, userTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone, userTimezone);
await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone, newTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone, newTimezone);
});
it("should clear the timezone when the publish feature is turned off", async () => {
getComponent();
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone, userTimezone);
expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone, userTimezone);
await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyTimezone);
expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith(ProfileKeyMSC4175Timezone);
});
});
describe("resource limit exceeded errors", () => {
it("pops a toast when M_RESOURCE_LIMIT_EXCEEDED is seen down sync", async () => {
const addOrReplaceToast = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
const dismissToast = jest.spyOn(ToastStore.sharedInstance(), "dismissToast");
getComponent();
mockClient.emit(ClientEvent.Sync, SyncState.Error, null, {
error: new MatrixError({
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
limit_type: "hs_disabled",
admin_contact: "admin@example.org",
}),
});
expect(addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: "serverlimit",
title: "Warning",
}),
);
mockClient.emit(ClientEvent.Sync, SyncState.Prepared, null, undefined);
expect(dismissToast).toHaveBeenCalledWith("serverlimit");
});
});
describe("module-rendered fullscreen view (e.g. multiroom)", () => {
// A page_type for which a module registers a custom full-screen renderer.
const modulePageType = "io.element.test_fullscreen";
beforeEach(() => {
ModuleApi.instance.navigation.registerLocationRenderer(modulePageType, () => (
<div data-testid="module-content" />
));
});
afterEach(() => {
ModuleApi.instance.navigation.locationRenderers.delete(modulePageType);
});
it("renders the resizable separator for a normal room view with the new room list", () => {
const { container } = getComponent({ page_type: "room" });
// With the new room list and no module renderer, the resizable left panel and its separator are used.
expect(container.querySelector(".mx_Separator")).toBeInTheDocument();
});
it("does not render the resizer for a module-rendered fullscreen view, but keeps the space panel", () => {
const { container, getByTestId } = getComponent({ page_type: modulePageType });
// The module's full-screen content is shown...
expect(getByTestId("module-content")).toBeInTheDocument();
// ...without the resizable separator or the legacy resize handle...
expect(container.querySelector(".mx_Separator")).not.toBeInTheDocument();
expect(container.querySelector(".mx_ResizeHandle")).not.toBeInTheDocument();
// ...while the space panel rail remains visible.
expect(container.querySelector(".mx_SpacePanel")).toBeInTheDocument();
});
});
it("should handle KeyBindingAction.ToggleRoomSidePanel", async () => {
getComponent({ page_type: "room_view" });
jest.spyOn(mockSdkContext.rightPanelStore, "togglePanel");
fireEvent.keyDown(document.body, { key: ".", code: "Period", ctrlKey: true, keyCode: 190 });
expect(mockSdkContext.rightPanelStore.togglePanel).toHaveBeenCalledWith(null);
});
});