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

Commit 0b0e414

Browse files
authored
Fix unresponsive notification toggles (#8549)
* Return consistently typed setting values from localStorage * Watch notification toggles * Test that it all works
1 parent c1579f7 commit 0b0e414

File tree

4 files changed

+69
-51
lines changed

4 files changed

+69
-51
lines changed

src/components/views/settings/Notifications.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,36 @@ interface IState {
105105
};
106106
pushers?: IPusher[];
107107
threepids?: IThreepid[];
108+
109+
desktopNotifications: boolean;
110+
desktopShowBody: boolean;
111+
audioNotifications: boolean;
108112
}
109113

110114
export default class Notifications extends React.PureComponent<IProps, IState> {
115+
private settingWatchers: string[];
116+
111117
public constructor(props: IProps) {
112118
super(props);
113119

114120
this.state = {
115121
phase: Phase.Loading,
122+
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
123+
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
124+
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
116125
};
126+
127+
this.settingWatchers = [
128+
SettingsStore.watchSetting("notificationsEnabled", null, (...[,,,, value]) =>
129+
this.setState({ desktopNotifications: value as boolean }),
130+
),
131+
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[,,,, value]) =>
132+
this.setState({ desktopShowBody: value as boolean }),
133+
),
134+
SettingsStore.watchSetting("audioNotificationsEnabled", null, (...[,,,, value]) =>
135+
this.setState({ audioNotifications: value as boolean }),
136+
),
137+
];
117138
}
118139

119140
private get isInhibited(): boolean {
@@ -129,6 +150,10 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
129150
this.refreshFromServer();
130151
}
131152

153+
public componentWillUnmount() {
154+
this.settingWatchers.forEach(watcher => SettingsStore.unwatchSetting(watcher));
155+
}
156+
132157
private async refreshFromServer() {
133158
try {
134159
const newState = (await Promise.all([
@@ -137,7 +162,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
137162
this.refreshThreepids(),
138163
])).reduce((p, c) => Object.assign(c, p), {});
139164

140-
this.setState({
165+
this.setState<keyof Omit<IState, "desktopNotifications" | "desktopShowBody" | "audioNotifications">>({
141166
...newState,
142167
phase: Phase.Ready,
143168
});
@@ -308,17 +333,14 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
308333

309334
private onDesktopNotificationsChanged = async (checked: boolean) => {
310335
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
311-
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
312336
};
313337

314338
private onDesktopShowBodyChanged = async (checked: boolean) => {
315339
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
316-
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
317340
};
318341

319342
private onAudioNotificationsChanged = async (checked: boolean) => {
320343
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
321-
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
322344
};
323345

324346
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => {
@@ -499,23 +521,23 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
499521

500522
<LabelledToggleSwitch
501523
data-test-id='notif-setting-notificationsEnabled'
502-
value={SettingsStore.getValue("notificationsEnabled")}
524+
value={this.state.desktopNotifications}
503525
onChange={this.onDesktopNotificationsChanged}
504526
label={_t('Enable desktop notifications for this session')}
505527
disabled={this.state.phase === Phase.Persisting}
506528
/>
507529

508530
<LabelledToggleSwitch
509531
data-test-id='notif-setting-notificationBodyEnabled'
510-
value={SettingsStore.getValue("notificationBodyEnabled")}
532+
value={this.state.desktopShowBody}
511533
onChange={this.onDesktopShowBodyChanged}
512534
label={_t('Show message in desktop notification')}
513535
disabled={this.state.phase === Phase.Persisting}
514536
/>
515537

516538
<LabelledToggleSwitch
517539
data-test-id='notif-setting-audioNotificationsEnabled'
518-
value={SettingsStore.getValue("audioNotificationsEnabled")}
540+
value={this.state.audioNotifications}
519541
onChange={this.onAudioNotificationsChanged}
520542
label={_t('Enable audible notifications for this session')}
521543
disabled={this.state.phase === Phase.Persisting}

src/settings/handlers/AbstractLocalStorageSettingsHandler.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import SettingsHandler from "./SettingsHandler";
2222
*/
2323
export default abstract class AbstractLocalStorageSettingsHandler extends SettingsHandler {
2424
// Shared cache between all subclass instances
25-
private static itemCache = new Map<string, any>();
25+
private static itemCache = new Map<string, string>();
2626
private static objectCache = new Map<string, object>();
2727
private static storageListenerBound = false;
2828

@@ -51,7 +51,7 @@ export default abstract class AbstractLocalStorageSettingsHandler extends Settin
5151
}
5252
}
5353

54-
protected getItem(key: string): any {
54+
protected getItem(key: string): string {
5555
if (!AbstractLocalStorageSettingsHandler.itemCache.has(key)) {
5656
const value = localStorage.getItem(key);
5757
AbstractLocalStorageSettingsHandler.itemCache.set(key, value);
@@ -61,6 +61,14 @@ export default abstract class AbstractLocalStorageSettingsHandler extends Settin
6161
return AbstractLocalStorageSettingsHandler.itemCache.get(key);
6262
}
6363

64+
protected getBoolean(key: string): boolean | null {
65+
const item = this.getItem(key);
66+
if (item === "true") return true;
67+
if (item === "false") return false;
68+
// Fall back to the next config level
69+
return null;
70+
}
71+
6472
protected getObject<T extends object>(key: string): T | null {
6573
if (!AbstractLocalStorageSettingsHandler.objectCache.has(key)) {
6674
try {
@@ -76,11 +84,15 @@ export default abstract class AbstractLocalStorageSettingsHandler extends Settin
7684
return AbstractLocalStorageSettingsHandler.objectCache.get(key) as T;
7785
}
7886

79-
protected setItem(key: string, value: any): void {
87+
protected setItem(key: string, value: string): void {
8088
AbstractLocalStorageSettingsHandler.itemCache.set(key, value);
8189
localStorage.setItem(key, value);
8290
}
8391

92+
protected setBoolean(key: string, value: boolean | null): void {
93+
this.setItem(key, `${value}`);
94+
}
95+
8496
protected setObject(key: string, value: object): void {
8597
AbstractLocalStorageSettingsHandler.objectCache.set(key, value);
8698
localStorage.setItem(key, JSON.stringify(value));

src/settings/handlers/DeviceSettingsHandler.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,11 @@ export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsH
4343

4444
// Special case notifications
4545
if (settingName === "notificationsEnabled") {
46-
const value = this.getItem("notifications_enabled");
47-
if (typeof(value) === "string") return value === "true";
48-
return null; // wrong type or otherwise not set
46+
return this.getBoolean("notifications_enabled");
4947
} else if (settingName === "notificationBodyEnabled") {
50-
const value = this.getItem("notifications_body_enabled");
51-
if (typeof(value) === "string") return value === "true";
52-
return null; // wrong type or otherwise not set
48+
return this.getBoolean("notifications_body_enabled");
5349
} else if (settingName === "audioNotificationsEnabled") {
54-
const value = this.getItem("audio_notifications_enabled");
55-
if (typeof(value) === "string") return value === "true";
56-
return null; // wrong type or otherwise not set
50+
return this.getBoolean("audio_notifications_enabled");
5751
}
5852

5953
const settings = this.getSettings() || {};
@@ -68,15 +62,15 @@ export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsH
6862

6963
// Special case notifications
7064
if (settingName === "notificationsEnabled") {
71-
this.setItem("notifications_enabled", newValue);
65+
this.setBoolean("notifications_enabled", newValue);
7266
this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
7367
return Promise.resolve();
7468
} else if (settingName === "notificationBodyEnabled") {
75-
this.setItem("notifications_body_enabled", newValue);
69+
this.setBoolean("notifications_body_enabled", newValue);
7670
this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
7771
return Promise.resolve();
7872
} else if (settingName === "audioNotificationsEnabled") {
79-
this.setItem("audio_notifications_enabled", newValue);
73+
this.setBoolean("audio_notifications_enabled", newValue);
8074
this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
8175
return Promise.resolve();
8276
}
@@ -126,15 +120,11 @@ export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsH
126120
return false;
127121
}
128122

129-
const value = this.getItem("mx_labs_feature_" + featureName);
130-
if (value === "true") return true;
131-
if (value === "false") return false;
132-
// Try to read the next config level for the feature.
133-
return null;
123+
return this.getBoolean("mx_labs_feature_" + featureName);
134124
}
135125

136126
private writeFeature(featureName: string, enabled: boolean | null) {
137-
this.setItem("mx_labs_feature_" + featureName, `${enabled}`);
127+
this.setBoolean("mx_labs_feature_" + featureName, enabled);
138128
this.watchers.notifyUpdate(featureName, null, SettingLevel.DEVICE, enabled);
139129
}
140130
}

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

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ limitations under the License.
1313
*/
1414

1515
import React from 'react';
16-
import { mount } from 'enzyme';
16+
import { mount, ReactWrapper } from 'enzyme';
1717
import { IPushRule, IPushRules, RuleId, IPusher } from 'matrix-js-sdk/src/matrix';
1818
import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids';
1919
import { act } from 'react-dom/test-utils';
@@ -23,16 +23,11 @@ import SettingsStore from "../../../../src/settings/SettingsStore";
2323
import { StandardActions } from '../../../../src/notifications/StandardActions';
2424
import { getMockClientWithEventEmitter } from '../../../test-utils';
2525

26-
jest.mock('../../../../src/settings/SettingsStore', () => ({
27-
monitorSetting: jest.fn(),
28-
getValue: jest.fn(),
29-
setValue: jest.fn(),
30-
}));
31-
3226
// don't pollute test output with error logs from mock rejections
3327
jest.mock("matrix-js-sdk/src/logger");
3428

35-
jest.useRealTimers();
29+
// Avoid indirectly importing any eagerly created stores that would require extra setup
30+
jest.mock("../../../../src/Notifier");
3631

3732
const masterRule = {
3833
actions: ["dont_notify"],
@@ -81,21 +76,13 @@ describe('<Notifications />', () => {
8176
mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] });
8277
mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] });
8378
mockClient.setPusher.mockClear().mockResolvedValue({});
84-
85-
(SettingsStore.getValue as jest.Mock).mockClear().mockReturnValue(true);
86-
(SettingsStore.setValue as jest.Mock).mockClear().mockResolvedValue(true);
8779
});
8880

8981
it('renders spinner while loading', () => {
9082
const component = getComponent();
9183
expect(component.find('.mx_Spinner').length).toBeTruthy();
9284
});
9385

94-
it('renders error message when fetching push rules fails', async () => {
95-
mockClient.getPushRules.mockRejectedValue({});
96-
const component = await getComponentAndWait();
97-
expect(findByTestId(component, 'error-message').length).toBeTruthy();
98-
});
9986
it('renders error message when fetching push rules fails', async () => {
10087
mockClient.getPushRules.mockRejectedValue({});
10188
const component = await getComponentAndWait();
@@ -221,17 +208,24 @@ describe('<Notifications />', () => {
221208
});
222209
});
223210

224-
it('sets settings value on toggle click', async () => {
211+
it('toggles and sets settings correctly', async () => {
225212
const component = await getComponentAndWait();
213+
let audioNotifsToggle: ReactWrapper;
226214

227-
const audioNotifsToggle = findByTestId(component, 'notif-setting-audioNotificationsEnabled')
228-
.find('div[role="switch"]');
215+
const update = () => {
216+
audioNotifsToggle = findByTestId(component, 'notif-setting-audioNotificationsEnabled')
217+
.find('div[role="switch"]');
218+
};
219+
update();
229220

230-
await act(async () => {
231-
audioNotifsToggle.simulate('click');
232-
});
221+
expect(audioNotifsToggle.getDOMNode<HTMLElement>().getAttribute("aria-checked")).toEqual("true");
222+
expect(SettingsStore.getValue("audioNotificationsEnabled")).toEqual(true);
223+
224+
act(() => { audioNotifsToggle.simulate('click'); });
225+
update();
233226

234-
expect(SettingsStore.setValue).toHaveBeenCalledWith('audioNotificationsEnabled', null, "device", false);
227+
expect(audioNotifsToggle.getDOMNode<HTMLElement>().getAttribute("aria-checked")).toEqual("false");
228+
expect(SettingsStore.getValue("audioNotificationsEnabled")).toEqual(false);
235229
});
236230
});
237231

0 commit comments

Comments
 (0)