element-web/src/components/views/settings/Notifications.tsx

648 lines
24 KiB
TypeScript
Raw Normal View History

/*
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";
2021-07-17 01:29:25 +03:00
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";
2021-06-29 15:11:58 +03:00
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",
2020-08-29 14:14:16 +03:00
// 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,
2021-07-13 09:02:44 +03:00
];
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[];
2020-08-29 14:14:16 +03:00
};
pushers?: IPusher[];
2021-07-17 01:29:25 +03:00
threepids?: IThreepid[];
}
export default class Notifications extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.Loading,
};
2020-08-29 14:14:16 +03:00
}
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<Partial<IState>> {
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]) {
2021-07-13 09:02:44 +03:00
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<IState> = {};
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;
2020-08-29 14:14:16 +03:00
}
2021-07-17 08:51:44 +03:00
private refreshPushers(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getPushers();
}
2021-07-17 08:51:44 +03:00
private refreshThreepids(): Promise<Partial<IState>> {
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) {
2021-07-13 09:02:44 +03:00
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false);
} else {
2021-07-13 09:02:44 +03:00
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);
});
2020-08-29 14:14:16 +03:00
};
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);
});
2020-08-29 14:14:16 +03:00
};
private renderTopSection() {
const masterSwitch = <LabelledToggleSwitch
value={!this.isInhibited}
label={_t("Enable for this account")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>;
// 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 => <LabelledToggleSwitch
key={e.address}
value={this.state.pushers.some(p => 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 }
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onDesktopNotificationsChanged}
label={_t('Enable desktop notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onDesktopShowBodyChanged}
label={_t('Show message in desktop notification')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onAudioNotificationsChanged}
label={_t('Enable audible notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
{ emailSwitches }
</>;
2020-08-29 14:14:16 +03:00
}
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 = <AccessibleButton
onClick={this.onClearNotificationsClicked}
kind='danger'
className='mx_UserNotifSettings_clearNotifsButton'
>{ _t("Clear notifications") }</AccessibleButton>;
}
if (category === RuleClass.VectorOther && this.isInhibited) {
// only render the utility buttons (if needed)
if (clearNotifsButton) {
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Other") }</div>
{ clearNotifsButton }
</div>;
}
return null;
}
let keywordComposer: JSX.Element;
if (category === RuleClass.VectorMentions) {
keywordComposer = <TagComposer
tags={this.state.vectorKeywordRuleInfo?.rules.map(r => 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) => (
<StyledRadioButton
key={r.ruleId}
name={r.ruleId}
checked={r.vectorState === s}
onChange={this.onRadioChecked.bind(this, r, s)}
disabled={this.state.phase === Phase.Persisting}
/>
);
const rows = this.state.vectorPushRules[category].map(r => <tr key={category + r.ruleId}>
<td>{ r.description }</td>
<td>{ makeRadio(r, VectorState.Off) }</td>
<td>{ makeRadio(r, VectorState.On) }</td>
<td>{ makeRadio(r, VectorState.Loud) }</td>
</tr>);
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 <>
<table className='mx_UserNotifSettings_pushRulesTable'>
<thead>
<tr>
<th>{ sectionName }</th>
<th>{ _t("Off") }</th>
<th>{ _t("On") }</th>
<th>{ _t("Noisy") }</th>
</tr>
</thead>
<tbody>
{ rows }
</tbody>
</table>
{ clearNotifsButton }
{ keywordComposer }
</>;
}
private renderTargets() {
if (this.isInhibited) return null; // no targets if there's no notifications
const rows = this.state.pushers.map(p => <tr key={p.kind+p.pushkey}>
<td>{ p.app_display_name }</td>
<td>{ p.device_display_name }</td>
</tr>);
if (!rows.length) return null; // no targets to show
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Notification targets") }</div>
<table>
<tbody>
{ rows }
</tbody>
</table>
</div>;
}
public render() {
if (this.state.phase === Phase.Loading) {
// Ends up default centered
return <Spinner />;
} else if (this.state.phase === Phase.Error) {
return <p>{ _t("There was an error loading your notification settings.") }</p>;
}
return <div className='mx_UserNotifSettings'>
{ this.renderTopSection() }
{ this.renderCategory(RuleClass.VectorGlobal) }
{ this.renderCategory(RuleClass.VectorMentions) }
{ this.renderCategory(RuleClass.VectorOther) }
{ this.renderTargets() }
</div>;
2020-08-29 14:14:16 +03:00
}
}