/* Copyright 2024 New Vector Ltd. Copyright 2016-2022 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, { type ChangeEvent, type ChangeEventHandler, type JSX, type ReactNode } from "react"; import { type IAnnotatedPushRule, type IPusher, type PushRuleAction, PushRuleKind, RuleId, type IThreepid, ThreepidMedium, type LocalNotificationSettings, type EmptyObject, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { Form, SettingsToggleInput } from "@vector-im/compound-web"; import Spinner from "../elements/Spinner"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { ContentRules, type IContentRules, PushRuleVectorState, VectorPushRulesDefinitions, VectorState, type VectorPushRuleDefinition, } from "../../../notifications"; import { _t, type TranslatedString } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import StyledRadioButton from "../elements/StyledRadioButton"; import { SettingLevel } from "../../../settings/SettingLevel"; import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import SdkConfig from "../../../SdkConfig"; import AccessibleButton from "../elements/AccessibleButton"; import TagComposer from "../elements/TagComposer"; import { objectClone } from "../../../utils/objects"; import { arrayDiff, filterBoolean } from "../../../utils/arrays"; import { clearAllNotifications, getLocalNotificationAccountDataEventType } from "../../../utils/notifications"; import { updateExistingPushRulesWithActions, updatePushRuleActions, } from "../../../utils/pushRules/updatePushRuleActions"; import { Caption } from "../typography/Caption"; import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading"; import { SettingsSubsection } from "./shared/SettingsSubsection"; import { doesRoomHaveUnreadMessages } from "../../../Unread"; import SettingsFlag from "../elements/SettingsFlag"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. enum Phase { Loading = "loading", Ready = "ready", Persisting = "persisting", // technically a meta-state for Ready, but whatever // unrecoverable error - eg can't load push rules Error = "error", // error saving individual rule SavingError = "savingError", } enum RuleClass { Master = "master", // The vector sections map approximately to UI sections VectorGlobal = "vector_global", VectorMentions = "vector_mentions", VectorOther = "vector_other", Other = "other", // unknown rules, essentially } const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions; // This array doesn't care about categories: it's just used for a simple sort const RULE_DISPLAY_ORDER: string[] = [ // Global RuleId.DM, RuleId.EncryptedDM, RuleId.Message, RuleId.EncryptedMessage, // Mentions RuleId.ContainsDisplayName, RuleId.ContainsUserName, RuleId.AtRoomNotification, // Other RuleId.InviteToSelf, RuleId.IncomingCall, RuleId.SuppressNotices, RuleId.Tombstone, ]; interface IVectorPushRule { ruleId: RuleId | typeof KEYWORD_RULE_ID | string; rule?: IAnnotatedPushRule; description: TranslatedString | string; vectorState: VectorState; // loudest vectorState of a rule and its synced rules // undefined when rule has no synced rules syncedVectorState?: VectorState; } interface IState { phase: Phase; // Optional stuff is required when `phase === Ready` masterPushRule?: IAnnotatedPushRule; vectorKeywordRuleInfo?: IContentRules; vectorPushRules?: { [category in RuleClass]?: IVectorPushRule[]; }; pushers?: IPusher[]; threepids?: IThreepid[]; deviceNotificationsEnabled: boolean; clearingNotifications: boolean; ruleIdsWithError: Record; } const findInDefaultRules = ( ruleId: RuleId | string, defaultRules: { [k in RuleClass]: IAnnotatedPushRule[]; }, ): IAnnotatedPushRule | undefined => { for (const category in defaultRules) { const rule: IAnnotatedPushRule | undefined = defaultRules[category as RuleClass].find( (rule) => rule.rule_id === ruleId, ); if (rule) { return rule; } } }; // Vector notification states ordered by loudness in ascending order const OrderedVectorStates = [VectorState.Off, VectorState.On, VectorState.Loud]; /** * Find the 'loudest' vector state assigned to a rule * and it's synced rules * If rules have fallen out of sync, * the loudest rule can determine the display value * @param defaultRules * @param rule - parent rule * @param definition - definition of parent rule * @returns VectorState - the maximum/loudest state for the parent and synced rules */ const maximumVectorState = ( defaultRules: { [k in RuleClass]: IAnnotatedPushRule[]; }, rule: IAnnotatedPushRule, definition: VectorPushRuleDefinition, ): VectorState | undefined => { if (!definition.syncedRuleIds?.length) { return undefined; } const vectorState = definition.syncedRuleIds.reduce((maxVectorState, ruleId) => { // already set to maximum if (maxVectorState === VectorState.Loud) { return maxVectorState; } const syncedRule = findInDefaultRules(ruleId, defaultRules); if (syncedRule) { const syncedRuleVectorState = definition.ruleToVectorState(syncedRule); // if syncedRule is 'louder' than current maximum // set maximum to louder vectorState if ( syncedRuleVectorState && OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState) ) { return syncedRuleVectorState; } } return maxVectorState; }, definition.ruleToVectorState(rule)!); return vectorState; }; const NotificationActivitySettings = (): JSX.Element => { return ( { evt.preventDefault(); evt.stopPropagation(); }} > ); }; /** * The old, deprecated notifications tab view, only displayed if the user has the labs flag disabled. */ export default class Notifications extends React.PureComponent { private settingWatchers: string[] = []; public constructor(props: EmptyObject) { super(props); this.state = { phase: Phase.Loading, deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true, clearingNotifications: false, ruleIdsWithError: {}, }; } private get isInhibited(): boolean { // Caution: The master rule's enabled state is inverted from expectation. When // the master rule is *enabled* it means all other rules are *disabled* (or // inhibited). Conversely, when the master rule is *disabled* then all other rules // are *enabled* (or operate fine). return !!this.state.masterPushRule?.enabled; } public componentDidMount(): void { this.settingWatchers = [ SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => { this.setState({ deviceNotificationsEnabled: value as boolean }); }), ]; // noinspection JSIgnoredPromiseFromCall this.refreshFromServer(); this.refreshFromAccountData(); } public componentWillUnmount(): void { this.settingWatchers.forEach((watcher) => SettingsStore.unwatchSetting(watcher)); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) { this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled); } } private async refreshFromServer(): Promise { try { const newState = ( await Promise.all([this.refreshRules(), this.refreshPushers(), this.refreshThreepids()]) ).reduce((p, c) => Object.assign(c, p), {}); this.setState< keyof Pick< IState, "phase" | "vectorKeywordRuleInfo" | "vectorPushRules" | "pushers" | "threepids" | "masterPushRule" > >({ ...newState, phase: Phase.Ready, }); } catch (e) { logger.error("Error setting up notifications for settings: ", e); this.setState({ phase: Phase.Error }); } } private async refreshFromAccountData(): Promise { const cli = MatrixClientPeg.safeGet(); const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId)); if (settingsEvent) { const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced; await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, notificationsEnabled); } } private persistLocalNotificationSettings(enabled: boolean): Promise { const cli = MatrixClientPeg.safeGet(); return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), { is_silenced: !enabled, }); } private async refreshRules(): Promise> { const ruleSets = await MatrixClientPeg.safeGet().getPushRules()!; const categories: Record = { [RuleId.Master]: RuleClass.Master, [RuleId.DM]: RuleClass.VectorGlobal, [RuleId.EncryptedDM]: RuleClass.VectorGlobal, [RuleId.Message]: RuleClass.VectorGlobal, [RuleId.EncryptedMessage]: RuleClass.VectorGlobal, [RuleId.ContainsDisplayName]: RuleClass.VectorMentions, [RuleId.ContainsUserName]: RuleClass.VectorMentions, [RuleId.AtRoomNotification]: RuleClass.VectorMentions, [RuleId.InviteToSelf]: RuleClass.VectorOther, [RuleId.IncomingCall]: RuleClass.VectorOther, [RuleId.SuppressNotices]: RuleClass.VectorOther, [RuleId.Tombstone]: RuleClass.VectorOther, // Everything maps to a generic "other" (unknown rule) }; const defaultRules: { [k in RuleClass]: IAnnotatedPushRule[]; } = { [RuleClass.Master]: [], [RuleClass.VectorGlobal]: [], [RuleClass.VectorMentions]: [], [RuleClass.VectorOther]: [], [RuleClass.Other]: [], }; for (const k in ruleSets.global) { // noinspection JSUnfilteredForInLoop const kind = k as PushRuleKind; for (const r of ruleSets.global[kind]!) { const rule: IAnnotatedPushRule = Object.assign(r, { kind }); const category = categories[rule.rule_id] ?? RuleClass.Other; if (rule.rule_id[0] === ".") { defaultRules[category].push(rule); } } } const preparedNewState: Partial = {}; if (defaultRules.master.length > 0) { preparedNewState.masterPushRule = defaultRules.master[0]; } else { // XXX: Can this even happen? How do we safely recover? throw new Error("Failed to locate a master push rule"); } // Parse keyword rules preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets); // Prepare rendering for all of our known rules preparedNewState.vectorPushRules = {}; const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther]; for (const category of vectorCategories) { preparedNewState.vectorPushRules[category] = []; for (const rule of defaultRules[category]) { const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.rule_id]; const vectorState = definition.ruleToVectorState(rule)!; preparedNewState.vectorPushRules[category]!.push({ ruleId: rule.rule_id, rule, vectorState, syncedVectorState: maximumVectorState(defaultRules, rule, definition), description: _t(definition.description), }); } // Quickly sort the rules for display purposes preparedNewState.vectorPushRules[category]!.sort((a, b) => { let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId); let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId); // Assume unknown things go at the end if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length; if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length; return idxA - idxB; }); if (category === KEYWORD_RULE_CATEGORY) { preparedNewState.vectorPushRules[category]!.push({ ruleId: KEYWORD_RULE_ID, description: _t("settings|notifications|messages_containing_keywords"), vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState, }); } } return preparedNewState; } private refreshPushers(): Promise> { return MatrixClientPeg.safeGet().getPushers(); } private refreshThreepids(): Promise> { return MatrixClientPeg.safeGet().getThreePids(); } private showSaveError(): void { Modal.createDialog(ErrorDialog, { title: _t("settings|notifications|error_saving"), description: _t("settings|notifications|error_saving_detail"), }); } private onMasterRuleChanged: ChangeEventHandler = async (evt): Promise => { const { checked } = evt.target; this.setState({ phase: Phase.Persisting }); const masterRule = this.state.masterPushRule!; try { await MatrixClientPeg.safeGet().setPushRuleEnabled("global", masterRule.kind, masterRule.rule_id, !checked); await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); logger.error("Error updating master push rule:", e); this.showSaveError(); } }; private setSavingError = (ruleId: RuleId | string): void => { this.setState(({ ruleIdsWithError }) => ({ phase: Phase.SavingError, ruleIdsWithError: { ...ruleIdsWithError, [ruleId]: true }, })); }; private onEmailNotificationsChanged = async (email: string, evt: ChangeEvent): Promise => { const { checked } = evt.target; this.setState({ phase: Phase.Persisting }); try { if (checked) { await MatrixClientPeg.safeGet().setPusher({ kind: "email", app_id: "m.email", pushkey: email, app_display_name: "Email Notifications", device_display_name: email, lang: navigator.language, data: { brand: SdkConfig.get().brand, }, // We always append for email pushers since we don't want to stop other // accounts notifying to the same email address append: true, }); } else { const pusher = this.state.pushers?.find((p) => p.kind === "email" && p.pushkey === email); if (pusher) { await MatrixClientPeg.safeGet().removePusher(pusher.pushkey, pusher.app_id); } } await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); logger.error("Error updating email pusher:", e); this.showSaveError(); } }; private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise => { this.setState(({ ruleIdsWithError }) => ({ phase: Phase.Persisting, ruleIdsWithError: { ...ruleIdsWithError, [rule.ruleId]: false }, })); try { const cli = MatrixClientPeg.safeGet(); if (rule.ruleId === KEYWORD_RULE_ID) { // should not encounter this if (!this.state.vectorKeywordRuleInfo) { throw new Error("Notification data is incomplete."); } // Update all the keywords for (const rule of this.state.vectorKeywordRuleInfo.rules) { let enabled: boolean | undefined; let actions: PushRuleAction[] | undefined; if (checkedState === VectorState.On) { if (rule.actions.length !== 1) { // XXX: Magic number actions = PushRuleVectorState.actionsFor(checkedState); } if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) { enabled = true; } } else if (checkedState === VectorState.Loud) { if (rule.actions.length !== 3) { // XXX: Magic number actions = PushRuleVectorState.actionsFor(checkedState); } if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) { enabled = true; } } else { enabled = false; } if (actions) { await cli.setPushRuleActions("global", rule.kind, rule.rule_id, actions); } if (enabled !== undefined) { await cli.setPushRuleEnabled("global", rule.kind, rule.rule_id, enabled); } } } else { const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.ruleId]; const actions = definition.vectorStateToActions[checkedState]; // 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(); } catch (e) { this.setSavingError(rule.ruleId); logger.error("Error updating push rule:", e); } }; private onClearNotificationsClicked = async (): Promise => { try { this.setState({ clearingNotifications: true }); const client = MatrixClientPeg.safeGet(); await clearAllNotifications(client); } finally { this.setState({ clearingNotifications: false }); } }; private async setKeywords( unsafeKeywords: (string | undefined)[], originalRules: IAnnotatedPushRule[], ): Promise { try { // De-duplicate and remove empties const keywords = filterBoolean(Array.from(new Set(unsafeKeywords))); const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern)))); // Note: Technically because of the UI interaction (at the time of writing), the diff // will only ever be +/-1 so we don't really have to worry about efficiently handling // tons of keyword changes. const diff = arrayDiff(oldKeywords, keywords); for (const word of diff.removed) { for (const rule of originalRules.filter((r) => r.pattern === word)) { await MatrixClientPeg.safeGet().deletePushRule("global", rule.kind, rule.rule_id); } } let ruleVectorState = this.state.vectorKeywordRuleInfo!.vectorState; if (ruleVectorState === VectorState.Off) { // When the current global keywords rule is OFF, we need to look at // the flavor of existing rules to apply the same actions // when creating the new rule. const existingRuleVectorState = originalRules.length ? PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) : undefined; // set to same state as existing rule, or default to On ruleVectorState = existingRuleVectorState ?? VectorState.On; //default } const kind = PushRuleKind.ContentSpecific; for (const word of diff.added) { await MatrixClientPeg.safeGet().addPushRule("global", kind, word, { actions: PushRuleVectorState.actionsFor(ruleVectorState), pattern: word, }); if (ruleVectorState === VectorState.Off) { await MatrixClientPeg.safeGet().setPushRuleEnabled("global", kind, word, false); } } await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); logger.error("Error updating keyword push rules:", e); this.showSaveError(); } } private onKeywordAdd = (keyword: string): void => { // should not encounter this if (!this.state.vectorKeywordRuleInfo) { throw new Error("Notification data is incomplete."); } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We add the keyword immediately as a sort of local echo effect this.setState( { phase: Phase.Persisting, vectorKeywordRuleInfo: { ...this.state.vectorKeywordRuleInfo, rules: [ ...this.state.vectorKeywordRuleInfo.rules, // XXX: Horrible assumption that we don't need the remaining fields { pattern: keyword } as IAnnotatedPushRule, ], }, }, async (): Promise => { await this.setKeywords( this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, ); }; private onKeywordRemove = (keyword: string): void => { // should not encounter this if (!this.state.vectorKeywordRuleInfo) { throw new Error("Notification data is incomplete."); } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We remove the keyword immediately as a sort of local echo effect this.setState( { phase: Phase.Persisting, vectorKeywordRuleInfo: { ...this.state.vectorKeywordRuleInfo, rules: this.state.vectorKeywordRuleInfo.rules.filter((r) => r.pattern !== keyword), }, }, async (): Promise => { await this.setKeywords( this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, ); }; private renderTopSection(): JSX.Element { const masterSwitch = ( ); // If all the rules are inhibited, don't show anything. if (this.isInhibited) { return masterSwitch; } const emailSwitches = (this.state.threepids || []) .filter((t) => t.medium === ThreepidMedium.Email) .map((e) => ( p.kind === "email" && p.pushkey === e.address)} label={_t("settings|notifications|enable_email_notifications", { email: e.address })} onChange={this.onEmailNotificationsChanged.bind(this, e.address)} disabled={this.state.phase === Phase.Persisting} /> )); return ( {masterSwitch} {this.state.deviceNotificationsEnabled && ( <> )} {emailSwitches} ); } private renderCategory(category: RuleClass): ReactNode { if (this.isInhibited) { return null; // nothing to show for the section } let keywordComposer: JSX.Element | undefined; if (category === RuleClass.VectorMentions) { const tags = filterBoolean(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []); keywordComposer = ( ); } const VectorStateToLabel = { [VectorState.On]: _t("common|on"), [VectorState.Off]: _t("common|off"), [VectorState.Loud]: _t("settings|notifications|noisy"), }; const makeRadio = (r: IVectorPushRule, s: VectorState): JSX.Element => ( ); const fieldsetRows = this.state.vectorPushRules?.[category]?.map((r) => (
{r.description} {makeRadio(r, VectorState.Off)} {makeRadio(r, VectorState.On)} {makeRadio(r, VectorState.Loud)} {this.state.ruleIdsWithError[r.ruleId] && (
{_t("settings|notifications|error_updating")}
)}
)); let sectionName: string; switch (category) { case RuleClass.VectorGlobal: sectionName = _t("notifications|class_global"); break; case RuleClass.VectorMentions: sectionName = _t("notifications|mentions_keywords"); break; case RuleClass.VectorOther: sectionName = _t("notifications|class_other"); break; default: throw new Error("Developer error: Unnamed notifications section: " + category); } return (
{VectorStateToLabel[VectorState.Off]} {VectorStateToLabel[VectorState.On]} {VectorStateToLabel[VectorState.Loud]} {fieldsetRows}
{keywordComposer}
); } private renderTargets(): ReactNode { if (this.isInhibited) return null; // no targets if there's no notifications const rows = this.state.pushers?.map((p) => ( {p.app_display_name} {p.device_display_name} )); if (!rows?.length) return null; // no targets to show return (
{_t("settings|notifications|push_targets")}
{rows}
); } public render(): React.ReactNode { if (this.state.phase === Phase.Loading) { // Ends up default centered return ; } else if (this.state.phase === Phase.Error) { return

{_t("settings|notifications|error_loading")}

; } let clearNotifsButton: JSX.Element | undefined; if ( MatrixClientPeg.safeGet() .getRooms() .some((r) => doesRoomHaveUnreadMessages(r, true)) ) { clearNotifsButton = ( {_t("notifications|mark_all_read")} ); } return ( <> {this.renderTopSection()} {this.renderCategory(RuleClass.VectorGlobal)} {this.renderCategory(RuleClass.VectorMentions)} {this.renderCategory(RuleClass.VectorOther)} {this.renderTargets()} {clearNotifsButton} ); } }