diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f7c23332fde..cb944a00d41 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -70,6 +70,7 @@ import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning"; import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage"; import { PipContainer } from "./PipContainer"; +import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; // 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. @@ -165,6 +166,8 @@ class LoggedInView extends React.Component { this.updateServerNoticeEvents(); this._matrixClient.on(ClientEvent.AccountData, this.onAccountData); + // check push rules on start up as well + monitorSyncedPushRules(this._matrixClient.getAccountData("m.push_rules"), this._matrixClient); this._matrixClient.on(ClientEvent.Sync, this.onSync); // Call `onSync` with the current state as well this.onSync(this._matrixClient.getSyncState(), null, this._matrixClient.getSyncStateData()); @@ -279,6 +282,7 @@ class LoggedInView extends React.Component { if (event.getType() === "m.ignored_user_list") { dis.dispatch({ action: "ignore_state_changed" }); } + monitorSyncedPushRules(event, this._matrixClient); }; private onCompactLayoutChanged = (): void => { diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 35c7fa3d49b..3aa4edf8a43 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -15,18 +15,10 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import { - IAnnotatedPushRule, - IPusher, - PushRuleAction, - IPushRule, - PushRuleKind, - RuleId, -} from "matrix-js-sdk/src/@types/PushRules"; +import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules"; import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import { logger } from "matrix-js-sdk/src/logger"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; -import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import Spinner from "../elements/Spinner"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -51,6 +43,10 @@ import TagComposer from "../elements/TagComposer"; import { objectClone } from "../../../utils/objects"; import { arrayDiff } from "../../../utils/arrays"; import { clearAllNotifications, getLocalNotificationAccountDataEventType } from "../../../utils/notifications"; +import { + updateExistingPushRulesWithActions, + updatePushRuleActions, +} from "../../../utils/pushRules/updatePushRuleActions"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. @@ -187,7 +183,6 @@ const maximumVectorState = ( export default class Notifications extends React.PureComponent { private settingWatchers: string[]; - private pushProcessor: PushProcessor; public constructor(props: IProps) { super(props); @@ -215,8 +210,6 @@ export default class Notifications extends React.PureComponent { this.setState({ audioNotifications: value as boolean }), ), ]; - - this.pushProcessor = new PushProcessor(MatrixClientPeg.get()); } private get isInhibited(): boolean { @@ -461,43 +454,6 @@ export default class Notifications extends React.PureComponent { await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked); }; - private setPushRuleActions = async ( - ruleId: IPushRule["rule_id"], - kind: PushRuleKind, - actions?: PushRuleAction[], - ): Promise => { - const cli = MatrixClientPeg.get(); - if (!actions) { - await cli.setPushRuleEnabled("global", kind, ruleId, false); - } else { - await cli.setPushRuleActions("global", kind, ruleId, actions); - await cli.setPushRuleEnabled("global", kind, ruleId, true); - } - }; - - /** - * Updated syncedRuleIds from rule definition - * If a rule does not exist it is ignored - * Synced rules are updated sequentially - * and stop at first error - */ - private updateSyncedRules = async ( - syncedRuleIds: VectorPushRuleDefinition["syncedRuleIds"], - actions?: PushRuleAction[], - ): Promise => { - // get synced rules that exist for user - const syncedRules: ReturnType[] = syncedRuleIds - ?.map((ruleId) => this.pushProcessor.getPushRuleAndKindById(ruleId)) - .filter(Boolean); - - if (!syncedRules?.length) { - return; - } - for (const { kind, rule: syncedRule } of syncedRules) { - await this.setPushRuleActions(syncedRule.rule_id, kind, actions); - } - }; - private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise => { this.setState({ phase: Phase.Persisting }); @@ -538,8 +494,13 @@ export default class Notifications extends React.PureComponent { } else { const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.ruleId]; const actions = definition.vectorStateToActions[checkedState]; - await this.setPushRuleActions(rule.rule.rule_id, rule.rule.kind, actions); - await this.updateSyncedRules(definition.syncedRuleIds, actions); + // we should not encounter this + // satisfies types + if (!rule.rule) { + throw new Error("Cannot update rule: push rule data is incomplete."); + } + await updatePushRuleActions(cli, rule.rule.rule_id, rule.rule.kind, actions); + await updateExistingPushRulesWithActions(cli, definition.syncedRuleIds, actions); } await this.refreshFromServer(); diff --git a/src/utils/pushRules/monitorSyncedPushRules.ts b/src/utils/pushRules/monitorSyncedPushRules.ts new file mode 100644 index 00000000000..9061d74a8f4 --- /dev/null +++ b/src/utils/pushRules/monitorSyncedPushRules.ts @@ -0,0 +1,108 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; +import { RuleId, IAnnotatedPushRule } from "matrix-js-sdk/src/@types/PushRules"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { VectorPushRulesDefinitions, VectorPushRuleDefinition } from "../../notifications"; +import { updateExistingPushRulesWithActions } from "./updatePushRuleActions"; + +const pushRuleAndKindToAnnotated = ( + ruleAndKind: ReturnType, +): IAnnotatedPushRule | undefined => + ruleAndKind + ? { + ...ruleAndKind.rule, + kind: ruleAndKind.kind, + } + : undefined; + +/** + * Checks that any synced rules that exist a given rule are in sync + * And updates any that are out of sync + * Ignores ruleIds that do not exist for the user + * @param matrixClient - cli + * @param pushProcessor - processor used to retrieve current state of rules + * @param ruleId - primary rule + * @param definition - VectorPushRuleDefinition of the primary rule + */ +const monitorSyncedRule = async ( + matrixClient: MatrixClient, + pushProcessor: PushProcessor, + ruleId: RuleId | string, + definition: VectorPushRuleDefinition, +): Promise => { + const primaryRule = pushRuleAndKindToAnnotated(pushProcessor.getPushRuleAndKindById(ruleId)); + + if (!primaryRule) { + return; + } + const syncedRules: IAnnotatedPushRule[] | undefined = definition.syncedRuleIds + ?.map((ruleId) => pushRuleAndKindToAnnotated(pushProcessor.getPushRuleAndKindById(ruleId))) + .filter((n?: IAnnotatedPushRule): n is IAnnotatedPushRule => Boolean(n)); + + // no synced rules to manage + if (!syncedRules?.length) { + return; + } + + const primaryRuleVectorState = definition.ruleToVectorState(primaryRule); + + const outOfSyncRules = syncedRules.filter( + (syncedRule) => definition.ruleToVectorState(syncedRule) !== primaryRuleVectorState, + ); + + if (outOfSyncRules.length) { + await updateExistingPushRulesWithActions( + matrixClient, + // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention + outOfSyncRules.map(({ rule_id }) => rule_id), + primaryRule.actions, + ); + } +}; + +/** + * On changes to m.push_rules account data, + * check that synced push rules are in sync with their primary rule, + * and update any out of sync rules. + * synced rules are defined in VectorPushRulesDefinitions + * If updating a rule fails for any reason, + * the error is caught and handled silently + * @param accountDataEvent - MatrixEvent + * @param matrixClient - cli + * @returns Resolves when updates are complete + */ +export const monitorSyncedPushRules = async ( + accountDataEvent: MatrixEvent | undefined, + matrixClient: MatrixClient, +): Promise => { + if (accountDataEvent?.getType() !== EventType.PushRules) { + return; + } + const pushProcessor = new PushProcessor(matrixClient); + + Object.entries(VectorPushRulesDefinitions).forEach(async ([ruleId, definition]) => { + try { + await monitorSyncedRule(matrixClient, pushProcessor, ruleId, definition); + } catch (error) { + logger.error(`Failed to fully synchronise push rules for ${ruleId}`, error); + } + }); +}; diff --git a/src/utils/pushRules/updatePushRuleActions.ts b/src/utils/pushRules/updatePushRuleActions.ts new file mode 100644 index 00000000000..e4ea970c39e --- /dev/null +++ b/src/utils/pushRules/updatePushRuleActions.ts @@ -0,0 +1,75 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { IPushRule, PushRuleAction, PushRuleKind } from "matrix-js-sdk/src/matrix"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; + +/** + * Sets the actions for a given push rule id and kind + * When actions are falsy, disables the rule + * @param matrixClient - cli + * @param ruleId - rule id to update + * @param kind - PushRuleKind + * @param actions - push rule actions to set for rule + */ +export const updatePushRuleActions = async ( + matrixClient: MatrixClient, + ruleId: IPushRule["rule_id"], + kind: PushRuleKind, + actions?: PushRuleAction[], +): Promise => { + if (!actions) { + await matrixClient.setPushRuleEnabled("global", kind, ruleId, false); + } else { + await matrixClient.setPushRuleActions("global", kind, ruleId, actions); + await matrixClient.setPushRuleEnabled("global", kind, ruleId, true); + } +}; + +interface PushRuleAndKind { + rule: IPushRule; + kind: PushRuleKind; +} + +/** + * Update push rules with given actions + * Where they already exist for current user + * Rules are updated sequentially and stop at first error + * @param matrixClient - cli + * @param ruleIds - RuleIds of push rules to attempt to set actions for + * @param actions - push rule actions to set for rule + * @returns resolves when all rules have been updated + * @returns rejects when a rule update fails + */ +export const updateExistingPushRulesWithActions = async ( + matrixClient: MatrixClient, + ruleIds?: IPushRule["rule_id"][], + actions?: PushRuleAction[], +): Promise => { + const pushProcessor = new PushProcessor(matrixClient); + + const rules: PushRuleAndKind[] | undefined = ruleIds + ?.map((ruleId) => pushProcessor.getPushRuleAndKindById(ruleId)) + .filter((n: PushRuleAndKind | null): n is PushRuleAndKind => Boolean(n)); + + if (!rules?.length) { + return; + } + for (const { kind, rule } of rules) { + await updatePushRuleActions(matrixClient, rule.rule_id, kind, actions); + } +}; diff --git a/test/components/structures/LoggedInView-test.tsx b/test/components/structures/LoggedInView-test.tsx new file mode 100644 index 00000000000..5e389d65dea --- /dev/null +++ b/test/components/structures/LoggedInView-test.tsx @@ -0,0 +1,326 @@ +/* +Copyright 2015 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render, RenderResult } from "@testing-library/react"; +import { ConditionKind, EventType, IPushRule, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { ClientEvent } from "matrix-js-sdk/src/client"; +import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; +import { logger } from "matrix-js-sdk/src/logger"; + +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, mockClientMethodsUser } from "../../test-utils"; +import { TestSdkContext } from "../../TestSdkContext"; + +describe("", () => { + const userId = "@alice:domain.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getAccountData: jest.fn(), + getRoom: jest.fn(), + getSyncState: jest.fn().mockReturnValue(null), + getSyncStateData: jest.fn().mockReturnValue(null), + getMediaHandler: jest.fn(), + setPushRuleEnabled: jest.fn(), + setPushRuleActions: jest.fn(), + }); + const mediaHandler = new MediaHandler(mockClient); + const mockSdkContext = new TestSdkContext(); + + const defaultProps = { + matrixClient: mockClient, + onRegistered: jest.fn(), + resizeNotifier: new ResizeNotifier(), + collapseLhs: false, + hideToSRUsers: false, + config: { + brand: "Test", + element_call: {}, + }, + currentRoomId: "", + }; + + const getComponent = (props = {}): RenderResult => + render(, { + wrapper: ({ children }) => {children}, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockClient.getMediaHandler.mockReturnValue(mediaHandler); + mockClient.setPushRuleActions.mockReset().mockResolvedValue({}); + }); + + 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 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("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("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(); + }); + }); + }); +});