From 25da9dd63eb48adce2a45b82d36ad44223057d10 Mon Sep 17 00:00:00 2001 From: Tim Miller-Williams Date: Tue, 5 Nov 2019 20:51:55 +0000 Subject: [PATCH] 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. --- client/components/Windows/Settings.vue | 8 +- client/js/options.js | 178 ----------------------- client/js/settings.js | 113 ++++++++++++++ client/js/socket-events/configuration.js | 9 +- client/js/socket-events/setting.js | 19 +-- client/js/store-settings.js | 125 ++++++++++++---- client/js/store.js | 25 +++- 7 files changed, 236 insertions(+), 241 deletions(-) delete mode 100644 client/js/options.js create mode 100644 client/js/settings.js diff --git a/client/components/Windows/Settings.vue b/client/components/Windows/Settings.vue index d324f1ef..603c0537 100644 --- a/client/components/Windows/Settings.vue +++ b/client/components/Windows/Settings.vue @@ -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"; diff --git a/client/js/options.js b/client/js/options.js deleted file mode 100644 index fcd6e177..00000000 --- a/client/js/options.js +++ /dev/null @@ -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"); -} diff --git a/client/js/settings.js b/client/js/settings.js new file mode 100644 index 00000000..028c309d --- /dev/null +++ b/client/js/settings.js @@ -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; +} diff --git a/client/js/socket-events/configuration.js b/client/js/socket-events/configuration.js index c83db2d9..65ceabd8 100644 --- a/client/js/socket-events/configuration.js +++ b/client/js/socket-events/configuration.js @@ -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; } diff --git a/client/js/socket-events/setting.js b/client/js/socket-events/setting.js index ce916c85..cb5b3f16 100644 --- a/client/js/socket-events/setting.js +++ b/client/js/socket-events/setting.js @@ -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}); } } }); diff --git a/client/js/store-settings.js b/client/js/store-settings.js index 5dc24871..b0bb0120 100644 --- a/client/js/store-settings.js +++ b/client/js/store-settings.js @@ -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"; + +export function createSettingsStore(store) { + return { + namespaced: true, + 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 createMutators(keys) { - return Object.fromEntries(keys.map(createMutator)); +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; } -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: "", -}; +/** + * 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}; -export default { - namespaced: true, - state, - mutations: createMutators(Object.keys(state)), -}; + 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; +} diff --git a/client/js/store.js b/client/js/store.js index b1161b2e..cfecd468 100644 --- a/client/js/store.js +++ b/client/js/store.js @@ -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;