diff --git a/client/css/style.css b/client/css/style.css index 68ce4c4c..1abe8bd5 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -223,6 +223,7 @@ kbd { #chat .title::before, #footer .icon, #chat .count::before, +#settings .extra-experimental, #settings .extra-help, #settings #play::before, #form #submit::before, @@ -401,6 +402,10 @@ kbd { line-height: 50px; } +#settings .extra-experimental::before { + content: "\f0c3"; /* https://fontawesome.com/icons/flask?style=solid */ +} + #settings .extra-help::before { content: "\f059"; /* http://fontawesome.io/icon/question-circle/ */ } @@ -1565,6 +1570,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ margin-top: 30px; } +#settings .sync-warning-base { + display: none; +} + #settings .opt { display: block; padding: 5px 0 5px 1px; @@ -1574,15 +1583,21 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ margin-right: 6px; } +#settings .extra-experimental { + color: #84ce88; +} + #settings .extra-help, #settings #play { color: #7f8c8d; } +#settings .extra-experimental, #settings .extra-help { cursor: help; } +#settings .extra-experimental, #settings h2 .extra-help { font-size: 0.8em; } diff --git a/client/js/autocompletion.js b/client/js/autocompletion.js index ec8e1d7f..2ed04f48 100644 --- a/client/js/autocompletion.js +++ b/client/js/autocompletion.js @@ -10,16 +10,16 @@ const constants = require("./constants"); const input = $("#input"); let textcomplete; +let enabled = false; module.exports = { enable: enableAutocomplete, disable: () => { - input.off("input.tabcomplete"); - Mousetrap(input.get(0)).off("tab", "keydown"); - - if (textcomplete) { + if (enabled) { + input.off("input.tabcomplete"); + Mousetrap(input.get(0)).off("tab", "keydown"); textcomplete.destroy(); - textcomplete = null; + enabled = false; } }, }; @@ -63,7 +63,7 @@ const nicksStrategy = { }, replace([, original], position = 1) { // If no postfix specified, return autocompleted nick as-is - if (!options.nickPostfix) { + if (!options.settings.nickPostfix) { return original; } @@ -73,7 +73,7 @@ const nicksStrategy = { } // If nick is first in the input, append specified postfix - return original + options.nickPostfix; + return original + options.settings.nickPostfix; }, index: 1, }; @@ -169,6 +169,7 @@ const backgroundColorStrategy = { }; function enableAutocomplete() { + enabled = true; let tabCount = 0; let lastMatch = ""; let currentMatches = []; diff --git a/client/js/libs/handlebars/tz.js b/client/js/libs/handlebars/tz.js index 2e807d52..8ec5b47e 100644 --- a/client/js/libs/handlebars/tz.js +++ b/client/js/libs/handlebars/tz.js @@ -5,6 +5,6 @@ const constants = require("../../constants"); module.exports = function(time) { const options = require("../../options"); - const format = options.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault; + const format = options.settings.showSeconds ? constants.timeFormats.msgWithSeconds : constants.timeFormats.msgDefault; return moment(time).format(format); }; diff --git a/client/js/options.js b/client/js/options.js index 39aaa2a8..64f2a43f 100644 --- a/client/js/options.js +++ b/client/js/options.js @@ -2,15 +2,28 @@ const $ = require("jquery"); const escapeRegExp = require("lodash/escapeRegExp"); -const userStyles = $("#user-specified-css"); const storage = require("./localStorage"); const tz = require("./libs/handlebars/tz"); +const socket = require("./socket"); -const windows = $("#windows"); -const chat = $("#chat"); +const $windows = $("#windows"); +const $chat = $("#chat"); +const $settings = $("#settings"); +const $theme = $("#theme"); +const $userStyles = $("#user-specified-css"); -// Default options -const options = { +const noCSSparamReg = /[?&]nocss/; + +// Not yet available at this point but used in various functionaly. +// Will be assigned when `initialize` is called. +let $syncWarningOverride; +let $syncWarningBase; +let $warningUnsupported; +let $warningBlocked; + +// Default settings +const settings = { + syncSettings: false, autocomplete: true, nickPostfix: "", coloredNicks: true, @@ -24,155 +37,267 @@ const options = { statusMessages: "condensed", theme: $("#theme").data("server-theme"), media: true, - userStyles: userStyles.text(), + userStyles: "", }; -let userOptions = JSON.parse(storage.get("settings")) || {}; -for (const key in options) { - if (userOptions[key] !== undefined) { - options[key] = userOptions[key]; +const noSync = ["syncSettings"]; + +// alwaysSync is reserved for things like "highlights". +// TODO: figure out how to deal with legacy clients that have different settings. +const alwaysSync = []; + +// Process usersettings from localstorage. +let userSettings = JSON.parse(storage.get("settings")) || {}; + +for (const key in settings) { + if (userSettings[key] !== undefined) { + settings[key] = userSettings[key]; } } -// Apply custom CSS on page load -if (typeof userOptions.userStyles === "string" && !/[?&]nocss/.test(window.location.search)) { - userStyles.html(userOptions.userStyles); +// 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); } -userOptions = null; +if (typeof userSettings.theme === "string") { + $theme.prop("href", `themes/${userSettings.theme}.css`); +} -module.exports = options; +userSettings = null; + +module.exports = { + alwaysSync: alwaysSync, + noSync: noSync, + initialized: false, + highlightsRE: null, + settings: settings, + shouldOpenMessagePreview, + noServerSettings, + processSetting, + initialize, +}; // Due to cyclical dependency, have to require it after exports const autocompletion = require("./autocompletion"); -module.exports.shouldOpenMessagePreview = function(type) { - return type === "link" ? options.links : options.media; -}; +function shouldOpenMessagePreview(type) { + return type === "link" ? settings.links : settings.media; +} -module.exports.initialize = () => { - module.exports.initialize = null; +// 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 === "denied") { + $warningBlocked.show(); + } else { + $warningBlocked.hide(); + } +} - const settings = $("#settings"); +function applySetting(name, value) { + if (name === "syncSettings" && value) { + $syncWarningOverride.hide(); + } else if (name === "motd") { + $chat.toggleClass("hide-" + name, !value); + } else if (name === "statusMessages") { + $chat.toggleClass("hide-status-messages", value === "hidden"); + $chat.toggleClass("condensed-status-messages", value === "condensed"); + } else if (name === "coloredNicks") { + $chat.toggleClass("colored-nicks", value); + } else if (name === "theme") { + $theme.prop("href", `themes/${value}.css`); + } else if (name === "userStyles" && !noCSSparamReg.test(window.location.search)) { + $userStyles.html(value); + } else if (name === "highlights") { + let highlights; - for (const i in options) { - if (i === "userStyles") { - settings.find("#user-specified-css-input").val(options[i]); - } else if (i === "highlights") { - settings.find("input[name=" + i + "]").val(options[i]); - } else if (i === "nickPostfix") { - settings.find("input[name=" + i + "]").val(options[i]); - } else if (i === "statusMessages") { - settings.find(`input[name=${i}][value=${options[i]}]`) - .prop("checked", true); - } else if (i === "theme") { - $("#theme").prop("href", "themes/" + options[i] + ".css"); - settings.find("select[name=" + i + "]").val(options[i]); - } else if (options[i]) { - settings.find("input[name=" + i + "]").prop("checked", true); + if (typeof value === "string") { + highlights = value.split(",").map(function(h) { + return h.trim(); + }); + } else { + highlights = value; + } + + highlights = highlights.filter(function(h) { + // Ensure we don't have empty string in the list of highlights + // otherwise, users get notifications for everything + return h !== ""; + }); + // Construct regex with wordboundary for every highlight item + const highlightsTokens = highlights.map(function(h) { + return escapeRegExp(h); + }); + + if (highlightsTokens && highlightsTokens.length) { + module.exports.highlightsRE = new RegExp("\\b(?:" + highlightsTokens.join("|") + ")\\b", "i"); + } else { + module.exports.highlightsRE = null; + } + } else if (name === "showSeconds") { + $chat.find(".msg > .time").each(function() { + $(this).text(tz($(this).parent().data("time"))); + }); + $chat.toggleClass("show-seconds", value); + } else if (name === "autocomplete") { + if (value) { + autocompletion.enable(); + } else { + autocompletion.disable(); + } + } else if (name === "desktopNotifications") { + if (value && Notification.permission !== "granted") { + Notification.requestPermission(updateDesktopNotificationStatus); + } else if (!value) { + $warningBlocked.hide(); } } +} - const desktopNotificationsCheckbox = $("#desktopNotifications"); - const warningUnsupported = $("#warnUnsupportedDesktopNotifications"); - const warningBlocked = $("#warnBlockedDesktopNotifications").hide(); +function settingSetEmit(name, value) { + socket.emit("setting:set", { + name: name, + value: value, + }); +} - // Updates the checkbox and warning in settings when the Settings page is - // opened or when the checkbox state is changed. - // When notifications are not supported, this is never called (because - // checkbox state can not be changed). - const updateDesktopNotificationStatus = function() { - if (Notification.permission === "denied") { - desktopNotificationsCheckbox.prop("disabled", true); - desktopNotificationsCheckbox.prop("checked", false); - warningBlocked.show(); - } else { - if (Notification.permission === "default" && desktopNotificationsCheckbox.prop("checked")) { - desktopNotificationsCheckbox.prop("checked", false); - } +// When sync is `true` the setting will also be send to the backend for syncing. +function updateSetting(name, value, sync) { + let storeValue = value; - desktopNotificationsCheckbox.prop("disabled", false); - warningBlocked.hide(); + // First convert highlights if input is a string. + // Otherwise we are comparing the wrong types. + if (name === "highlights" && typeof value === "string") { + storeValue = value.split(",").map(function(h) { + return h.trim(); + }).filter(function(h) { + // Ensure we don't have empty string in the list of highlights + // otherwise, users get notifications for everything + return h !== ""; + }); + } + + const currentOption = settings[name]; + + // Only update and process when the setting is actually changed. + if (currentOption !== storeValue) { + settings[name] = storeValue; + storage.set("settings", JSON.stringify(settings)); + applySetting(name, value); + + // Sync is checked, request settings from server. + if (name === "syncSettings" && value) { + socket.emit("setting:get"); + $syncWarningOverride.hide(); + $syncWarningBase.hide(); + } else if (name === "syncSettings") { + $syncWarningOverride.show(); } - }; - // If browser does not support notifications, override existing settings and + if (settings.syncSettings && !noSync.includes(name) && sync) { + settingSetEmit(name, value); + } else if (alwaysSync.includes(name) && sync) { + settingSetEmit(name, value); + } + } +} + +function noServerSettings() { + // Sync is enabled but the server has no settings so we sync all settings from this client. + if (settings.syncSettings) { + for (const name in settings) { + if (!noSync.includes(name)) { + settingSetEmit(name, settings[name]); + } else if (alwaysSync.includes(name)) { + settingSetEmit(name, settings[name]); + } + } + + $syncWarningOverride.hide(); + $syncWarningBase.hide(); + } else { + $syncWarningOverride.hide(); + $syncWarningBase.show(); + } +} + +// If `save` is set to true it will pass the setting to `updateSetting()` processSetting +function processSetting(name, value, save) { + if (name === "userStyles") { + $settings.find("#user-specified-css-input").val(value); + } else if (name === "highlights") { + $settings.find(`input[name=${name}]`).val(value); + } else if (name === "nickPostfix") { + $settings.find(`input[name=${name}]`).val(value); + } else if (name === "statusMessages") { + $settings.find(`input[name=${name}][value=${value}]`) + .prop("checked", true); + } else if (name === "theme") { + $settings.find("#theme-select").val(value); + } else if (typeof value === "boolean") { + $settings.find(`input[name=${name}]`).prop("checked", value); + } + + // 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() { + $warningBlocked = $settings.find("#warnBlockedDesktopNotifications"); + $warningUnsupported = $settings.find("#warnUnsupportedDesktopNotifications"); + + $syncWarningOverride = $settings.find(".sync-warning-override"); + $syncWarningBase = $settings.find(".sync-warning-base"); + + $warningBlocked.hide(); + module.exports.initialized = true; + + // 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)) { - warningUnsupported.hide(); - windows.on("show", "#settings", updateDesktopNotificationStatus); + $warningUnsupported.hide(); + $windows.on("show", "#settings", updateDesktopNotificationStatus); } else { - options.desktopNotifications = false; - desktopNotificationsCheckbox.prop("disabled", true); - desktopNotificationsCheckbox.prop("checked", false); + $warningUnsupported.show(); } - settings.on("change", "input, select, textarea", function() { - const self = $(this); - const type = self.prop("type"); - const name = self.prop("name"); + $settings.on("change", "input, select, textarea", function(e) { + // We only want to trigger on human triggerd changes. + if (e.originalEvent) { + const $self = $(this); + const type = $self.prop("type"); + const name = $self.prop("name"); - if (type === "password") { - return; - } else if (type === "radio") { - if (self.prop("checked")) { - options[name] = self.val(); - } - } else if (type === "checkbox") { - options[name] = self.prop("checked"); - } else { - options[name] = self.val(); - } - - storage.set("settings", JSON.stringify(options)); - - if (name === "motd") { - chat.toggleClass("hide-" + name, !self.prop("checked")); - } else if (name === "statusMessages") { - chat.toggleClass("hide-status-messages", options[name] === "hidden"); - chat.toggleClass("condensed-status-messages", options[name] === "condensed"); - } else if (name === "coloredNicks") { - chat.toggleClass("colored-nicks", self.prop("checked")); - } else if (name === "theme") { - $("#theme").prop("href", "themes/" + options[name] + ".css"); - } else if (name === "userStyles") { - userStyles.html(options[name]); - } else if (name === "highlights") { - options.highlights = options[name].split(",").map(function(h) { - return h.trim(); - }).filter(function(h) { - // Ensure we don't have empty string in the list of highlights - // otherwise, users get notifications for everything - return h !== ""; - }); - // Construct regex with wordboundary for every highlight item - const highlightsTokens = options.highlights.map(function(h) { - return escapeRegExp(h); - }); - - if (highlightsTokens && highlightsTokens.length) { - module.exports.highlightsRE = new RegExp("\\b(?:" + highlightsTokens.join("|") + ")\\b", "i"); - } else { - module.exports.highlightsRE = null; - } - } else if (name === "nickPostfix") { - options.nickPostfix = options[name]; - } else if (name === "showSeconds") { - chat.find(".msg > .time").each(function() { - $(this).text(tz($(this).parent().data("time"))); - }); - chat.toggleClass("show-seconds", self.prop("checked")); - } else if (name === "autocomplete") { - if (self.prop("checked")) { - autocompletion.enable(); - } else { - autocompletion.disable(); - } - } else if (name === "desktopNotifications") { - if ($(this).prop("checked") && Notification.permission !== "granted") { - Notification.requestPermission(updateDesktopNotificationStatus); + if (type === "radio") { + if ($self.prop("checked")) { + updateSetting(name, $self.val(), true); + } + } else if (type === "checkbox") { + updateSetting(name, $self.prop("checked"), true); + settings[name] = $self.prop("checked"); + } else if (type !== "password") { + updateSetting(name, $self.val(), true); } } - }).find("input") - .trigger("change"); -}; + }); + + // 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("settings:get"); +} diff --git a/client/js/socket-events/configuration.js b/client/js/socket-events/configuration.js index 17352969..2a6fec17 100644 --- a/client/js/socket-events/configuration.js +++ b/client/js/socket-events/configuration.js @@ -7,7 +7,7 @@ const options = require("../options"); const webpush = require("../webpush"); socket.on("configuration", function(data) { - if (!options.initialize) { + if (options.initialized) { return; } diff --git a/client/js/socket-events/index.js b/client/js/socket-events/index.js index 0841459e..951eb0dc 100644 --- a/client/js/socket-events/index.js +++ b/client/js/socket-events/index.js @@ -20,3 +20,4 @@ require("./sign_out"); require("./sessions_list"); require("./configuration"); require("./changelog"); +require("./setting"); diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js index 71967b33..6335d686 100644 --- a/client/js/socket-events/msg.js +++ b/client/js/socket-events/msg.js @@ -124,9 +124,9 @@ function notifyMessage(targetId, channel, msg) { const button = sidebar.find(".chan[data-id='" + targetId + "']"); - if (msg.highlight || (options.notifyAllMessages && msg.type === "message")) { + if (msg.highlight || (options.settings.notifyAllMessages && msg.type === "message")) { if (!document.hasFocus() || !channel.hasClass("active")) { - if (options.notification) { + if (options.settings.notification) { try { pop.play(); } catch (exception) { @@ -136,7 +136,7 @@ function notifyMessage(targetId, channel, msg) { utils.toggleNotificationMarkers(true); - if (options.desktopNotifications && Notification.permission === "granted") { + if (options.settings.desktopNotifications && ("Notification" in window) && Notification.permission === "granted") { let title; let body; diff --git a/client/js/socket-events/setting.js b/client/js/socket-events/setting.js new file mode 100644 index 00000000..f8a2eb17 --- /dev/null +++ b/client/js/socket-events/setting.js @@ -0,0 +1,28 @@ +"use strict"; + +const socket = require("../socket"); +const options = require("../options"); + +function evaluateSetting(name, value) { + if (options.settings.syncSettings && options.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); +}); + +socket.on("setting:all", function(settings) { + if (Object.keys(settings).length === 0) { + options.noServerSettings(); + } else { + for (const name in settings) { + evaluateSetting(name, settings[name]); + } + } +}); diff --git a/client/js/sorting.js b/client/js/sorting.js index d997b6ed..54f4d457 100644 --- a/client/js/sorting.js +++ b/client/js/sorting.js @@ -30,7 +30,7 @@ module.exports = function() { } ); - options.ignoreSortSync = true; + options.settings.ignoreSortSync = true; }, }); sidebar.find(".network").sortable({ @@ -58,7 +58,7 @@ module.exports = function() { } ); - options.ignoreSortSync = true; + options.settings.ignoreSortSync = true; }, }); }; diff --git a/client/views/windows/settings.tpl b/client/views/windows/settings.tpl index 20e20bf0..f5fe5737 100644 --- a/client/views/windows/settings.tpl +++ b/client/views/windows/settings.tpl @@ -5,6 +5,22 @@

Settings

+ {{#unless public}} +
+

+ Settings synchronisation + + + +

+ +

Warning Checking this box will override the settings of this client with those stored on the server.

+

Warning No settings have been synced before. Enabling this will sync all settings of this client as the base for other clients.

+
+ {{/unless}}

Messages

diff --git a/src/clientManager.js b/src/clientManager.js index 44b846cd..6e1c8092 100644 --- a/src/clientManager.js +++ b/src/clientManager.js @@ -116,6 +116,7 @@ ClientManager.prototype.addUser = function(name, password, enableLog) { awayMessage: "", networks: [], sessions: {}, + clientSettings: {}, }; try { diff --git a/src/server.js b/src/server.js index 22163cab..164733f1 100644 --- a/src/server.js +++ b/src/server.js @@ -441,6 +441,44 @@ function initializeClient(socket, client, token, lastMessage) { socket.on("sessions:get", sendSessionList); + if (!Helper.config.public) { + socket.on("setting:set", (newSetting) => { + if (!newSetting || typeof newSetting !== "object") { + return; + } + + // Older user configs will not have the clientSettings property. + if (!client.config.hasOwnProperty("clientSettings")) { + client.config.clientSettings = {}; + } + + // We do not need to do write operations and emit events if nothing changed. + if (client.config.clientSettings[newSetting.name] !== newSetting.value) { + client.config.clientSettings[newSetting.name] = newSetting.value; + + // Pass the setting to all clients. + client.emit("setting:new", { + name: newSetting.name, + value: newSetting.value, + }); + + client.manager.updateUser(client.name, { + clientSettings: client.config.clientSettings, + }); + } + }); + + socket.on("setting:get", () => { + if (!client.config.hasOwnProperty("clientSettings")) { + socket.emit("setting:all", {}); + return; + } + + const clientSettings = client.config.clientSettings; + socket.emit("setting:all", clientSettings); + }); + } + socket.on("sign-out", (tokenToSignOut) => { // If no token provided, sign same client out if (!tokenToSignOut) {