Rework settings such that all behavior for each setting is kept together

Behavior includes: default value, whether setting should be synced, and
an optional 'apply' callback which is called when setting is changed in
Vuex.
This commit is contained in:
Tim Miller-Williams 2019-11-05 20:51:55 +00:00 committed by Pavel Djundik
parent 703848919c
commit 25da9dd63e
7 changed files with 236 additions and 241 deletions

View File

@ -463,7 +463,6 @@ export default {
},
data() {
return {
options: null,
canRegisterProtocol: false,
passwordChangeStatus: null,
passwordErrors: {
@ -476,8 +475,6 @@ export default {
};
},
mounted() {
this.options = require("../../js/options"); // TODO: do this in a smarter way
socket.emit("sessions:get");
// Enable protocol handler registration if supported
@ -507,7 +504,7 @@ export default {
value = event.target.value;
}
this.options.updateSetting(name, value, true);
this.$store.dispatch("settings/update", {name, value, sync: true});
},
changePassword() {
const allFields = new FormData(this.$refs.settingsForm);
@ -540,8 +537,7 @@ export default {
socket.emit("change-password", data);
},
onForceSyncClick() {
const options = require("../../js/options");
options.syncAllSettings(true);
this.$store.dispatch("settings/syncAll", true);
},
registerProtocol() {
const uri = document.location.origin + document.location.pathname + "?uri=%s";

View File

@ -1,178 +0,0 @@
"use strict";
const $ = require("jquery");
const storage = require("./localStorage");
const socket = require("./socket");
const store = require("./store").default;
require("../js/autocompletion");
const $theme = $("#theme");
const $userStyles = $("#user-specified-css");
const noCSSparamReg = /[?&]nocss/;
// Default settings
const settings = store.state.settings;
const noSync = ["syncSettings"];
// alwaysSync is reserved for settings that should be synced
// to the server regardless of the clients sync setting.
const alwaysSync = ["highlights"];
const defaultThemeColor = document.querySelector('meta[name="theme-color"]').content;
// Process usersettings from localstorage.
let userSettings = JSON.parse(storage.get("settings")) || false;
if (!userSettings) {
// Enable sync by default if there are no user defined settings.
store.commit("settings/syncSettings", true);
} else {
for (const key in settings) {
// Older The Lounge versions converted highlights to an array, turn it back into a string
if (key === "highlights" && typeof userSettings[key] === "object") {
userSettings[key] = userSettings[key].join(", ");
}
// Make sure the setting in local storage has the same type that the code expects
if (
typeof userSettings[key] !== "undefined" &&
typeof settings[key] === typeof userSettings[key]
) {
store.commit(`settings/${key}`, userSettings[key]);
}
}
}
// Apply custom CSS and themes on page load
// Done here and not on init because on slower devices and connections
// it can take up to several seconds before init is called.
if (typeof userSettings.userStyles === "string" && !noCSSparamReg.test(window.location.search)) {
$userStyles.html(userSettings.userStyles);
}
if (
typeof userSettings.theme === "string" &&
$theme.attr("href") !== `themes/${userSettings.theme}.css`
) {
$theme.attr("href", `themes/${userSettings.theme}.css`);
}
userSettings = null;
module.exports = {
alwaysSync,
noSync,
settings,
syncAllSettings,
processSetting,
initialize,
updateSetting,
};
// Updates the checkbox and warning in settings.
// When notifications are not supported, this is never called (because
// checkbox state can not be changed).
function updateDesktopNotificationStatus() {
if (Notification.permission === "granted") {
store.commit("desktopNotificationState", "granted");
} else {
store.commit("desktopNotificationState", "blocked");
}
}
function applySetting(name, value) {
if (name === "theme") {
const themeUrl = `themes/${value}.css`;
if ($theme.attr("href") !== themeUrl) {
$theme.attr("href", themeUrl);
const newTheme = store.state.serverConfiguration.themes.filter(
(theme) => theme.name === value
)[0];
let themeColor = defaultThemeColor;
if (newTheme.themeColor) {
themeColor = newTheme.themeColor;
}
document.querySelector('meta[name="theme-color"]').content = themeColor;
}
} else if (name === "userStyles" && !noCSSparamReg.test(window.location.search)) {
$userStyles.html(value);
} else if (name === "desktopNotifications") {
updateDesktopNotificationStatus();
if ("Notification" in window && value && Notification.permission !== "granted") {
Notification.requestPermission(updateDesktopNotificationStatus);
}
}
}
function settingSetEmit(name, value) {
socket.emit("setting:set", {name, value});
}
// When sync is `true` the setting will also be sent to the backend for syncing.
function updateSetting(name, value, sync) {
store.commit(`settings/${name}`, value);
storage.set("settings", JSON.stringify(settings));
applySetting(name, value);
// Sync is checked, request settings from server.
if (name === "syncSettings" && value) {
socket.emit("setting:get");
}
if (settings.syncSettings && !noSync.includes(name) && sync) {
settingSetEmit(name, value);
} else if (alwaysSync.includes(name) && sync) {
settingSetEmit(name, value);
}
}
function syncAllSettings(force = false) {
// Sync all settings if sync is enabled or force is true.
if (settings.syncSettings || force) {
for (const name in settings) {
if (!noSync.includes(name)) {
settingSetEmit(name, settings[name]);
} else if (alwaysSync.includes(name)) {
settingSetEmit(name, settings[name]);
}
}
}
}
// If `save` is set to true it will pass the setting to `updateSetting()` processSetting
function processSetting(name, value, save) {
// No need to also call processSetting when `save` is true.
// updateSetting does take care of that.
if (save) {
// Sync is false as applySetting is never called as the result of a user changing the setting.
updateSetting(name, value, false);
} else {
applySetting(name, value);
}
}
function initialize() {
// Settings have now entirely updated, apply settings to the client.
for (const name in settings) {
processSetting(name, settings[name], false);
}
// If browser does not support notifications
// display proper message in settings.
if ("Notification" in window) {
updateDesktopNotificationStatus();
} else {
store.commit("desktopNotificationState", "unsupported");
}
// Local init is done, let's sync
// We always ask for synced settings even if it is disabled.
// Settings can be mandatory to sync and it is used to determine sync base state.
socket.emit("setting:get");
}

113
client/js/settings.js Normal file
View File

@ -0,0 +1,113 @@
const socket = require("./socket");
const defaultSettingConfig = {
apply() {},
default: null,
sync: null,
};
export const config = normalizeConfig({
syncSettings: {
default: true,
sync: "never",
apply(store, value) {
value && socket.emit("setting:get");
},
},
advanced: {
default: false,
},
autocomplete: {
default: true,
},
nickPostfix: {
default: "",
},
coloredNicks: {
default: true,
},
desktopNotifications: {
default: false,
apply(store, value) {
store.commit("refreshDesktopNotificationState", null, {root: true});
if ("Notification" in window && value && Notification.permission !== "granted") {
Notification.requestPermission(() =>
store.commit("refreshDesktopNotificationState", null, {root: true})
);
}
},
},
highlights: {
default: "",
sync: "always",
},
links: {
default: true,
},
motd: {
default: true,
},
notification: {
default: true,
},
notifyAllMessages: {
default: false,
},
showSeconds: {
default: false,
},
statusMessages: {
default: "condensed",
},
theme: {
default: document.getElementById("theme").dataset.serverTheme,
apply(store, value) {
const themeEl = document.getElementById("theme");
const themeUrl = `themes/${value}.css`;
if (themeEl.attributes.href.value === themeUrl) {
return;
}
themeEl.attributes.href.value = themeUrl;
const newTheme = store.state.serverConfiguration.themes.filter(
(theme) => theme.name === value
)[0];
const themeColor =
newTheme.themeColor || document.querySelector('meta[name="theme-color"]').content;
document.querySelector('meta[name="theme-color"]').content = themeColor;
},
},
media: {
default: true,
},
userStyles: {
default: "",
apply(store, value) {
if (!/[?&]nocss/.test(window.location.search)) {
document.getElementById("user-specified-css").innerHTML = value;
}
},
},
});
export function createState() {
const state = {};
for (const settingName in config) {
state[settingName] = config[settingName].default;
}
return state;
}
function normalizeConfig(obj) {
const newConfig = {};
for (const settingName in obj) {
newConfig[settingName] = {...defaultSettingConfig, ...obj[settingName]};
}
return newConfig;
}

View File

@ -2,7 +2,6 @@
const $ = require("jquery");
const socket = require("../socket");
const options = require("../options");
const webpush = require("../webpush");
const upload = require("../upload");
const store = require("../store").default;
@ -25,11 +24,15 @@ socket.once("configuration", function(data) {
store.commit("isFileUploadEnabled", data.fileUpload);
store.commit("serverConfiguration", data);
// 'theme' setting depends on serverConfiguration.themes so
// settings cannot be applied before this point
store.dispatch("settings/applyAll");
if (data.fileUpload) {
upload.initialize(data.fileUploadMaxFileSize);
}
options.initialize();
socket.emit("setting:get");
webpush.initialize();
// If localStorage contains a theme that does not exist on this server, switch
@ -37,7 +40,7 @@ socket.once("configuration", function(data) {
const currentTheme = data.themes.find((t) => t.name === store.state.settings.theme);
if (currentTheme === undefined) {
options.processSetting("theme", data.defaultTheme, true);
store.commit("settings/update", {name: "theme", value: data.defaultTheme, sync: true});
} else if (currentTheme.themeColor) {
document.querySelector('meta[name="theme-color"]').content = currentTheme.themeColor;
}

View File

@ -1,33 +1,20 @@
"use strict";
const socket = require("../socket");
const options = require("../options");
const store = require("../store").default;
function evaluateSetting(name, value) {
if (
store.state.settings.syncSettings &&
store.state.settings[name] !== value &&
!options.noSync.includes(name)
) {
options.processSetting(name, value, true);
} else if (options.alwaysSync.includes(name)) {
options.processSetting(name, value, true);
}
}
socket.on("setting:new", function(data) {
const name = data.name;
const value = data.value;
evaluateSetting(name, value);
store.dispatch("settings/update", {name, value, sync: false});
});
socket.on("setting:all", function(settings) {
if (Object.keys(settings).length === 0) {
options.syncAllSettings();
store.dispatch("settings/syncAll");
} else {
for (const name in settings) {
evaluateSetting(name, settings[name]);
store.dispatch("settings/update", {name, value: settings[name], sync: false});
}
}
});

View File

@ -1,37 +1,100 @@
function createMutator(propertyName) {
return [
propertyName,
(state, value) => {
state[propertyName] = value;
},
];
}
const storage = require("./localStorage");
const socket = require("./socket");
import {config, createState} from "./settings";
function createMutators(keys) {
return Object.fromEntries(keys.map(createMutator));
}
const state = {
syncSettings: false,
advanced: false,
autocomplete: true,
nickPostfix: "",
coloredNicks: true,
desktopNotifications: false,
highlights: "",
links: true,
motd: true,
notification: true,
notifyAllMessages: false,
showSeconds: false,
statusMessages: "condensed",
theme: document.getElementById("theme").dataset.serverTheme,
media: true,
userStyles: "",
};
export default {
export function createSettingsStore(store) {
return {
namespaced: true,
state,
mutations: createMutators(Object.keys(state)),
};
state: assignStoredSettings(createState(), loadFromLocalStorage()),
mutations: {
set(state, {name, value}) {
state[name] = value;
},
},
actions: {
syncAll({state}, force = false) {
if (state.syncSettings === false || force === false) {
return;
}
for (const name in state) {
if (config[name].sync !== "never" || config[name].sync === "always") {
socket.emit("setting:set", {name, value: state[name]});
}
}
},
applyAll({state}) {
for (const settingName in config) {
config[settingName].apply(store, state[settingName]);
}
},
update({state, commit}, {name, value, sync = false}) {
if (state[name] === value) {
return;
}
const settingConfig = config[name];
if (
sync === false &&
(state.syncSettings === false || settingConfig.sync === "never")
) {
return;
}
commit("set", {name, value});
storage.set("settings", JSON.stringify(state));
settingConfig.apply(store, value);
if (!sync) {
return;
}
if (
(state.syncSettings && settingConfig.sync !== "never") ||
settingConfig.sync === "always"
) {
socket.emit("setting:set", {name, value});
}
},
},
};
}
function loadFromLocalStorage() {
const storedSettings = JSON.parse(storage.get("settings")) || false;
if (!storedSettings) {
return {};
}
// Older The Lounge versions converted highlights to an array, turn it back into a string
if (typeof storedSettings.highlights === "object") {
storedSettings.highlights = storedSettings.highlights.join(", ");
}
return storedSettings;
}
/**
* Essentially Object.assign but does not overwrite and only assigns
* if key exists in both supplied objects and types match
*
* @param {object} defaultSettings
* @param {object} storedSettings
*/
function assignStoredSettings(defaultSettings, storedSettings) {
const newSettings = {...defaultSettings};
for (const key in defaultSettings) {
// Make sure the setting in local storage has the same type that the code expects
if (
typeof storedSettings[key] !== "undefined" &&
typeof defaultSettings[key] === typeof storedSettings[key]
) {
newSettings[key] = storedSettings[key];
}
}
return newSettings;
}

View File

@ -1,20 +1,27 @@
import Vue from "vue";
import Vuex from "vuex";
import settings from "./store-settings";
import {createSettingsStore} from "./store-settings";
const storage = require("./localStorage");
Vue.use(Vuex);
function detectDesktopNotificationState() {
if (!("Notification" in window)) {
return "unsupported";
} else if (Notification.permission === "granted") {
return "granted";
}
return "blocked";
}
const store = new Vuex.Store({
modules: {
settings,
},
state: {
appLoaded: false,
activeChannel: null,
currentUserVisibleError: null,
desktopNotificationState: "unsupported",
desktopNotificationState: detectDesktopNotificationState(),
isAutoCompleting: false,
isConnected: false,
isFileUploadEnabled: false,
@ -41,8 +48,8 @@ const store = new Vuex.Store({
currentUserVisibleError(state, error) {
state.currentUserVisibleError = error;
},
desktopNotificationState(state, desktopNotificationState) {
state.desktopNotificationState = desktopNotificationState;
refreshDesktopNotificationState(state) {
state.desktopNotificationState = detectDesktopNotificationState();
},
isAutoCompleting(state, isAutoCompleting) {
state.isAutoCompleting = isAutoCompleting;
@ -129,4 +136,8 @@ const store = new Vuex.Store({
},
});
// Settings module is registered dynamically because it benefits
// from a direct reference to the store
store.registerModule("settings", createSettingsStore(store));
export default store;