From 618be3440037d514d1130bf3fd7e164019d1644b Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Mon, 29 May 2023 23:50:00 +0200 Subject: [PATCH] wip: implement new notification settings --- res/css/_components.pcss | 2 + res/css/views/settings/_Notifications2.pcss | 92 ++++ .../views/settings/tabs/_SettingsIndent.pcss | 21 + src/components/views/elements/TagComposer.tsx | 7 +- .../views/settings/Notifications2.tsx | 486 ++++++++++++++++++ .../settings/UseNotificationSettings.tsx | 124 +++++ .../views/settings/shared/SettingsIndent.tsx | 27 + .../views/settings/shared/SettingsSection.tsx | 5 +- .../tabs/user/NotificationUserSettingsTab.tsx | 8 +- src/hooks/useAsyncRefreshMemo.ts | 38 ++ src/i18n/strings/en_EN.json | 28 +- src/models/notificationsettings/ActionData.ts | 65 +++ src/models/notificationsettings/NotifState.ts | 38 ++ .../NotificationSettingsModel.ts | 207 ++++++++ .../notificationsettings/StandardPushRule.ts | 51 ++ .../notificationsettings/computeChanges.ts | 84 +++ .../getContentPushRules.ts | 26 + .../notificationsettings/getRoomPushRules.ts | 64 +++ .../getStandardPushRules.ts | 32 ++ src/models/notificationsettings/isRuleId.ts | 21 + .../notificationsettings/pushRuleKey.ts | 25 + 21 files changed, 1441 insertions(+), 10 deletions(-) create mode 100644 res/css/views/settings/_Notifications2.pcss create mode 100644 res/css/views/settings/tabs/_SettingsIndent.pcss create mode 100644 src/components/views/settings/Notifications2.tsx create mode 100644 src/components/views/settings/UseNotificationSettings.tsx create mode 100644 src/components/views/settings/shared/SettingsIndent.tsx create mode 100644 src/hooks/useAsyncRefreshMemo.ts create mode 100644 src/models/notificationsettings/ActionData.ts create mode 100644 src/models/notificationsettings/NotifState.ts create mode 100644 src/models/notificationsettings/NotificationSettingsModel.ts create mode 100644 src/models/notificationsettings/StandardPushRule.ts create mode 100644 src/models/notificationsettings/computeChanges.ts create mode 100644 src/models/notificationsettings/getContentPushRules.ts create mode 100644 src/models/notificationsettings/getRoomPushRules.ts create mode 100644 src/models/notificationsettings/getStandardPushRules.ts create mode 100644 src/models/notificationsettings/isRuleId.ts create mode 100644 src/models/notificationsettings/pushRuleKey.ts diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 56628095f2f0..e991aa757e48 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -323,6 +323,7 @@ @import "./views/settings/_KeyboardShortcut.pcss"; @import "./views/settings/_LayoutSwitcher.pcss"; @import "./views/settings/_Notifications.pcss"; +@import "./views/settings/_Notifications2.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_ProfileSettings.pcss"; @import "./views/settings/_SecureBackupPanel.pcss"; @@ -332,6 +333,7 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; diff --git a/res/css/views/settings/_Notifications2.pcss b/res/css/views/settings/_Notifications2.pcss new file mode 100644 index 000000000000..05ffb799558b --- /dev/null +++ b/res/css/views/settings/_Notifications2.pcss @@ -0,0 +1,92 @@ +.mx_Notifications2 { + .mx_SettingsSection_subSections { + color: #17191c; + gap: 32px; + display: flex; + flex-direction: column; + } + + .mx_Notifications2_banner { + background: #F4F8FF; + line-height: 2.25rem; + border-radius: 8px; + padding: 12px 16px; + gap: 12px; + display: flex; + flex-direction: row; + align-items: center; + + p { + margin: 0; + } + + .mx_AccessibleButton { + align-self: initial; + white-space: nowrap; + } + } + + .mx_Notifications2_list { + display: flex; + flex-direction: column; + gap: 12px; + + .mx_SettingsFlag { + margin: 0; + } + } + + .mx_SettingsSubsection_description .mx_SettingsSubsection_text { + font-size: 1.2rem; + + .mx_NotificationBadge { + vertical-align: bottom; + display: inline-flex; + margin: 0 2px; + } + } + + .mx_SettingsSubsection_content { + margin-top: 12px; + grid-gap: 12px; + justify-items: stretch; + justify-content: stretch; + } + + .mx_StyledRadioButton_content { + margin-left: 10px; + margin-right: 10px; + } + + .mx_Notifications2_CombinedInput { + display: flex; + flex-direction: row; + } + + .mx_TagComposer { + margin-top: 16px; + + &.mx_TagComposer_disabled { + opacity: 0.7; + } + + .mx_TagComposer_tags { + margin-top: 16px; + gap: 8px; + + .mx_Tag { + border-radius: 18px; + line-height: 2.4rem; + padding: 6px 12px; + background: rgba(141, 151, 165, 0.15); + margin: 0; + + .mx_Tag_delete { + background: #8D97A5; + color: #fff; + align-self: initial; + } + } + } + } +} diff --git a/res/css/views/settings/tabs/_SettingsIndent.pcss b/res/css/views/settings/tabs/_SettingsIndent.pcss new file mode 100644 index 000000000000..e95f0900b5e4 --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsIndent.pcss @@ -0,0 +1,21 @@ +/* +Copyright 2023 New Vector Ltd + +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. +*/ +.mx_SettingsIndent { + padding-left: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/src/components/views/elements/TagComposer.tsx b/src/components/views/elements/TagComposer.tsx index 0fdd5e98cdb0..c0fe8b7264ea 100644 --- a/src/components/views/elements/TagComposer.tsx +++ b/src/components/views/elements/TagComposer.tsx @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; import React, { ChangeEvent, FormEvent } from "react"; import Field from "./Field"; @@ -67,7 +68,11 @@ export default class TagComposer extends React.PureComponent { public render(): React.ReactNode { return ( -
+
void] { + return useAsyncRefreshMemo(() => client.getThreePids().then((it) => it.threepids), [client], []); +} + +function usePushers(client: MatrixClient): [IPusher[], () => void] { + return useAsyncRefreshMemo(() => client.getPushers().then((it) => it.pushers), [client], []); +} + +export default function Notifications2(): JSX.Element { + const cli = MatrixClientPeg.get(); + const notificationsEnabled = useSettingValue("notificationsEnabled"); + const notificationBodyEnabled = useSettingValue("notificationBodyEnabled"); + const audioNotificationsEnabled = useSettingValue("audioNotificationsEnabled"); + + const { model, pendingChanges, reconcileModel } = useNotificationSettings(cli); + const [pushers, refreshPushers] = usePushers(cli); + const [threepids, refreshThreepids] = useThreepids(cli); + const hasPendingChanges = + pendingChanges?.added?.length !== 0 || + pendingChanges?.removed?.length !== 0 || + pendingChanges?.updated?.size !== 0; + const disabled = model === null || hasPendingChanges; + + const setEmailEnabled = useCallback( + (email: string, enabled: boolean) => { + if (enabled) { + cli.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 = pushers.find((p) => p.kind === "email" && p.pushkey === email); + if (pusher) { + cli.removePusher(pusher.pushkey, pusher.app_id); + } + } + refreshThreepids(); + refreshPushers(); + }, + [cli, pushers, refreshPushers, refreshThreepids], + ); + + return ( +
+ {hasPendingChanges && ( +
+

+ {_t( + "Update: We have updated our notification settings. This won’t affect your previously selected settings.", + {}, + { + strong: (content) => {content}, + }, + )} +

+ reconcileModel(model)}> + {_t("Switch now")} + +
+ )} + +
+ + reconcileModel({ + ...model, + notifications: value, + }) + } + /> + + SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, value) + } + /> + + SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, value) + } + /> + + SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, value) + } + /> +
+ + + reconcileModel({ + ...model, + defaultLevels: { + ...model.defaultLevels, + dm: + value !== NotificationSettingsOptions.MENTIONS_KEYWORDS + ? RoomNotifState.AllMessages + : RoomNotifState.MentionsOnly, + room: + value === NotificationSettingsOptions.ALL_MESSAGES + ? RoomNotifState.AllMessages + : RoomNotifState.MentionsOnly, + }, + }) + } + /> + + + + reconcileModel({ + ...model, + sound: { + ...model.sound, + people: value ? "default" : undefined, + }, + }) + } + /> + + reconcileModel({ + ...model, + sound: { + ...model.sound, + mentions: value ? "default" : undefined, + }, + }) + } + /> + + reconcileModel({ + ...model, + sound: { + ...model.sound, + calls: value ? "ring" : undefined, + }, + }) + } + /> + + + + reconcileModel({ + ...model, + activity: { + ...model.activity, + invite: value, + }, + }) + } + /> + + reconcileModel({ + ...model, + activity: { + ...model.activity, + status_event: value, + }, + }) + } + /> + + reconcileModel({ + ...model, + activity: { + ...model.activity, + bot_notices: value, + }, + }) + } + /> + + when keywords are used in a room.", + {}, + { + badge: , + }, + )} + > + + reconcileModel({ + ...model, + mentions: { + ...model.mentions, + room: value, + }, + }) + } + /> + + reconcileModel({ + ...model, + mentions: { + ...model.mentions, + intentional: value, + displayname: value, + mxid: value, + }, + }) + } + /> + + reconcileModel({ + ...model, + keywords: { + ...model.keywords, + enabled: value, + }, + }) + } + /> + { + reconcileModel({ + ...model, + keywords: { + ...model.keywords, + list: [...model.keywords.list, keyword], + }, + }); + }} + onRemove={(keyword) => { + reconcileModel({ + ...model, + keywords: { + ...model.keywords, + list: model.keywords.list.filter((it) => it !== keyword), + }, + }); + }} + label={_t("Keyword")} + placeholder={_t("New keyword")} + /> + + + + {_t("Receive an email summary of missed notifications")} + +
+ + {_t( + "Select which emails you want to send summaries to. Manage your emails in .", + {}, + { + button: (content) => ( + { + dispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.General, + }); + }} + > + {content} + + ), + }, + )} + +
+ + {threepids + .filter((t) => t.medium === ThreepidMedium.Email) + .map((email) => ( + it.pushkey === email.address) !== undefined} + onChange={(value) => setEmailEnabled(email.address, value)} + /> + ))} + +
+ +
    + {pushers + .filter((it) => it.kind !== "email") + .map((pusher) => ( +
  • + {pusher.device_display_name || `${pusher.app_id} on ${pusher.profile_tag}`} +
  • + ))} +
+
+ + { + await clearAllNotifications(cli); + }} + > + {_t("Mark all messages as read")} + + { + await reconcileModel({ + notifications: true, + defaultLevels: { + room: RoomNotifState.MentionsOnly, + dm: RoomNotifState.AllMessages, + }, + sound: { + people: "default", + mentions: "default", + calls: "ring", + }, + activity: { + invite: true, + status_event: false, + bot_notices: false, + }, + mentions: { + intentional: true, + displayname: true, + mxid: true, + room: true, + }, + keywords: { + enabled: true, + list: [], + }, + }); + }} + > + {_t("Reset to default settings")} + + +
+
+ ); +} diff --git a/src/components/views/settings/UseNotificationSettings.tsx b/src/components/views/settings/UseNotificationSettings.tsx new file mode 100644 index 000000000000..f147e881cbc0 --- /dev/null +++ b/src/components/views/settings/UseNotificationSettings.tsx @@ -0,0 +1,124 @@ +/* +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 { IPushRules, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useState } from "react"; + +import { computeChanges, PushRuleChanges } from "../../../models/notificationsettings/computeChanges"; +import { getContentPushRules } from "../../../models/notificationsettings/getContentPushRules"; +import { getStandardPushRules } from "../../../models/notificationsettings/getStandardPushRules"; +import { fromRules, NotificationSettingsModel } from "../../../models/notificationsettings/NotificationSettingsModel"; + +function applyChanges(cli: MatrixClient, rules: IPushRules, changes: PushRuleChanges): IPushRules { + for (const rule of changes.added) { + console.log("Adding rule: ", rule); + cli.addPushRule("global", rule.kind, rule.rule_id, rule); + } + for (const rule of changes.removed) { + const old = + rules.global.override.find((it) => it.rule_id === rule.id) ?? + rules.global.content.find((it) => it.rule_id === rule.id) ?? + rules.global.underride.find((it) => it.rule_id === rule.id); + cli.deletePushRule("global", rule.kind, rule.id); + console.log("Removing rule: ", rule, old); + } + for (const [id, rule] of changes.updated) { + const old = + rules.global.override.find((it) => it.rule_id === id) ?? + rules.global.content.find((it) => it.rule_id === id) ?? + rules.global.underride.find((it) => it.rule_id === id); + const changedAttributes = Object.keys(rule); + const oldAttributes = Object.fromEntries( + Object.entries(old).filter(([key]) => changedAttributes.includes(key)), + ); + if (rule.enabled !== undefined) { + cli.setPushRuleEnabled("global", rule.kind, id, rule.enabled); + } + if (rule.actions !== undefined) { + cli.setPushRuleActions("global", rule.kind, id, rule.actions); + } + console.log(`Updating rule ${id}: `, rule, oldAttributes); + } + + return { + ...rules, + global: { + ...rules.global, + underride: rules.global.underride.map((it) => ({ + ...it, + ...changes.updated.get(it.rule_id), + })), + content: rules.global.content + .filter((it) => changes.removed.find((key) => key.id === it.rule_id) === undefined) + .map((it) => ({ + ...it, + ...changes.updated.get(it.rule_id), + })) + .concat(changes.added), + override: rules.global.override.map((it) => ({ + ...it, + ...changes.updated.get(it.rule_id), + })), + }, + }; +} + +type UseNotificationSettings = { + model: NotificationSettingsModel | null; + pendingChanges: PushRuleChanges | null; + reconcileModel: (model: NotificationSettingsModel) => Promise; + refresh: () => () => void; +}; + +export function useNotificationSettings(cli: MatrixClient): UseNotificationSettings { + const [, setPushRules] = useState(null); + const [pendingChanges, setPendingChanges] = useState(null); + const [model, setModel] = useState(null); + const refresh = useCallback(() => { + let discard = false; + cli.getPushRules().then((rules) => { + if (!discard) { + const standard = getStandardPushRules(rules); + const content = getContentPushRules(rules); + const model = fromRules(standard, content); + setPushRules(rules); + setModel(model); + setPendingChanges(computeChanges(rules, model)); + console.log("RECEIVED PUSH RULES: ", model, rules); + } + }); + return () => { + discard = true; + }; + }, [cli]); + useEffect(refresh, [refresh]); + + const reconcileModel = useCallback( + async (model: NotificationSettingsModel) => { + setPushRules((rules) => { + const changes = computeChanges(rules, model); + const newRules = applyChanges(cli, rules, changes); + setModel(model); + setPendingChanges(computeChanges(newRules, model)); + console.log("UPDATED PUSH RULES: ", model, newRules); + return newRules; + }); + }, + [cli], + ); + + return { model, pendingChanges, reconcileModel, refresh }; +} diff --git a/src/components/views/settings/shared/SettingsIndent.tsx b/src/components/views/settings/shared/SettingsIndent.tsx new file mode 100644 index 000000000000..049f2303f88c --- /dev/null +++ b/src/components/views/settings/shared/SettingsIndent.tsx @@ -0,0 +1,27 @@ +/* +Copyright 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, { HTMLAttributes } from "react"; + +export interface SettingsIndentProps extends HTMLAttributes { + children?: React.ReactNode; +} + +export const SettingsIndent: React.FC = ({ children, ...rest }) => ( +
+ {children} +
+); diff --git a/src/components/views/settings/shared/SettingsSection.tsx b/src/components/views/settings/shared/SettingsSection.tsx index 1fc00905653c..33e54aa155fa 100644 --- a/src/components/views/settings/shared/SettingsSection.tsx +++ b/src/components/views/settings/shared/SettingsSection.tsx @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classnames from "classnames"; import React, { HTMLAttributes } from "react"; import Heading from "../../typography/Heading"; @@ -40,8 +41,8 @@ export interface SettingsSectionProps extends HTMLAttributes { * * ``` */ -export const SettingsSection: React.FC = ({ heading, children, ...rest }) => ( -
+export const SettingsSection: React.FC = ({ className, heading, children, ...rest }) => ( +
{typeof heading === "string" ? {heading} : <>{heading}}
{children}
diff --git a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx index 4e95220df1f0..697abacf495f 100644 --- a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx @@ -16,18 +16,14 @@ limitations under the License. import React from "react"; -import { _t } from "../../../../../languageHandler"; -import Notifications from "../../Notifications"; -import { SettingsSection } from "../../shared/SettingsSection"; +import Notifications2 from "../../Notifications2"; import SettingsTab from "../SettingsTab"; export default class NotificationUserSettingsTab extends React.Component { public render(): React.ReactNode { return ( - - - + ); } diff --git a/src/hooks/useAsyncRefreshMemo.ts b/src/hooks/useAsyncRefreshMemo.ts new file mode 100644 index 000000000000..196e259d94fa --- /dev/null +++ b/src/hooks/useAsyncRefreshMemo.ts @@ -0,0 +1,38 @@ +/* +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 { DependencyList, useCallback, useEffect, useState } from "react"; + +type Fn = () => Promise; + +export function useAsyncRefreshMemo(fn: Fn, deps: DependencyList, initialValue: T): [T, () => void]; +export function useAsyncRefreshMemo(fn: Fn, deps: DependencyList, initialValue?: T): [T | undefined, () => void]; +export function useAsyncRefreshMemo(fn: Fn, deps: DependencyList, initialValue?: T): [T | undefined, () => void] { + const [value, setValue] = useState(initialValue); + const refresh = useCallback(() => { + let discard = false; + fn().then((v) => { + if (!discard) { + setValue(v); + } + }); + return () => { + discard = true; + }; + }, deps); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(refresh, [refresh]); + return [value, refresh]; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f4e329696e02..2063e00823ae 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1442,6 +1442,33 @@ "Mentions & keywords": "Mentions & keywords", "Notification targets": "Notification targets", "There was an error loading your notification settings.": "There was an error loading your notification settings.", + "All messages": "All messages", + "People, Mentions and Keywords": "People, Mentions and Keywords", + "Mentions and Keywords only": "Mentions and Keywords only", + "Update: We have updated our notification settings. This won’t affect your previously selected settings.": "Update: We have updated our notification settings. This won’t affect your previously selected settings.", + "Switch now": "Switch now", + "Show message preview in desktop notification": "Show message preview in desktop notification", + "I want to be notified for (Default Setting)": "I want to be notified for (Default Setting)", + "This setting will be applied by default to all your rooms.": "This setting will be applied by default to all your rooms.", + "Play a sound for": "Play a sound for", + "Applied by default to all rooms on all devices.": "Applied by default to all rooms on all devices.", + "Mentions and Keywords": "Mentions and Keywords", + "Audio and Video calls": "Audio and Video calls", + "Other things we think you might be interested in:": "Other things we think you might be interested in:", + "Invited to a room": "Invited to a room", + "New room activity, upgrades and status messages occur": "New room activity, upgrades and status messages occur", + "Messages are sent by a bot": "Messages are sent by a bot", + "Show a badge when keywords are used in a room.": "Show a badge when keywords are used in a room.", + "Notify when someone mentions using @room": "Notify when someone mentions using @room", + "Notify when someone mentions using @displayname or @mxid": "Notify when someone mentions using @displayname or @mxid", + "Notify when someone uses a keyword": "Notify when someone uses a keyword", + "Enter keywords here, or use for spelling variations or nicknames": "Enter keywords here, or use for spelling variations or nicknames", + "Email summary": "Email summary", + "Receive an email summary of missed notifications": "Receive an email summary of missed notifications", + "Select which emails you want to send summaries to. Manage your emails in .": "Select which emails you want to send summaries to. Manage your emails in .", + "Quick Actions": "Quick Actions", + "Mark all messages as read": "Mark all messages as read", + "Reset to default settings": "Reset to default settings", "Failed to save your profile": "Failed to save your profile", "The operation could not be completed": "The operation could not be completed", "Display Name": "Display Name", @@ -1684,7 +1711,6 @@ "Room Addresses": "Room Addresses", "Uploaded sound": "Uploaded sound", "Get notifications as set up in your settings": "Get notifications as set up in your settings", - "All messages": "All messages", "Get notified for every message": "Get notified for every message", "@mentions & keywords": "@mentions & keywords", "Get notified only with mentions and keywords as set up in your settings": "Get notified only with mentions and keywords as set up in your settings", diff --git a/src/models/notificationsettings/ActionData.ts b/src/models/notificationsettings/ActionData.ts new file mode 100644 index 000000000000..2b130919cad9 --- /dev/null +++ b/src/models/notificationsettings/ActionData.ts @@ -0,0 +1,65 @@ +/* +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 { PushRuleActionName, TweakName } from "matrix-js-sdk/src/matrix"; + +import { StandardPushRule } from "./StandardPushRule"; + +export type ActionData = { + notify: boolean | null; + coalesce: boolean; + tweaks: { + [TweakName.Highlight]?: boolean; + [TweakName.Sound]?: string; + }; +}; + +export function parseActions(rule: StandardPushRule | null | undefined): ActionData | null { + if (!rule || !rule.enabled) { + return null; + } + + const result: ActionData = { + notify: null, + coalesce: false, + tweaks: {}, + }; + for (const action of rule.actions) { + switch (action) { + case PushRuleActionName.Notify: + result.notify = true; + break; + case PushRuleActionName.DontNotify: + result.notify = false; + result.coalesce = false; + break; + case PushRuleActionName.Coalesce: + result.coalesce = true; + result.notify = true; + break; + default: + switch (action.set_tweak) { + case TweakName.Highlight: + result.tweaks[TweakName.Highlight] = action.value; + break; + case TweakName.Sound: + result.tweaks[TweakName.Sound] = action.value; + break; + } + } + } + return result; +} diff --git a/src/models/notificationsettings/NotifState.ts b/src/models/notificationsettings/NotifState.ts new file mode 100644 index 000000000000..31f35cd3b244 --- /dev/null +++ b/src/models/notificationsettings/NotifState.ts @@ -0,0 +1,38 @@ +/* +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 { PushRuleAction, PushRuleActionName } from "../../../../matrix-js-sdk"; +import { RoomNotifState } from "../../RoomNotifs"; +import { parseActions } from "./ActionData"; +import { StandardPushRule } from "./StandardPushRule"; + +export function parseNotificationState(rule: StandardPushRule | undefined): RoomNotifState { + const actions = parseActions(rule); + if (actions.notify === true) { + return RoomNotifState.AllMessages; + } else { + return RoomNotifState.MentionsOnly; + } +} + +export function notificationStateToAction(state: RoomNotifState, notifyActions: PushRuleAction[]): PushRuleAction[] { + switch (state) { + case RoomNotifState.MentionsOnly: + return [PushRuleActionName.DontNotify]; + case RoomNotifState.AllMessages: + return [PushRuleActionName.Notify, ...notifyActions]; + } +} diff --git a/src/models/notificationsettings/NotificationSettingsModel.ts b/src/models/notificationsettings/NotificationSettingsModel.ts new file mode 100644 index 000000000000..4c121e4355f0 --- /dev/null +++ b/src/models/notificationsettings/NotificationSettingsModel.ts @@ -0,0 +1,207 @@ +/* +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 { + IAnnotatedPushRule, + PushRuleAction, + PushRuleActionName, + PushRuleKind, + RuleId, + TweakName, +} from "matrix-js-sdk/src/matrix"; + +import { RoomNotifState } from "../../RoomNotifs"; +import { parseActions } from "./ActionData"; +import { notificationStateToAction, parseNotificationState } from "./NotifState"; +import { standardPushRule, StandardPushRule } from "./StandardPushRule"; + +export type NotificationSettingsModel = { + notifications: boolean; + defaultLevels: { + room: RoomNotifState; + dm: RoomNotifState; + }; + sound: { + people: string | undefined; + mentions: string | undefined; + calls: string | undefined; + }; + activity: { + invite: boolean; + status_event: boolean; + bot_notices: boolean; + }; + mentions: { + intentional: boolean; + displayname: boolean; + mxid: boolean; + room: boolean; + }; + keywords: { + enabled: boolean; + list: string[]; + }; +}; + +export function fromRules( + rules: Map, + content: IAnnotatedPushRule[], +): NotificationSettingsModel { + return { + notifications: !(rules.get(RuleId.Master)?.enabled ?? false), + defaultLevels: { + room: parseNotificationState(rules.get(RuleId.Message)), + dm: parseNotificationState(rules.get(RuleId.DM)), + }, + sound: { + calls: parseActions(rules.get(RuleId.IncomingCall))?.tweaks?.sound, + mentions: + parseActions(rules.get(RuleId.IsUserMention))?.tweaks?.sound ?? + parseActions(rules.get(RuleId.ContainsUserName))?.tweaks?.sound ?? + parseActions(rules.get(RuleId.ContainsDisplayName))?.tweaks?.sound ?? + parseActions(rules.get(RuleId.IsRoomMention))?.tweaks?.sound ?? + parseActions(rules.get(RuleId.AtRoomNotification))?.tweaks?.sound, + people: + parseActions(rules.get(RuleId.DM))?.tweaks?.sound ?? + parseActions(rules.get(RuleId.EncryptedMessage))?.tweaks?.sound, + }, + activity: { + bot_notices: !(rules.get(RuleId.SuppressNotices)?.enabled ?? false), + invite: rules.get(RuleId.InviteToSelf)?.enabled ?? false, + status_event: rules.get(RuleId.MemberEvent)?.enabled || rules.get(RuleId.Tombstone)?.enabled || false, + }, + mentions: { + intentional: rules.get(RuleId.IsUserMention)?.enabled ?? false, + displayname: rules.get(RuleId.ContainsDisplayName)?.enabled ?? false, + mxid: rules.get(RuleId.ContainsUserName)?.enabled ?? false, + room: rules.get(RuleId.IsRoomMention)?.enabled ?? rules.get(RuleId.AtRoomNotification)?.enabled ?? false, + }, + keywords: { + enabled: content.find((it) => !it.enabled) === undefined, + list: content.map((it) => it.pattern), + }, + }; +} + +function highlightAction(value: boolean): PushRuleAction { + return { set_tweak: TweakName.Highlight, value }; +} + +function soundAction(value: string | undefined): PushRuleAction { + return { set_tweak: TweakName.Sound, value }; +} + +export function toRules(settings: NotificationSettingsModel): [Map, IAnnotatedPushRule[]] { + const rules: Map = new Map(); + rules.set( + RuleId.Master, + standardPushRule(!settings.notifications, PushRuleKind.Override, [PushRuleActionName.DontNotify]), + ); + + rules.set( + RuleId.IncomingCall, + standardPushRule(true, PushRuleKind.Underride, [ + PushRuleActionName.Notify, + highlightAction(false), + settings.sound.calls && soundAction(settings.sound.calls), + ]), + ); + + // default + const messageRule = standardPushRule( + true, + PushRuleKind.Underride, + notificationStateToAction(settings.defaultLevels.room, [highlightAction(false)]), + ); + rules.set(RuleId.Message, messageRule); + rules.set(RuleId.EncryptedMessage, messageRule); + const dmRule = standardPushRule( + true, + PushRuleKind.Underride, + notificationStateToAction(settings.defaultLevels.dm, [ + highlightAction(false), + settings.sound.people && soundAction(settings.sound.people), + ]), + ); + rules.set(RuleId.DM, dmRule); + rules.set(RuleId.EncryptedDM, dmRule); + + // activity + rules.set( + RuleId.InviteToSelf, + standardPushRule(settings.activity.invite, PushRuleKind.Override, [ + PushRuleActionName.Notify, + highlightAction(false), + settings.sound.people && soundAction(settings.sound.people), + ]), + ); + + rules.set( + RuleId.SuppressNotices, + standardPushRule(!settings.activity.bot_notices, PushRuleKind.Override, [PushRuleActionName.DontNotify]), + ); + rules.set( + RuleId.MemberEvent, + standardPushRule(settings.activity.status_event, PushRuleKind.Override, [PushRuleActionName.DontNotify]), + ); + rules.set( + RuleId.Tombstone, + standardPushRule(settings.activity.status_event, PushRuleKind.Override, [ + PushRuleActionName.Notify, + highlightAction(true), + ]), + ); + + // mentions + const mentionActions: PushRuleAction[] = [ + PushRuleActionName.Notify, + highlightAction(true), + settings.sound.mentions && soundAction(settings.sound.mentions), + ]; + rules.set( + RuleId.IsUserMention, + standardPushRule(settings.mentions.intentional, PushRuleKind.Override, mentionActions), + ); + rules.set( + RuleId.ContainsDisplayName, + standardPushRule(settings.mentions.displayname, PushRuleKind.Override, mentionActions), + ); + rules.set(RuleId.ContainsUserName, standardPushRule(settings.mentions.mxid, PushRuleKind.Override, mentionActions)); + rules.set( + RuleId.IsRoomMention, + standardPushRule(settings.mentions.room, PushRuleKind.Override, [ + PushRuleActionName.Notify, + highlightAction(false), + ]), + ); + rules.set( + RuleId.AtRoomNotification, + standardPushRule(settings.mentions.room, PushRuleKind.Override, [ + PushRuleActionName.Notify, + highlightAction(false), + ]), + ); + + const content: IAnnotatedPushRule[] = settings.keywords.list.map((keyword) => ({ + rule_id: keyword, + kind: PushRuleKind.ContentSpecific, + default: false, + enabled: settings.keywords.enabled, + pattern: keyword, + actions: [PushRuleActionName.Notify, highlightAction(false)], + })); + return [rules, content]; +} diff --git a/src/models/notificationsettings/StandardPushRule.ts b/src/models/notificationsettings/StandardPushRule.ts new file mode 100644 index 000000000000..b0456c961ee6 --- /dev/null +++ b/src/models/notificationsettings/StandardPushRule.ts @@ -0,0 +1,51 @@ +/* +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 { PushRuleAction, PushRuleKind, TweakName } from "matrix-js-sdk/src/matrix"; + +export type StandardPushRule = { + enabled: boolean; + kind: PushRuleKind; + actions: PushRuleAction[]; +}; + +export function comparePushRuleActions(a, b): number { + const nameA = typeof a === "string" ? a : `set_tweak:${a.set_tweak}`; + const nameB = typeof b === "string" ? b : `set_tweak:${b.set_tweak}`; + return nameA.localeCompare(nameB); +} + +export function standardPushRule( + enabled: boolean, + kind: PushRuleKind, + actions: (PushRuleAction | null | undefined | false)[] = [], +): StandardPushRule { + const actualActions: PushRuleAction[] = []; + for (const action of actions) { + if (typeof action === "object") { + if (action.set_tweak === TweakName.Sound) { + actualActions.push({ set_tweak: TweakName.Sound, value: action.value ?? "default" }); + } + if (action.set_tweak === TweakName.Highlight) { + actualActions.push({ set_tweak: TweakName.Highlight, value: action.value ?? true }); + } + } else if (typeof action === "string") { + actualActions.push(action); + } + } + actualActions.sort(comparePushRuleActions); + return { enabled, kind, actions: actualActions }; +} diff --git a/src/models/notificationsettings/computeChanges.ts b/src/models/notificationsettings/computeChanges.ts new file mode 100644 index 000000000000..098dbd99246e --- /dev/null +++ b/src/models/notificationsettings/computeChanges.ts @@ -0,0 +1,84 @@ +/* +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 { IAnnotatedPushRule, IPushRules, RuleId } from "matrix-js-sdk/src/matrix"; +import { deepCompare } from "matrix-js-sdk/src/utils"; + +import { getContentPushRules } from "./getContentPushRules"; +import { getStandardPushRules } from "./getStandardPushRules"; +import { NotificationSettingsModel, toRules } from "./NotificationSettingsModel"; +import { pushRuleKey, PushRuleKey } from "./pushRuleKey"; +import { comparePushRuleActions } from "./StandardPushRule"; + +export type PushRuleChanges = { + updated: Map; + added: IAnnotatedPushRule[]; + removed: PushRuleKey[]; +}; + +export type PushRuleUpdate = Pick & Partial>; + +export function computeChanges(rules: IPushRules, model: NotificationSettingsModel): PushRuleChanges { + const originalStandardRules = getStandardPushRules(rules); + const originalContentRules = getContentPushRules(rules); + const [changedStandardRules, changedContentRules] = toRules(model); + + const updated = new Map(); + const added: IAnnotatedPushRule[] = []; + const removed: PushRuleKey[] = []; + const keys = new Set([...originalStandardRules.keys(), ...changedStandardRules.keys()]); + for (const key of keys) { + const original = originalStandardRules.get(key); + const changed = changedStandardRules.get(key); + + const toChange: PushRuleUpdate = { + kind: original.kind, + }; + let hasChanged = false; + if (original?.enabled !== changed?.enabled) { + toChange.enabled = changed.enabled; + hasChanged = true; + } + const originalActions = original?.actions?.sort(comparePushRuleActions); + const changedActions = changed?.actions?.sort(comparePushRuleActions); + if (!deepCompare(originalActions, changedActions)) { + toChange.actions = changed.actions; + hasChanged = true; + } + if (hasChanged) { + updated.set(key, toChange); + } + } + + const contentRules = new Map(); + for (const rule of originalContentRules) { + contentRules.set(rule.rule_id, rule); + } + for (const rule of changedContentRules) { + const original = contentRules.get(rule.rule_id); + contentRules.delete(rule.rule_id); + if (original === undefined) { + added.push(rule); + } else if (!deepCompare(original, rule)) { + updated.set(rule.rule_id, rule); + } + } + for (const [id, rule] of contentRules.entries()) { + removed.push(pushRuleKey(rule.kind, id)); + } + + return { added, removed, updated }; +} diff --git a/src/models/notificationsettings/getContentPushRules.ts b/src/models/notificationsettings/getContentPushRules.ts new file mode 100644 index 000000000000..fc50e19ed2e8 --- /dev/null +++ b/src/models/notificationsettings/getContentPushRules.ts @@ -0,0 +1,26 @@ +/* +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 { IAnnotatedPushRule, IPushRules, PushRuleKind } from "matrix-js-sdk/src/matrix"; + +export function getContentPushRules(raw: IPushRules): IAnnotatedPushRule[] { + return ( + raw.global.content + ?.filter((it) => !it.default) + ?.map((it) => ({ ...it, kind: PushRuleKind.ContentSpecific })) + ?.sort((a, b) => a.rule_id.localeCompare(b.rule_id)) ?? [] + ); +} diff --git a/src/models/notificationsettings/getRoomPushRules.ts b/src/models/notificationsettings/getRoomPushRules.ts new file mode 100644 index 000000000000..1c0d201d5636 --- /dev/null +++ b/src/models/notificationsettings/getRoomPushRules.ts @@ -0,0 +1,64 @@ +/* +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 { ConditionKind, IPushRule, IPushRules } from "matrix-js-sdk/src/matrix"; + +import { RoomNotifState } from "../../RoomNotifs"; +import { parseActions } from "./ActionData"; + +function matchRoomOverrideRule(rule: IPushRule): string[] { + if (rule.default || !rule.enabled) { + // room overrides are never default rules, and we only care about them when they're enabled + return []; + } + + const result: string[] = []; + for (const condition of rule.conditions ?? []) { + if (condition.kind === ConditionKind.EventMatch && condition.key === "room_id") { + result.push(condition.value ?? condition.pattern); + } else { + return []; + } + } + return result; +} + +export function getRoomPushRules(raw: IPushRules): Map { + const result = new Map(); + for (const rule of raw.global.room ?? []) { + const data = parseActions(rule); + if (data) { + if (data.notify) { + result.set(rule.rule_id, RoomNotifState.AllMessages); + } else { + result.set(rule.rule_id, RoomNotifState.MentionsOnly); + } + } + } + for (const rule of raw.global.override ?? []) { + for (const room of matchRoomOverrideRule(rule)) { + const data = parseActions(rule); + if (data) { + if (data.notify) { + result.set(room, RoomNotifState.AllMessages); + } else { + result.set(room, RoomNotifState.Mute); + } + } + } + } + return result; +} diff --git a/src/models/notificationsettings/getStandardPushRules.ts b/src/models/notificationsettings/getStandardPushRules.ts new file mode 100644 index 000000000000..91a63c949262 --- /dev/null +++ b/src/models/notificationsettings/getStandardPushRules.ts @@ -0,0 +1,32 @@ +/* +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 { IPushRules, PushRuleKind, RuleId } from "matrix-js-sdk/src/matrix"; + +import { isRuleId } from "./isRuleId"; +import { StandardPushRule, standardPushRule } from "./StandardPushRule"; + +export function getStandardPushRules(raw: IPushRules): Map { + const rules = new Map(); + for (const kind of Object.values(PushRuleKind)) { + for (const rule of raw.global[kind] ?? []) { + if (rule.default && isRuleId(rule.rule_id)) { + rules.set(rule.rule_id, standardPushRule(rule.enabled, kind, rule.actions)); + } + } + } + return rules; +} diff --git a/src/models/notificationsettings/isRuleId.ts b/src/models/notificationsettings/isRuleId.ts new file mode 100644 index 000000000000..87072f63e965 --- /dev/null +++ b/src/models/notificationsettings/isRuleId.ts @@ -0,0 +1,21 @@ +/* +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 { RuleId } from "matrix-js-sdk/src/matrix"; + +export function isRuleId(id: RuleId | string): id is RuleId { + return Object.values(RuleId).includes(id as RuleId); +} diff --git a/src/models/notificationsettings/pushRuleKey.ts b/src/models/notificationsettings/pushRuleKey.ts new file mode 100644 index 000000000000..64d2b2bffaa1 --- /dev/null +++ b/src/models/notificationsettings/pushRuleKey.ts @@ -0,0 +1,25 @@ +/* +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 { RuleId, PushRuleKind } from "matrix-js-sdk/src/matrix"; + +export type PushRuleKey = { + kind: PushRuleKind; + id: RuleId | string; +}; +export function pushRuleKey(kind: PushRuleKind, id: RuleId | string): PushRuleKey { + return { kind, id }; +}