Implement support for watching for changes in settings

This implements a dream of one day being able to listen for changes in a settings to react to them, regardless of which device actually changed the setting. The use case for this kind of thing is extremely limited, but when it is needed it should be more than powerful enough.
This commit is contained in:
Travis Ralston 2019-02-22 16:33:20 -07:00
parent 150c941340
commit 7ea4008daa
14 changed files with 484 additions and 7 deletions

View file

@ -30,6 +30,7 @@ import MatrixActionCreators from './actions/MatrixActionCreators';
import {phasedRollOutExpiredForUser} from "./PhasedRollOut";
import Modal from './Modal';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
interface MatrixClientCreds {
homeserverUrl: string,
@ -137,8 +138,9 @@ class MatrixClientPeg {
opts.pendingEventOrdering = "detached";
opts.lazyLoadMembers = true;
// Connect the matrix client to the dispatcher
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;
console.log(`MatrixClientPeg: really starting MatrixClient`);
await this.get().startClient(opts);

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -23,6 +24,7 @@ import RoomSettingsHandler from "./handlers/RoomSettingsHandler";
import ConfigSettingsHandler from "./handlers/ConfigSettingsHandler";
import {_t} from '../languageHandler';
import SdkConfig from "../SdkConfig";
import dis from '../dispatcher';
import {SETTINGS} from "./Settings";
import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
@ -98,6 +100,121 @@ const LEVEL_ORDER = [
* be enabled).
*/
export default class SettingsStore {
// We support watching settings for changes, and do so only at the levels which are
// relevant to the setting. We pass the watcher on to the handlers and aggregate it
// before sending it off to the caller. We need to track which callback functions we
// provide to the handlers though so we can unwatch it on demand. In practice, we
// return a "callback reference" to the caller which correlates to an entry in this
// dictionary for each handler's callback function.
//
// We also maintain a list of monitors which are special watchers: they cause dispatches
// when the setting changes. We track which rooms we're monitoring though to ensure we
// don't duplicate updates on the bus.
static _watchers = {}; // { callbackRef => { level => callbackFn } }
static _monitors = {}; // { settingName => { roomId => callbackRef } }
/**
* Watches for changes in a particular setting. This is done without any local echo
* wrapping and fires whenever a change is detected in a setting's value. Watching
* is intended to be used in scenarios where the app needs to react to changes made
* by other devices. It is otherwise expected that callers will be able to use the
* Controller system or track their own changes to settings. Callers should retain
* @param {string} settingName The setting name to watch
* @param {String} roomId The room ID to watch for changes in. May be null for 'all'.
* @param {function} callbackFn A function to be called when a setting change is
* detected. Four arguments can be expected: the setting name, the room ID (may be null),
* the level the change happened at, and finally the new value for those arguments. The
* callback may need to do a call to #getValue() to see if a consequential change has
* occurred.
* @returns {string} A reference to the watcher that was employed.
*/
static watchSetting(settingName, roomId, callbackFn) {
const setting = SETTINGS[settingName];
const originalSettingName = settingName;
if (!setting) throw new Error(`${settingName} is not a setting`);
if (setting.invertedSettingName) {
settingName = setting.invertedSettingName;
}
const watcherId = `${new Date().getTime()}_${settingName}_${roomId}`;
SettingsStore._watchers[watcherId] = {};
const levels = Object.keys(LEVEL_HANDLERS);
for (const level of levels) {
const handler = SettingsStore._getHandler(originalSettingName, level);
if (!handler) continue;
const localizedCallback = (changedInRoomId, newVal) => {
callbackFn(originalSettingName, changedInRoomId, level, newVal);
};
console.log(`Starting watcher for ${settingName}@${roomId || '<null room>'} at level ${level}`);
SettingsStore._watchers[watcherId][level] = localizedCallback;
handler.watchSetting(settingName, roomId, localizedCallback);
}
return watcherId;
}
/**
* Stops the SettingsStore from watching a setting. This is a no-op if the watcher
* provided is not found.
* @param {string} watcherReference The watcher reference (received from #watchSetting)
* to cancel.
*/
static unwatchSetting(watcherReference) {
if (!SettingsStore._watchers[watcherReference]) return;
for (const handlerName of Object.keys(SettingsStore._watchers[watcherReference])) {
const handler = LEVEL_HANDLERS[handlerName];
if (!handler) continue;
handler.unwatchSetting(SettingsStore._watchers[watcherReference][handlerName]);
}
delete SettingsStore._watchers[watcherReference];
}
/**
* Sets up a monitor for a setting. This behaves similar to #watchSetting except instead
* of making a call to a callback, it forwards all changes to the dispatcher. Callers can
* expect to listen for the 'setting_updated' action with an object containing settingName,
* roomId, level, and newValue.
* @param {string} settingName The setting name to monitor.
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
*/
static monitorSetting(settingName, roomId) {
if (!this._monitors[settingName]) this._monitors[settingName] = {};
const registerWatcher = () => {
this._monitors[settingName][roomId] = SettingsStore.watchSetting(
settingName, roomId, (settingName, inRoomId, level, newValue) => {
dis.dispatch({
action: 'setting_updated',
settingName,
roomId: inRoomId,
level,
newValue,
});
},
);
};
const hasRoom = Object.keys(this._monitors[settingName]).find((r) => r === roomId || r === null);
if (!hasRoom) {
registerWatcher();
} else {
if (roomId === null) {
// Unregister all existing watchers and register the new one
for (const roomId of Object.keys(this._monitors[settingName])) {
SettingsStore.unwatchSetting(this._monitors[settingName][roomId]);
}
this._monitors[settingName] = {};
registerWatcher();
} // else a watcher is already registered for the room, so don't bother registering it again
}
}
/**
* Gets the translated display name for a given setting
* @param {string} settingName The setting to look up.

View file

@ -0,0 +1,57 @@
/*
Copyright 2019 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.
*/
/**
* Generalized management class for dealing with watchers on a per-handler (per-level)
* basis without duplicating code. Handlers are expected to push updates through this
* class, which are then proxied outwards to any applicable watchers.
*/
export class WatchManager {
_watchers = {}; // { settingName: { roomId: callbackFns[] } }
// Proxy for handlers to delegate changes to this manager
watchSetting(settingName, roomId, cb) {
if (!this._watchers[settingName]) this._watchers[settingName] = {};
if (!this._watchers[settingName][roomId]) this._watchers[settingName][roomId] = [];
this._watchers[settingName][roomId].push(cb);
}
// Proxy for handlers to delegate changes to this manager
unwatchSetting(cb) {
for (const settingName of Object.keys(this._watchers)) {
for (const roomId of Object.keys(this._watchers[settingName])) {
let idx;
while ((idx = this._watchers[settingName][roomId].indexOf(cb)) !== -1) {
this._watchers[settingName][roomId].splice(idx, 1);
}
}
}
}
notifyUpdate(settingName, inRoomId, newValue) {
if (!this._watchers[settingName]) return;
const roomWatchers = this._watchers[settingName];
const callbacks = [];
if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]);
if (roomWatchers[null]) callbacks.push(...roomWatchers[null]);
for (const callback of callbacks) {
callback(inRoomId, newValue);
}
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -14,14 +15,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import {WatchManager} from "../WatchManager";
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
/**
* Gets and sets settings at the "account" level for the current user.
* This handler does not make use of the roomId parameter.
*/
export default class AccountSettingHandler extends SettingsHandler {
export default class AccountSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor() {
super();
this._watchers = new WatchManager();
this._onAccountData = this._onAccountData.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("accountData", this._onAccountData);
}
newClient.on("accountData", this._onAccountData);
}
_onAccountData(event) {
if (event.getType() === "org.matrix.preview_urls") {
let val = event.getContent()['disable'];
if (typeof(val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", null, val);
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
console.log(settingName);
this._watchers.notifyUpdate(settingName, null, event.getContent()[settingName]);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
@ -67,6 +103,14 @@ export default class AccountSettingHandler extends SettingsHandler {
return cli !== undefined && cli !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_getSettings(eventType = "im.vector.web.settings") {
const cli = MatrixClientPeg.get();
if (!cli) return null;

View file

@ -47,4 +47,12 @@ export default class ConfigSettingsHandler extends SettingsHandler {
isSupported() {
return true; // SdkConfig is always there
}
watchSetting(settingName, roomId, cb) {
// no-op: no changes possible
}
unwatchSetting(cb) {
// no-op: no changes possible
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -51,4 +52,12 @@ export default class DefaultSettingsHandler extends SettingsHandler {
isSupported() {
return true;
}
watchSetting(settingName, roomId, cb) {
// no-op: no changes possible
}
unwatchSetting(cb) {
// no-op: no changes possible
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -17,6 +18,7 @@ limitations under the License.
import Promise from 'bluebird';
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from "../../MatrixClientPeg";
import {WatchManager} from "../WatchManager";
/**
* Gets and sets settings at the "device" level for the current device.
@ -31,6 +33,7 @@ export default class DeviceSettingsHandler extends SettingsHandler {
constructor(featureNames) {
super();
this._featureNames = featureNames;
this._watchers = new WatchManager();
}
getValue(settingName, roomId) {
@ -66,18 +69,22 @@ export default class DeviceSettingsHandler extends SettingsHandler {
// Special case notifications
if (settingName === "notificationsEnabled") {
localStorage.setItem("notifications_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, newValue);
return Promise.resolve();
} else if (settingName === "notificationBodyEnabled") {
localStorage.setItem("notifications_body_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, newValue);
return Promise.resolve();
} else if (settingName === "audioNotificationsEnabled") {
localStorage.setItem("audio_notifications_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, newValue);
return Promise.resolve();
}
const settings = this._getSettings() || {};
settings[settingName] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(settings));
this._watchers.notifyUpdate(settingName, null, newValue);
return Promise.resolve();
}
@ -90,6 +97,14 @@ export default class DeviceSettingsHandler extends SettingsHandler {
return localStorage !== undefined && localStorage !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_getSettings() {
const value = localStorage.getItem("mx_local_settings");
if (!value) return null;
@ -111,5 +126,6 @@ export default class DeviceSettingsHandler extends SettingsHandler {
_writeFeature(featureName, enabled) {
localStorage.setItem("mx_labs_feature_" + featureName, enabled);
this._watchers.notifyUpdate(featureName, null, enabled);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -66,4 +67,12 @@ export default class LocalEchoWrapper extends SettingsHandler {
isSupported() {
return this._handler.isSupported();
}
watchSetting(settingName, roomId, cb) {
this._handler.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._handler.unwatchSetting(cb);
}
}

View file

@ -0,0 +1,48 @@
/*
Copyright 2019 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.
*/
import SettingsHandler from "./SettingsHandler";
// Dev note: This whole class exists in the event someone logs out and back in - we want
// to make sure the right MatrixClient is listening for changes.
/**
* Represents the base class for settings handlers which need access to a MatrixClient.
* This class performs no logic and should be overridden.
*/
export default class MatrixClientBackedSettingsHandler extends SettingsHandler {
static _matrixClient;
static _instances = [];
static set matrixClient(client) {
const oldClient = MatrixClientBackedSettingsHandler._matrixClient;
MatrixClientBackedSettingsHandler._matrixClient = client;
for (const instance of MatrixClientBackedSettingsHandler._instances) {
instance.initMatrixClient(oldClient, client);
}
}
constructor() {
super();
MatrixClientBackedSettingsHandler._instances.push(this);
}
initMatrixClient() {
console.warn("initMatrixClient not overridden");
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -14,13 +15,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {WatchManager} from "../WatchManager";
/**
* Gets and sets settings at the "room-account" level for the current user.
*/
export default class RoomAccountSettingsHandler extends SettingsHandler {
export default class RoomAccountSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor() {
super();
this._watchers = new WatchManager();
this._onAccountData = this._onAccountData.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("Room.accountData", this._onAccountData);
}
newClient.on("Room.accountData", this._onAccountData);
}
_onAccountData(event, room) {
const roomId = room.roomId;
if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable'];
if (typeof (val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, val);
} else if (event.getType() === "org.matrix.room.color_scheme") {
this._watchers.notifyUpdate("roomColor", roomId, event.getContent());
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
this._watchers.notifyUpdate(settingName, roomId, event.getContent()[settingName]);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
@ -74,6 +113,14 @@ export default class RoomAccountSettingsHandler extends SettingsHandler {
return cli !== undefined && cli !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_getSettings(roomId, eventType = "im.vector.web.settings") {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return null;

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -16,12 +17,19 @@ limitations under the License.
import Promise from 'bluebird';
import SettingsHandler from "./SettingsHandler";
import {WatchManager} from "../WatchManager";
/**
* Gets and sets settings at the "room-device" level for the current device in a particular
* room.
*/
export default class RoomDeviceSettingsHandler extends SettingsHandler {
constructor() {
super();
this._watchers = new WatchManager();
}
getValue(settingName, roomId) {
// Special case blacklist setting to use legacy values
if (settingName === "blacklistUnverifiedDevices") {
@ -44,6 +52,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler {
if (!value["blacklistUnverifiedDevicesPerRoom"]) value["blacklistUnverifiedDevicesPerRoom"] = {};
value["blacklistUnverifiedDevicesPerRoom"][roomId] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(value));
this._watchers.notifyUpdate(settingName, roomId, newValue);
return Promise.resolve();
}
@ -54,6 +63,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler {
localStorage.setItem(this._getKey(settingName, roomId), newValue);
}
this._watchers.notifyUpdate(settingName, roomId, newValue);
return Promise.resolve();
}
@ -65,6 +75,14 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler {
return localStorage !== undefined && localStorage !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_read(key) {
const rawValue = localStorage.getItem(key);
if (!rawValue) return null;

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -14,13 +15,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {WatchManager} from "../WatchManager";
/**
* Gets and sets settings at the "room" level.
*/
export default class RoomSettingsHandler extends SettingsHandler {
export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor() {
super();
this._watchers = new WatchManager();
this._onEvent = this._onEvent.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("RoomState.events", this._onEvent);
}
newClient.on("RoomState.events", this._onEvent);
}
_onEvent(event) {
const roomId = event.getRoomId();
if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable'];
if (typeof (val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, val);
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
this._watchers.notifyUpdate(settingName, roomId, event.getContent()[settingName]);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
@ -64,6 +101,14 @@ export default class RoomSettingsHandler extends SettingsHandler {
return cli !== undefined && cli !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_getSettings(roomId, eventType = "im.vector.web.settings") {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return null;

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 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.
@ -68,4 +69,27 @@ export default class SettingsHandler {
isSupported() {
return false;
}
/**
* Watches for a setting change within this handler. The caller should preserve
* a reference to the callback so that it may be unwatched. The caller should
* additionally provide a unique callback for multiple watchers on the same setting.
* @param {string} settingName The setting name to watch for changes in.
* @param {String} roomId The room ID to watch for changes in.
* @param {function} cb A function taking two arguments: the room ID the setting changed
* in and the new value for the setting at this level in the given room.
*/
watchSetting(settingName, roomId, cb) {
throw new Error("Invalid operation: watchSetting was not overridden");
}
/**
* Unwatches a previously watched setting. If the callback is not associated with
* a watcher, this is a no-op.
* @param {function} cb A callback function previously supplied to watchSetting
* which should no longer be used.
*/
unwatchSetting(cb) {
throw new Error("Invalid operation: unwatchSetting was not overridden");
}
}