/* Copyright 2016 - 2021 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 Spinner from "../elements/Spinner"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules"; import { ContentRules, IContentRules, PushRuleVectorState, VectorPushRulesDefinitions, VectorState, } from "../../../notifications"; import { _t, TranslatedString } from "../../../languageHandler"; import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; 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 } from "../../../utils/arrays"; // 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 Error = "error", } 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; } interface IProps {} interface IState { phase: Phase; // Optional stuff is required when `phase === Ready` masterPushRule?: IAnnotatedPushRule; vectorKeywordRuleInfo?: IContentRules; vectorPushRules?: { [category in RuleClass]?: IVectorPushRule[]; }; pushers?: IPusher[]; threepids?: IThreepid[]; } export default class Notifications extends React.PureComponent { public constructor(props: IProps) { super(props); this.state = { phase: Phase.Loading, }; } 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() { // noinspection JSIgnoredPromiseFromCall this.refreshFromServer(); } private async refreshFromServer() { try { const newState = (await Promise.all([ this.refreshRules(), this.refreshPushers(), this.refreshThreepids(), ])).reduce((p, c) => Object.assign(c, p), {}); this.setState({ ...newState, phase: Phase.Ready, }); } catch (e) { console.error("Error setting up notifications for settings: ", e); this.setState({ phase: Phase.Error }); } } private async refreshRules(): Promise> { const ruleSets = await MatrixClientPeg.get().getPushRules(); const categories = { [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 = VectorPushRulesDefinitions[rule.rule_id]; const vectorState = definition.ruleToVectorState(rule); preparedNewState.vectorPushRules[category].push({ ruleId: rule.rule_id, rule, vectorState, 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("Messages containing keywords"), vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState, }); } } return preparedNewState; } private refreshPushers(): Promise> { return MatrixClientPeg.get().getPushers(); } private refreshThreepids(): Promise> { return MatrixClientPeg.get().getThreePids(); } private showSaveError() { Modal.createTrackedDialog('Error saving notification preferences', '', ErrorDialog, { title: _t('Error saving notification preferences'), description: _t('An error occurred whilst saving your notification preferences.'), }); } private onMasterRuleChanged = async (checked: boolean) => { this.setState({ phase: Phase.Persisting }); try { const masterRule = this.state.masterPushRule; await MatrixClientPeg.get().setPushRuleEnabled('global', masterRule.kind, masterRule.rule_id, !checked); await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); console.error("Error updating master push rule:", e); this.showSaveError(); } }; private onEmailNotificationsChanged = async (email: string, checked: boolean) => { this.setState({ phase: Phase.Persisting }); try { if (checked) { await MatrixClientPeg.get().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); pusher.kind = null; // flag for delete await MatrixClientPeg.get().setPusher(pusher); } await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); console.error("Error updating email pusher:", e); this.showSaveError(); } }; private onDesktopNotificationsChanged = async (checked: boolean) => { await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked); this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue() }; private onDesktopShowBodyChanged = async (checked: boolean) => { await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked); this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue() }; private onAudioNotificationsChanged = async (checked: boolean) => { await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked); this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue() }; private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => { this.setState({ phase: Phase.Persisting }); try { const cli = MatrixClientPeg.get(); if (rule.ruleId === KEYWORD_RULE_ID) { // Update all the keywords for (const rule of this.state.vectorKeywordRuleInfo.rules) { let enabled: boolean; let actions: PushRuleAction[]; 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 = VectorPushRulesDefinitions[rule.ruleId]; const actions = definition.vectorStateToActions[checkedState]; if (!actions) { await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false); } else { await cli.setPushRuleActions('global', rule.rule.kind, rule.rule.rule_id, actions); await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, true); } } await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); console.error("Error updating push rule:", e); this.showSaveError(); } }; private onClearNotificationsClicked = () => { MatrixClientPeg.get().getRooms().forEach(r => { if (r.getUnreadNotificationCount() > 0) { const events = r.getLiveTimeline().getEvents(); if (events.length) { // noinspection JSIgnoredPromiseFromCall MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]); } } }); }; private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]) { try { // De-duplicate and remove empties keywords = Array.from(new Set(keywords)).filter(k => !!k); const oldKeywords = Array.from(new Set(originalRules.map(r => r.pattern))).filter(k => !!k); // 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.get().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. if (originalRules.length) { ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]); } else { ruleVectorState = VectorState.On; // default } } const kind = PushRuleKind.ContentSpecific; for (const word of diff.added) { await MatrixClientPeg.get().addPushRule('global', kind, word, { actions: PushRuleVectorState.actionsFor(ruleVectorState), pattern: word, }); if (ruleVectorState === VectorState.Off) { await MatrixClientPeg.get().setPushRuleEnabled('global', kind, word, false); } } await this.refreshFromServer(); } catch (e) { this.setState({ phase: Phase.Error }); console.error("Error updating keyword push rules:", e); this.showSaveError(); } } private onKeywordAdd = (keyword: string) => { 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 () => { await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules); }); }; private onKeywordRemove = (keyword: string) => { 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 () => { await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules); }); }; private renderTopSection() { 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("Enable email notifications for %(email)s", { email: e.address })} onChange={this.onEmailNotificationsChanged.bind(this, e.address)} disabled={this.state.phase === Phase.Persisting} />); return <> { masterSwitch } { emailSwitches } ; } private renderCategory(category: RuleClass) { if (category !== RuleClass.VectorOther && this.isInhibited) { return null; // nothing to show for the section } let clearNotifsButton: JSX.Element; if ( category === RuleClass.VectorOther && MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0) ) { clearNotifsButton = { _t("Clear notifications") }; } if (category === RuleClass.VectorOther && this.isInhibited) { // only render the utility buttons (if needed) if (clearNotifsButton) { return
{ _t("Other") }
{ clearNotifsButton }
; } return null; } let keywordComposer: JSX.Element; if (category === RuleClass.VectorMentions) { keywordComposer = r.pattern)} onAdd={this.onKeywordAdd} onRemove={this.onKeywordRemove} disabled={this.state.phase === Phase.Persisting} label={_t("Keyword")} placeholder={_t("New keyword")} />; } const makeRadio = (r: IVectorPushRule, s: VectorState) => ( ); const rows = this.state.vectorPushRules[category].map(r => { r.description } { makeRadio(r, VectorState.Off) } { makeRadio(r, VectorState.On) } { makeRadio(r, VectorState.Loud) } ); let sectionName: TranslatedString; switch (category) { case RuleClass.VectorGlobal: sectionName = _t("Global"); break; case RuleClass.VectorMentions: sectionName = _t("Mentions & keywords"); break; case RuleClass.VectorOther: sectionName = _t("Other"); break; default: throw new Error("Developer error: Unnamed notifications section: " + category); } return <> { rows }
{ sectionName } { _t("Off") } { _t("On") } { _t("Noisy") }
{ clearNotifsButton } { keywordComposer } ; } private renderTargets() { 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("Notification targets") }
{ rows }
; } public render() { if (this.state.phase === Phase.Loading) { // Ends up default centered return ; } else if (this.state.phase === Phase.Error) { return

{ _t("There was an error loading your notification settings.") }

; } return
{ this.renderTopSection() } { this.renderCategory(RuleClass.VectorGlobal) } { this.renderCategory(RuleClass.VectorMentions) } { this.renderCategory(RuleClass.VectorOther) } { this.renderTargets() }
; } }