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

890 lines
35 KiB
TypeScript

/*
Copyright 2016 - 2022 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, { ReactNode } from "react";
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 Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {
ContentRules,
IContentRules,
PushRuleVectorState,
VectorPushRulesDefinitions,
VectorState,
} from "../../../notifications";
import type { VectorPushRuleDefinition } from "../../../notifications";
import { _t, TranslatedString } from "../../../languageHandler";
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, filterBoolean } from "../../../utils/arrays";
import { clearAllNotifications, getLocalNotificationAccountDataEventType } from "../../../utils/notifications";
import {
updateExistingPushRulesWithActions,
updatePushRuleActions,
} from "../../../utils/pushRules/updatePushRuleActions";
import { Caption } from "../typography/Caption";
// 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 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[];
deviceNotificationsEnabled: boolean;
desktopNotifications: boolean;
desktopShowBody: boolean;
audioNotifications: boolean;
clearingNotifications: boolean;
ruleIdsWithError: Record<RuleId | string, boolean>;
}
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<VectorState>((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;
};
export default class Notifications extends React.PureComponent<IProps, IState> {
private settingWatchers: string[];
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.Loading,
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true,
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
clearingNotifications: false,
ruleIdsWithError: {},
};
this.settingWatchers = [
SettingsStore.watchSetting("notificationsEnabled", null, (...[, , , , value]) =>
this.setState({ desktopNotifications: value as boolean }),
),
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => {
this.setState({ deviceNotificationsEnabled: value as boolean });
}),
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[, , , , value]) =>
this.setState({ desktopShowBody: value as boolean }),
),
SettingsStore.watchSetting("audioNotificationsEnabled", null, (...[, , , , value]) =>
this.setState({ audioNotifications: value as boolean }),
),
];
}
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 {
// noinspection JSIgnoredPromiseFromCall
this.refreshFromServer();
this.refreshFromAccountData();
}
public componentWillUnmount(): void {
this.settingWatchers.forEach((watcher) => SettingsStore.unwatchSetting(watcher));
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) {
this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled);
}
}
private async refreshFromServer(): Promise<void> {
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<void> {
const cli = MatrixClientPeg.get();
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
if (settingsEvent) {
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
await this.updateDeviceNotifications(notificationsEnabled);
}
}
private persistLocalNotificationSettings(enabled: boolean): Promise<{}> {
const cli = MatrixClientPeg.get();
return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), {
is_silenced: !enabled,
});
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.get().getPushRules()!;
const categories: Record<string, RuleClass> = {
[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<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: 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("Messages containing keywords"),
vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
});
}
}
return preparedNewState;
}
private refreshPushers(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getPushers();
}
private refreshThreepids(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getThreePids();
}
private showSaveError(): void {
Modal.createDialog(ErrorDialog, {
title: _t("Error saving notification preferences"),
description: _t("An error occurred whilst saving your notification preferences."),
});
}
private onMasterRuleChanged = async (checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
const masterRule = this.state.masterPushRule!;
try {
await MatrixClientPeg.get().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 updateDeviceNotifications = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onEmailNotificationsChanged = async (email: string, checked: boolean): Promise<void> => {
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);
if (pusher) {
await MatrixClientPeg.get().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 onDesktopNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onDesktopShowBodyChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
};
private onAudioNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise<void> => {
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.Persisting,
ruleIdsWithError: { ...ruleIdsWithError, [rule.ruleId]: false },
}));
try {
const cli = MatrixClientPeg.get();
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<void> => {
try {
this.setState({ clearingNotifications: true });
const client = MatrixClientPeg.get();
await clearAllNotifications(client);
} finally {
this.setState({ clearingNotifications: false });
}
};
private async setKeywords(
unsafeKeywords: (string | undefined)[],
originalRules: IAnnotatedPushRule[],
): Promise<void> {
try {
// De-duplicate and remove empties
const keywords = filterBoolean<string>(Array.from(new Set(unsafeKeywords)));
const oldKeywords = filterBoolean<string>(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<string>(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.
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.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 });
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<void> => {
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<void> => {
await this.setKeywords(
this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern),
originalRules,
);
},
);
};
private renderTopSection(): JSX.Element {
const masterSwitch = (
<>
<LabelledToggleSwitch
data-testid="notif-master-switch"
value={!this.isInhibited}
label={_t("Enable notifications for this account")}
caption={_t("Turn off to disable notifications on all your devices and sessions")}
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
data-testid="notif-email-switch"
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
data-testid="notif-device-switch"
value={this.state.deviceNotificationsEnabled}
label={_t("Enable notifications for this device")}
onChange={(checked) => this.updateDeviceNotifications(checked)}
disabled={this.state.phase === Phase.Persisting}
/>
{this.state.deviceNotificationsEnabled && (
<>
<LabelledToggleSwitch
data-testid="notif-setting-notificationsEnabled"
value={this.state.desktopNotifications}
onChange={this.onDesktopNotificationsChanged}
label={_t("Enable desktop notifications for this session")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-notificationBodyEnabled"
value={this.state.desktopShowBody}
onChange={this.onDesktopShowBodyChanged}
label={_t("Show message in desktop notification")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-audioNotificationsEnabled"
value={this.state.audioNotifications}
onChange={this.onAudioNotificationsChanged}
label={_t("Enable audible notifications for this session")}
disabled={this.state.phase === Phase.Persisting}
/>
</>
)}
{emailSwitches}
</>
);
}
private renderCategory(category: RuleClass): ReactNode {
if (category !== RuleClass.VectorOther && this.isInhibited) {
return null; // nothing to show for the section
}
let clearNotifsButton: JSX.Element | undefined;
if (
category === RuleClass.VectorOther &&
MatrixClientPeg.get()
.getRooms()
.some((r) => r.getUnreadNotificationCount() > 0)
) {
clearNotifsButton = (
<AccessibleButton
onClick={this.onClearNotificationsClicked}
disabled={this.state.clearingNotifications}
kind="danger"
className="mx_UserNotifSettings_clearNotifsButton"
data-testid="clear-notifications"
>
{_t("Mark all as read")}
</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 | undefined;
if (category === RuleClass.VectorMentions) {
const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
keywordComposer = (
<TagComposer
tags={tags}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
disabled={this.state.phase === Phase.Persisting}
label={_t("Keyword")}
placeholder={_t("New keyword")}
/>
);
}
const VectorStateToLabel = {
[VectorState.On]: _t("On"),
[VectorState.Off]: _t("Off"),
[VectorState.Loud]: _t("Noisy"),
};
const makeRadio = (r: IVectorPushRule, s: VectorState): JSX.Element => (
<StyledRadioButton
key={r.ruleId + s}
name={r.ruleId}
checked={(r.syncedVectorState ?? r.vectorState) === s}
onChange={this.onRadioChecked.bind(this, r, s)}
disabled={this.state.phase === Phase.Persisting}
aria-label={VectorStateToLabel[s]}
/>
);
const fieldsetRows = this.state.vectorPushRules?.[category]?.map((r) => (
<fieldset
key={category + r.ruleId}
data-testid={category + r.ruleId}
className="mx_UserNotifSettings_gridRowContainer"
>
<legend className="mx_UserNotifSettings_gridRowLabel">{r.description}</legend>
{makeRadio(r, VectorState.Off)}
{makeRadio(r, VectorState.On)}
{makeRadio(r, VectorState.Loud)}
{this.state.ruleIdsWithError[r.ruleId] && (
<div className="mx_UserNotifSettings_gridRowError">
<Caption isError>
{_t(
"An error occurred when updating your notification preferences. Please try to toggle your option again.",
)}
</Caption>
</div>
)}
</fieldset>
));
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 (
<>
<div data-testid={`notif-section-${category}`} className="mx_UserNotifSettings_grid">
<span className="mx_UserNotifSettings_gridRowLabel mx_UserNotifSettings_gridRowHeading">
{sectionName}
</span>
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Off]}</span>
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.On]}</span>
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Loud]}</span>
{fieldsetRows}
</div>
{clearNotifsButton}
{keywordComposer}
</>
);
}
private renderTargets(): ReactNode {
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(): React.ReactNode {
if (this.state.phase === Phase.Loading) {
// Ends up default centered
return <Spinner />;
} else if (this.state.phase === Phase.Error) {
return <p data-testid="error-message">{_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>
);
}
}