diff --git a/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts b/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts new file mode 100644 index 0000000000..5d64009b6f --- /dev/null +++ b/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts @@ -0,0 +1,87 @@ +/* +Copyright 2019 - 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 SettingsHandler from "./SettingsHandler"; + +/** + * Abstract settings handler wrapping around localStorage making getValue calls cheaper + * by caching the values and listening for localStorage updates from other tabs. + */ +export default abstract class AbstractLocalStorageSettingsHandler extends SettingsHandler { + private itemCache = new Map(); + private objectCache = new Map(); + + protected constructor() { + super(); + + // Listen for storage changes from other tabs to bust the cache + window.addEventListener("storage", (e: StorageEvent) => { + if (e.key === null) { + this.itemCache.clear(); + this.objectCache.clear(); + } else { + this.itemCache.delete(e.key); + this.objectCache.delete(e.key); + } + }); + } + + protected getItem(key: string): any { + if (!this.itemCache.has(key)) { + const value = localStorage.getItem(key); + this.itemCache.set(key, value); + return value; + } + + return this.itemCache.get(key); + } + + protected getObject(key: string): T | null { + if (!this.objectCache.has(key)) { + try { + const value = JSON.parse(localStorage.getItem(key)); + this.objectCache.set(key, value); + return value; + } catch (err) { + console.error("Failed to parse localStorage object", err); + return null; + } + } + + return this.objectCache.get(key) as T; + } + + protected setItem(key: string, value: any): void { + this.itemCache.set(key, value); + localStorage.setItem(key, value); + } + + protected setObject(key: string, value: object): void { + this.objectCache.set(key, value); + localStorage.setItem(key, JSON.stringify(value)); + } + + // handles both items and objects + protected removeItem(key: string): void { + localStorage.removeItem(key); + this.itemCache.delete(key); + this.objectCache.delete(key); + } + + public isSupported(): boolean { + return localStorage !== undefined && localStorage !== null; + } +} diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index 7d2fbaf236..25c75c67a1 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -1,7 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd. -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 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. @@ -16,17 +16,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsHandler from "./SettingsHandler"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../SettingLevel"; import { CallbackFn, WatchManager } from "../WatchManager"; +import AbstractLocalStorageSettingsHandler from "./AbstractLocalStorageSettingsHandler"; /** * Gets and sets settings at the "device" level for the current device. * This handler does not make use of the roomId parameter. This handler * will special-case features to support legacy settings. */ -export default class DeviceSettingsHandler extends SettingsHandler { +export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsHandler { /** * Creates a new device settings handler * @param {string[]} featureNames The names of known features. @@ -43,15 +43,15 @@ export default class DeviceSettingsHandler extends SettingsHandler { // Special case notifications if (settingName === "notificationsEnabled") { - const value = localStorage.getItem("notifications_enabled"); + const value = this.getItem("notifications_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } else if (settingName === "notificationBodyEnabled") { - const value = localStorage.getItem("notifications_body_enabled"); + const value = this.getItem("notifications_body_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } else if (settingName === "audioNotificationsEnabled") { - const value = localStorage.getItem("audio_notifications_enabled"); + const value = this.getItem("audio_notifications_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } @@ -68,15 +68,15 @@ export default class DeviceSettingsHandler extends SettingsHandler { // Special case notifications if (settingName === "notificationsEnabled") { - localStorage.setItem("notifications_enabled", newValue); + this.setItem("notifications_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } else if (settingName === "notificationBodyEnabled") { - localStorage.setItem("notifications_body_enabled", newValue); + this.setItem("notifications_body_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } else if (settingName === "audioNotificationsEnabled") { - localStorage.setItem("audio_notifications_enabled", newValue); + this.setItem("audio_notifications_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } @@ -87,7 +87,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { delete settings["useIRCLayout"]; settings["layout"] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(settings)); + this.setObject("mx_local_settings", settings); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); @@ -95,7 +95,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { const settings = this.getSettings() || {}; settings[settingName] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(settings)); + this.setObject("mx_local_settings", settings); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); @@ -105,10 +105,6 @@ export default class DeviceSettingsHandler extends SettingsHandler { return true; // It's their device, so they should be able to } - public isSupported(): boolean { - return localStorage !== undefined && localStorage !== null; - } - public watchSetting(settingName: string, roomId: string, cb: CallbackFn) { this.watchers.watchSetting(settingName, roomId, cb); } @@ -118,9 +114,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { } private getSettings(): any { // TODO: [TS] Type return - const value = localStorage.getItem("mx_local_settings"); - if (!value) return null; - return JSON.parse(value); + return this.getObject("mx_local_settings"); } // Note: features intentionally don't use the same key as settings to avoid conflicts @@ -132,7 +126,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { return false; } - const value = localStorage.getItem("mx_labs_feature_" + featureName); + const value = this.getItem("mx_labs_feature_" + featureName); if (value === "true") return true; if (value === "false") return false; // Try to read the next config level for the feature. @@ -140,7 +134,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { } private writeFeature(featureName: string, enabled: boolean | null) { - localStorage.setItem("mx_labs_feature_" + featureName, `${enabled}`); + this.setItem("mx_labs_feature_" + featureName, `${enabled}`); this.watchers.notifyUpdate(featureName, null, SettingLevel.DEVICE, enabled); } } diff --git a/src/settings/handlers/RoomDeviceSettingsHandler.ts b/src/settings/handlers/RoomDeviceSettingsHandler.ts index 47fcecdfac..c1d1b57e9b 100644 --- a/src/settings/handlers/RoomDeviceSettingsHandler.ts +++ b/src/settings/handlers/RoomDeviceSettingsHandler.ts @@ -1,6 +1,6 @@ /* Copyright 2017 Travis Ralston -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 - 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. @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsHandler from "./SettingsHandler"; import { SettingLevel } from "../SettingLevel"; import { WatchManager } from "../WatchManager"; +import AbstractLocalStorageSettingsHandler from "./AbstractLocalStorageSettingsHandler"; /** * Gets and sets settings at the "room-device" level for the current device in a particular * room. */ -export default class RoomDeviceSettingsHandler extends SettingsHandler { +export default class RoomDeviceSettingsHandler extends AbstractLocalStorageSettingsHandler { constructor(public readonly watchers: WatchManager) { super(); } @@ -32,7 +32,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { // Special case blacklist setting to use legacy values if (settingName === "blacklistUnverifiedDevices") { const value = this.read("mx_local_settings"); - if (value && value['blacklistUnverifiedDevicesPerRoom']) { + if (value?.['blacklistUnverifiedDevicesPerRoom']) { return value['blacklistUnverifiedDevicesPerRoom'][roomId]; } } @@ -49,16 +49,15 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { if (!value) value = {}; if (!value["blacklistUnverifiedDevicesPerRoom"]) value["blacklistUnverifiedDevicesPerRoom"] = {}; value["blacklistUnverifiedDevicesPerRoom"][roomId] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(value)); + this.setObject("mx_local_settings", value); this.watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue); return Promise.resolve(); } if (newValue === null) { - localStorage.removeItem(this.getKey(settingName, roomId)); + this.removeItem(this.getKey(settingName, roomId)); } else { - newValue = JSON.stringify({ value: newValue }); - localStorage.setItem(this.getKey(settingName, roomId), newValue); + this.setObject(this.getKey(settingName, roomId), { value: newValue }); } this.watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue); @@ -69,14 +68,8 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { return true; // It's their device, so they should be able to } - public isSupported(): boolean { - return localStorage !== undefined && localStorage !== null; - } - private read(key: string): any { - const rawValue = localStorage.getItem(key); - if (!rawValue) return null; - return JSON.parse(rawValue); + return this.getItem(key); } private getKey(settingName: string, roomId: string): string {