Create GranularSettingStore

GranularSettingStore is a class to manage settings of varying granularity, such as URL previews at the device, room, and account levels.

Signed-off-by: Travis Ralston <travpc@gmail.com>
This commit is contained in:
Travis Ralston 2017-10-22 20:15:20 -06:00
parent 580d8dce19
commit 8c3e5ebbad

426
src/GranularSettingStore.js Normal file
View file

@ -0,0 +1,426 @@
/*
Copyright 2017 Travis Ralston
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 Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg';
const SETTINGS = [
/*
// EXAMPLE SETTING
{
name: "my-setting",
type: "room", // or "account"
ignoreLevels: [], // options: "device", "room-account", "account", "room"
// "room-account" and "room" don't apply for `type: account`.
defaults: {
your: "defaults",
},
},
*/
// TODO: Populate this
];
// This controls the priority of particular handlers. Handler order should match the
// documentation throughout this file, as should the `types`. The priority is directly
// related to the index in the map, where index 0 is highest preference.
const PRIORITY_MAP = [
{level: 'device', settingClass: DeviceSetting, types: ['room', 'account']},
{level: 'room-account', settingClass: RoomAccountSetting, types: ['room']},
{level: 'account', settingClass: AccountSetting, types: ['room', 'account']},
{level: 'room', settingClass: RoomSetting, types: ['room']},
{level: 'default', settingClass: DefaultSetting, types: ['room', 'account']},
// TODO: Add support for 'legacy' settings (old events, etc)
// TODO: Labs handler? (or make UserSettingsStore use this as a backend)
];
/**
* Controls and manages Granular Settings through use of localStorage, account data,
* and room state. Granular Settings are user settings that can have overrides at
* particular levels, notably the device, account, and room level. With the topmost
* level being the preferred setting, the override procedure is:
* - localstorage (per-device)
* - room account data (per-account in room)
* - account data (per-account)
* - room state event (per-room)
* - default (defined by Riot)
*
* There are two types of settings: Account and Room.
*
* Account Settings use the same override procedure described above, but drop the room
* account data and room state event checks. Account Settings are best used for things
* like which theme the user would prefer.
*
* Room Settings use the exact override procedure described above. Room Settings are
* best suited for settings which room administrators may want to define a default
* for members of the room, such as the case is with URL previews. Room Settings may
* also elect to not allow the room state event check, allowing for per-room settings
* that are not defaulted by the room administrator.
*/
export default class GranularSettingStore {
/**
* Gets the content for an account setting.
* @param {string} name The name of the setting to lookup
* @returns {Promise<*>} Resolves to the content for the setting, or null if the
* value cannot be found.
*/
static getAccountSetting(name) {
const handlers = GranularSettingStore._getHandlers('account');
const initFn = settingClass => new settingClass('account', name);
return GranularSettingStore._iterateHandlers(handlers, initFn);
}
/**
* Gets the content for a room setting.
* @param {string} name The name of the setting to lookup
* @param {string} roomId The room ID to lookup the setting for
* @returns {Promise<*>} Resolves to the content for the setting, or null if the
* value cannot be found.
*/
static getRoomSetting(name, roomId) {
const handlers = GranularSettingStore._getHandlers('room');
const initFn = settingClass => new settingClass('room', name, roomId);
return GranularSettingStore._iterateHandlers(handlers, initFn);
}
static _iterateHandlers(handlers, initFn) {
let index = 0;
const wrapperFn = () => {
// If we hit the end with no result, return 'not found'
if (handlers.length >= index) return null;
// Get the handler, increment the index, and create a setting object
const handler = handlers[index++];
const setting = initFn(handler.settingClass);
// Try to read the value of the setting. If we get nothing for a value,
// then try the next handler. Otherwise, return the value early.
return Promise.resolve(setting.getValue()).then(value => {
if (!value) return wrapperFn();
return value;
});
};
return wrapperFn();
}
/**
* Sets the content for a particular account setting at a given level in the hierarchy.
* If the setting does not exist at the given level, this will attempt to create it. The
* default level may not be modified.
* @param {string} name The name of the setting.
* @param {string} level The level to set the value of. Either 'device' or 'account'.
* @param {Object} content The value for the setting, or null to clear the level's value.
* @returns {Promise} Resolves when completed
*/
static setAccountSetting(name, level, content) {
const handler = GranularSettingStore._getHandler('account', level);
if (!handler) throw new Error("Missing account setting handler for " + name + " at " + level);
const setting = new handler.settingClass('account', name);
return Promise.resolve(setting.setValue(content));
}
/**
* Sets the content for a particular room setting at a given level in the hierarchy. If
* the setting does not exist at the given level, this will attempt to create it. The
* default level may not be modified.
* @param {string} name The name of the setting.
* @param {string} level The level to set the value of. One of 'device', 'room-account',
* 'account', or 'room'.
* @param {string} roomId The room ID to set the value of.
* @param {Object} content The value for the setting, or null to clear the level's value.
* @returns {Promise} Resolves when completed
*/
static setRoomSetting(name, level, roomId, content) {
const handler = GranularSettingStore._getHandler('room', level);
if (!handler) throw new Error("Missing room setting handler for " + name + " at " + level);
const setting = new handler.settingClass('room', name, roomId);
return Promise.resolve(setting.setValue(content));
}
/**
* Checks to ensure the current user may set the given account setting.
* @param {string} name The name of the setting.
* @param {string} level The level to check at. Either 'device' or 'account'.
* @returns {boolean} Whether or not the current user may set the account setting value.
*/
static canSetAccountSetting(name, level) {
const handler = GranularSettingStore._getHandler('account', level);
if (!handler) return false;
const setting = new handler.settingClass('account', name);
return setting.canSetValue();
}
/**
* Checks to ensure the current user may set the given room setting.
* @param {string} name The name of the setting.
* @param {string} level The level to check at. One of 'device', 'room-account', 'account',
* or 'room'.
* @param {string} roomId The room ID to check in.
* @returns {boolean} Whether or not the current user may set the room setting value.
*/
static canSetRoomSetting(name, level, roomId) {
const handler = GranularSettingStore._getHandler('room', level);
if (!handler) return false;
const setting = new handler.settingClass('room', name, roomId);
return setting.canSetValue();
}
/**
* Removes an account setting at a given level, forcing the level to inherit from an
* earlier stage in the hierarchy.
* @param {string} name The name of the setting.
* @param {string} level The level to clear. Either 'device' or 'account'.
*/
static removeAccountSetting(name, level) {
// This is just a convenience method.
GranularSettingStore.setAccountSetting(name, level, null);
}
/**
* Removes a room setting at a given level, forcing the level to inherit from an earlier
* stage in the hierarchy.
* @param {string} name The name of the setting.
* @param {string} level The level to clear. One of 'device', 'room-account', 'account',
* or 'room'.
* @param {string} roomId The room ID to clear the setting on.
*/
static removeRoomSetting(name, level, roomId) {
// This is just a convenience method.
GranularSettingStore.setRoomSetting(name, level, roomId, null);
}
/**
* Determines whether or not a particular level is supported on the current platform.
* @param {string} level The level to check. One of 'device', 'room-account', 'account',
* 'room', or 'default'.
* @returns {boolean} Whether or not the level is supported.
*/
static isLevelSupported(level) {
return GranularSettingStore._getHandlersAtLevel(level).length > 0;
}
static _getHandlersAtLevel(level) {
return PRIORITY_MAP.filter(h => h.level === level && h.settingClass.isSupported());
}
static _getHandlers(type) {
return PRIORITY_MAP.filter(h => {
if (!h.types.includes(type)) return false;
if (!h.settingClass.isSupported()) return false;
return true;
});
}
static _getHandler(type, level) {
const handlers = GranularSettingStore._getHandlers(type);
return handlers.filter(h => h.level === level)[0];
}
}
// Validate of properties is assumed to be done well prior to instantiation of these classes,
// therefore these classes don't do any sanity checking. The following interface is assumed:
// constructor(type, name, roomId) - roomId may be null for type=='account'
// getValue() - returns a promise for the value. Falsey resolves are treated as 'not found'.
// setValue(content) - sets the new value for the setting. Falsey should remove the value.
// canSetValue() - returns true if the current user can set this setting.
// static isSupported() - returns true if the setting type is supported
class DefaultSetting {
constructor(type, name, roomId = null) {
this.type = type;
this.name = name;
this.roomId = roomId;
}
getValue() {
for (let setting of SETTINGS) {
if (setting.type === this.type && setting.name === this.name) {
return setting.defaults;
}
}
return null;
}
setValue() {
throw new Error("Operation not permitted: Cannot set value of a default setting.");
}
canSetValue() {
// It's a default, so no, you can't.
return false;
}
static isSupported() {
return true; // defaults are always accepted
}
}
class DeviceSetting {
constructor(type, name, roomId = null) {
this.type = type;
this.name = name;
this.roomId = roomId;
}
_getKey() {
return "mx_setting_" + this.name + "_" + this.type;
}
getValue() {
if (!localStorage) return null;
const value = localStorage.getItem(this._getKey());
if (!value) return null;
return JSON.parse(value);
}
setValue(content) {
if (!localStorage) throw new Error("Operation not possible: No device storage available.");
if (!content) localStorage.removeItem(this._getKey());
else localStorage.setItem(this._getKey(), JSON.stringify(content));
}
canSetValue() {
// The user likely has control over their own localstorage.
return true;
}
static isSupported() {
// We can only do something if we have localstorage
return !!localStorage;
}
}
class RoomAccountSetting {
constructor(type, name, roomId = null) {
this.type = type;
this.name = name;
this.roomId = roomId;
}
_getEventType() {
return "im.vector.setting." + this.type + "." + this.name;
}
getValue() {
if (!MatrixClientPeg.get()) return null;
const room = MatrixClientPeg.getRoom(this.roomId);
if (!room) return null;
const event = room.getAccountData(this._getEventType());
if (!event || !event.getContent()) return null;
return event.getContent();
}
setValue(content) {
if (!MatrixClientPeg.get()) throw new Error("Operation not possible: No client peg");
return MatrixClientPeg.get().setRoomAccountData(this.roomId, this._getEventType(), content);
}
canSetValue() {
// It's their own room account data, so they should be able to set it.
return true;
}
static isSupported() {
// We can only do something if we have a client
return !!MatrixClientPeg.get();
}
}
class AccountSetting {
constructor(type, name, roomId = null) {
this.type = type;
this.name = name;
this.roomId = roomId;
}
_getEventType() {
return "im.vector.setting." + this.type + "." + this.name;
}
getValue() {
if (!MatrixClientPeg.get()) return null;
return MatrixClientPeg.getAccountData(this._getEventType());
}
setValue(content) {
if (!MatrixClientPeg.get()) throw new Error("Operation not possible: No client peg");
return MatrixClientPeg.setAccountData(this._getEventType(), content);
}
canSetValue() {
// It's their own account data, so they should be able to set it
return true;
}
static isSupported() {
// We can only do something if we have a client
return !!MatrixClientPeg.get();
}
}
class RoomSetting {
constructor(type, name, roomId = null) {
this.type = type;
this.name = name;
this.roomId = roomId;
}
_getEventType() {
return "im.vector.setting." + this.type + "." + this.name;
}
getValue() {
if (!MatrixClientPeg.get()) return null;
const room = MatrixClientPeg.get().getRoom(this.roomId);
if (!room) return null;
const stateEvent = room.currentState.getStateEvents(this._getEventType(), "");
if (!stateEvent || !stateEvent.getContent()) return null;
return stateEvent.getContent();
}
setValue(content) {
if (!MatrixClientPeg.get()) throw new Error("Operation not possible: No client peg");
return MatrixClientPeg.get().sendStateEvent(this.roomId, this._getEventType(), content, "");
}
canSetValue() {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.roomId);
if (!room) return false; // They're not in the room, likely.
return room.currentState.maySendStateEvent(this._getEventType(), cli.getUserId());
}
static isSupported() {
// We can only do something if we have a client
return !!MatrixClientPeg.get();
}
}