Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit e15ef9f

Browse files
author
Germain
authored
Add device notifications enabled switch (#9324)
1 parent 1a0dbbf commit e15ef9f

File tree

9 files changed

+251
-31
lines changed

9 files changed

+251
-31
lines changed

src/components/structures/MatrixChat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext";
137137
import { UseCaseSelection } from '../views/elements/UseCaseSelection';
138138
import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig';
139139
import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
140+
import { createLocalNotificationSettingsIfNeeded } from '../../utils/notifications';
140141

141142
// legacy export
142143
export { default as Views } from "../../Views";
@@ -1257,6 +1258,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
12571258
this.themeWatcher.recheck();
12581259
StorageManager.tryPersistStorage();
12591260

1261+
const cli = MatrixClientPeg.get();
1262+
createLocalNotificationSettingsIfNeeded(cli);
1263+
12601264
if (
12611265
MatrixClientPeg.currentUserIsJustRegistered() &&
12621266
SettingsStore.getValue("FTUE.useCaseSelection") === null

src/components/views/elements/LabelledToggleSwitch.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import React from "react";
1818
import classNames from "classnames";
1919

2020
import ToggleSwitch from "./ToggleSwitch";
21+
import { Caption } from "../typography/Caption";
2122

2223
interface IProps {
2324
// The value for the toggle switch
2425
value: boolean;
2526
// The translated label for the switch
2627
label: string;
28+
// The translated caption for the switch
29+
caption?: string;
2730
// Whether or not to disable the toggle switch
2831
disabled?: boolean;
2932
// True to put the toggle in front of the label
@@ -38,8 +41,14 @@ interface IProps {
3841
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
3942
public render() {
4043
// This is a minimal version of a SettingsFlag
41-
42-
let firstPart = <span className="mx_SettingsFlag_label">{ this.props.label }</span>;
44+
const { label, caption } = this.props;
45+
let firstPart = <span className="mx_SettingsFlag_label">
46+
{ label }
47+
{ caption && <>
48+
<br />
49+
<Caption>{ caption }</Caption>
50+
</> }
51+
</span>;
4352
let secondPart = <ToggleSwitch
4453
checked={this.props.value}
4554
disabled={this.props.disabled}

src/components/views/settings/Notifications.tsx

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import React from "react";
1818
import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
1919
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
2020
import { logger } from "matrix-js-sdk/src/logger";
21+
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
2122

2223
import Spinner from "../elements/Spinner";
2324
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -41,6 +42,7 @@ import AccessibleButton from "../elements/AccessibleButton";
4142
import TagComposer from "../elements/TagComposer";
4243
import { objectClone } from "../../../utils/objects";
4344
import { arrayDiff } from "../../../utils/arrays";
45+
import { getLocalNotificationAccountDataEventType } from "../../../utils/notifications";
4446

4547
// TODO: this "view" component still has far too much application logic in it,
4648
// which should be factored out to other files.
@@ -106,6 +108,7 @@ interface IState {
106108
pushers?: IPusher[];
107109
threepids?: IThreepid[];
108110

111+
deviceNotificationsEnabled: boolean;
109112
desktopNotifications: boolean;
110113
desktopShowBody: boolean;
111114
audioNotifications: boolean;
@@ -119,6 +122,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
119122

120123
this.state = {
121124
phase: Phase.Loading,
125+
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? false,
122126
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
123127
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
124128
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
@@ -128,6 +132,9 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
128132
SettingsStore.watchSetting("notificationsEnabled", null, (...[,,,, value]) =>
129133
this.setState({ desktopNotifications: value as boolean }),
130134
),
135+
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[,,,, value]) => {
136+
this.setState({ deviceNotificationsEnabled: value as boolean });
137+
}),
131138
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[,,,, value]) =>
132139
this.setState({ desktopShowBody: value as boolean }),
133140
),
@@ -148,12 +155,19 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
148155
public componentDidMount() {
149156
// noinspection JSIgnoredPromiseFromCall
150157
this.refreshFromServer();
158+
this.refreshFromAccountData();
151159
}
152160

153161
public componentWillUnmount() {
154162
this.settingWatchers.forEach(watcher => SettingsStore.unwatchSetting(watcher));
155163
}
156164

165+
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
166+
if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) {
167+
this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled);
168+
}
169+
}
170+
157171
private async refreshFromServer() {
158172
try {
159173
const newState = (await Promise.all([
@@ -162,7 +176,9 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
162176
this.refreshThreepids(),
163177
])).reduce((p, c) => Object.assign(c, p), {});
164178

165-
this.setState<keyof Omit<IState, "desktopNotifications" | "desktopShowBody" | "audioNotifications">>({
179+
this.setState<keyof Omit<IState,
180+
"deviceNotificationsEnabled" | "desktopNotifications" | "desktopShowBody" | "audioNotifications">
181+
>({
166182
...newState,
167183
phase: Phase.Ready,
168184
});
@@ -172,6 +188,22 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
172188
}
173189
}
174190

191+
private async refreshFromAccountData() {
192+
const cli = MatrixClientPeg.get();
193+
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
194+
if (settingsEvent) {
195+
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
196+
await this.updateDeviceNotifications(notificationsEnabled);
197+
}
198+
}
199+
200+
private persistLocalNotificationSettings(enabled: boolean): Promise<{}> {
201+
const cli = MatrixClientPeg.get();
202+
return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), {
203+
is_silenced: !enabled,
204+
});
205+
}
206+
175207
private async refreshRules(): Promise<Partial<IState>> {
176208
const ruleSets = await MatrixClientPeg.get().getPushRules();
177209
const categories = {
@@ -297,6 +329,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
297329
}
298330
};
299331

332+
private updateDeviceNotifications = async (checked: boolean) => {
333+
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
334+
};
335+
300336
private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
301337
this.setState({ phase: Phase.Persisting });
302338

@@ -497,7 +533,8 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
497533
const masterSwitch = <LabelledToggleSwitch
498534
data-test-id='notif-master-switch'
499535
value={!this.isInhibited}
500-
label={_t("Enable for this account")}
536+
label={_t("Enable notifications for this account")}
537+
caption={_t("Turn off to disable notifications on all your devices and sessions")}
501538
onChange={this.onMasterRuleChanged}
502539
disabled={this.state.phase === Phase.Persisting}
503540
/>;
@@ -521,28 +558,36 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
521558
{ masterSwitch }
522559

523560
<LabelledToggleSwitch
524-
data-test-id='notif-setting-notificationsEnabled'
525-
value={this.state.desktopNotifications}
526-
onChange={this.onDesktopNotificationsChanged}
527-
label={_t('Enable desktop notifications for this session')}
561+
data-test-id='notif-device-switch'
562+
value={this.state.deviceNotificationsEnabled}
563+
label={_t("Enable notifications for this device")}
564+
onChange={checked => this.updateDeviceNotifications(checked)}
528565
disabled={this.state.phase === Phase.Persisting}
529566
/>
530567

531-
<LabelledToggleSwitch
532-
data-test-id='notif-setting-notificationBodyEnabled'
533-
value={this.state.desktopShowBody}
534-
onChange={this.onDesktopShowBodyChanged}
535-
label={_t('Show message in desktop notification')}
536-
disabled={this.state.phase === Phase.Persisting}
537-
/>
538-
539-
<LabelledToggleSwitch
540-
data-test-id='notif-setting-audioNotificationsEnabled'
541-
value={this.state.audioNotifications}
542-
onChange={this.onAudioNotificationsChanged}
543-
label={_t('Enable audible notifications for this session')}
544-
disabled={this.state.phase === Phase.Persisting}
545-
/>
568+
{ this.state.deviceNotificationsEnabled && (<>
569+
<LabelledToggleSwitch
570+
data-test-id='notif-setting-notificationsEnabled'
571+
value={this.state.desktopNotifications}
572+
onChange={this.onDesktopNotificationsChanged}
573+
label={_t('Enable desktop notifications for this session')}
574+
disabled={this.state.phase === Phase.Persisting}
575+
/>
576+
<LabelledToggleSwitch
577+
data-test-id='notif-setting-notificationBodyEnabled'
578+
value={this.state.desktopShowBody}
579+
onChange={this.onDesktopShowBodyChanged}
580+
label={_t('Show message in desktop notification')}
581+
disabled={this.state.phase === Phase.Persisting}
582+
/>
583+
<LabelledToggleSwitch
584+
data-test-id='notif-setting-audioNotificationsEnabled'
585+
value={this.state.audioNotifications}
586+
onChange={this.onAudioNotificationsChanged}
587+
label={_t('Enable audible notifications for this session')}
588+
disabled={this.state.phase === Phase.Persisting}
589+
/>
590+
</>) }
546591

547592
{ emailSwitches }
548593
</>;

src/i18n/strings/en_EN.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1361,8 +1361,10 @@
13611361
"Messages containing keywords": "Messages containing keywords",
13621362
"Error saving notification preferences": "Error saving notification preferences",
13631363
"An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
1364-
"Enable for this account": "Enable for this account",
1364+
"Enable notifications for this account": "Enable notifications for this account",
1365+
"Turn off to disable notifications on all your devices and sessions": "Turn off to disable notifications on all your devices and sessions",
13651366
"Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
1367+
"Enable notifications for this device": "Enable notifications for this device",
13661368
"Enable desktop notifications for this session": "Enable desktop notifications for this session",
13671369
"Show message in desktop notification": "Show message in desktop notification",
13681370
"Enable audible notifications for this session": "Enable audible notifications for this session",

src/settings/Settings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
790790
default: false,
791791
controller: new NotificationsEnabledController(),
792792
},
793+
"deviceNotificationsEnabled": {
794+
supportedLevels: [SettingLevel.DEVICE],
795+
default: false,
796+
},
793797
"notificationSound": {
794798
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
795799
default: false,

src/utils/notifications.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event";
18+
import { MatrixClient } from "matrix-js-sdk/src/client";
19+
20+
import SettingsStore from "../settings/SettingsStore";
21+
22+
export const deviceNotificationSettingsKeys = [
23+
"notificationsEnabled",
24+
"notificationBodyEnabled",
25+
"audioNotificationsEnabled",
26+
];
27+
28+
export function getLocalNotificationAccountDataEventType(deviceId: string): string {
29+
return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
30+
}
31+
32+
export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise<void> {
33+
const eventType = getLocalNotificationAccountDataEventType(cli.deviceId);
34+
const event = cli.getAccountData(eventType);
35+
36+
// New sessions will create an account data event to signify they support
37+
// remote toggling of push notifications on this device. Default `is_silenced=true`
38+
// For backwards compat purposes, older sessions will need to check settings value
39+
// to determine what the state of `is_silenced`
40+
if (!event) {
41+
// If any of the above is true, we fall in the "backwards compat" case,
42+
// and `is_silenced` will be set to `false`
43+
const isSilenced = !deviceNotificationSettingsKeys.some(key => SettingsStore.getValue(key));
44+
45+
await cli.setAccountData(eventType, {
46+
is_silenced: isSilenced,
47+
});
48+
}
49+
}

test/components/views/settings/Notifications-test.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ limitations under the License.
1515
import React from 'react';
1616
// eslint-disable-next-line deprecate/import
1717
import { mount, ReactWrapper } from 'enzyme';
18-
import { IPushRule, IPushRules, RuleId, IPusher } from 'matrix-js-sdk/src/matrix';
18+
import {
19+
IPushRule,
20+
IPushRules,
21+
RuleId,
22+
IPusher,
23+
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
24+
MatrixEvent,
25+
} from 'matrix-js-sdk/src/matrix';
1926
import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids';
2027
import { act } from 'react-dom/test-utils';
2128

@@ -67,6 +74,17 @@ describe('<Notifications />', () => {
6774
setPushRuleEnabled: jest.fn(),
6875
setPushRuleActions: jest.fn(),
6976
getRooms: jest.fn().mockReturnValue([]),
77+
getAccountData: jest.fn().mockImplementation(eventType => {
78+
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
79+
return new MatrixEvent({
80+
type: eventType,
81+
content: {
82+
is_silenced: false,
83+
},
84+
});
85+
}
86+
}),
87+
setAccountData: jest.fn(),
7088
});
7189
mockClient.getPushRules.mockResolvedValue(pushRules);
7290

@@ -117,6 +135,7 @@ describe('<Notifications />', () => {
117135
const component = await getComponentAndWait();
118136

119137
expect(findByTestId(component, 'notif-master-switch').length).toBeTruthy();
138+
expect(findByTestId(component, 'notif-device-switch').length).toBeTruthy();
120139
expect(findByTestId(component, 'notif-setting-notificationsEnabled').length).toBeTruthy();
121140
expect(findByTestId(component, 'notif-setting-notificationBodyEnabled').length).toBeTruthy();
122141
expect(findByTestId(component, 'notif-setting-audioNotificationsEnabled').length).toBeTruthy();

test/components/views/settings/__snapshots__/Notifications-test.tsx.snap

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ exports[`<Notifications /> main notification switches renders only enable notifi
6060
className="mx_UserNotifSettings"
6161
>
6262
<LabelledToggleSwitch
63+
caption="Turn off to disable notifications on all your devices and sessions"
6364
data-test-id="notif-master-switch"
6465
disabled={false}
65-
label="Enable for this account"
66+
label="Enable notifications for this account"
6667
onChange={[Function]}
6768
value={false}
6869
>
@@ -72,18 +73,26 @@ exports[`<Notifications /> main notification switches renders only enable notifi
7273
<span
7374
className="mx_SettingsFlag_label"
7475
>
75-
Enable for this account
76+
Enable notifications for this account
77+
<br />
78+
<Caption>
79+
<span
80+
className="mx_Caption"
81+
>
82+
Turn off to disable notifications on all your devices and sessions
83+
</span>
84+
</Caption>
7685
</span>
7786
<_default
78-
aria-label="Enable for this account"
87+
aria-label="Enable notifications for this account"
7988
checked={false}
8089
disabled={false}
8190
onChange={[Function]}
8291
>
8392
<AccessibleButton
8493
aria-checked={false}
8594
aria-disabled={false}
86-
aria-label="Enable for this account"
95+
aria-label="Enable notifications for this account"
8796
className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
8897
element="div"
8998
onClick={[Function]}
@@ -93,7 +102,7 @@ exports[`<Notifications /> main notification switches renders only enable notifi
93102
<div
94103
aria-checked={false}
95104
aria-disabled={false}
96-
aria-label="Enable for this account"
105+
aria-label="Enable notifications for this account"
97106
className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
98107
onClick={[Function]}
99108
onKeyDown={[Function]}

0 commit comments

Comments
 (0)