diff --git a/doc/cookies.md b/doc/cookies.md index 171de8a4640..ec28352b4a6 100644 --- a/doc/cookies.md +++ b/doc/cookies.md @@ -5,7 +5,7 @@ Cookies used by Etherpad. | Name | Sample value | Domain | Path | Expires/max-age | Http-only | Secure | Usage description | |-------------------|----------------------------------|-------------|------|-----------------|-----------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | express_sid | s%3A7yCNjRmTW8ylGQ53I2IhOwYF9... | example.org | / | Session | true | true | Session ID of the [Express web framework](https://expressjs.com). When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in [webaccess.js#L131](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131). | -| language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set in [pad_editor.js#L111](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_editor.js#L111). | +| language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set by the pad client when the user changes **My View → Language** (currently in `src/static/js/pad.ts`, via `setMyViewLanguage()`). | | prefs / prefsHttp | %7B%22epThemesExtTheme%22... | example.org | /p | year 3000 | false | true | Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in [pad_cookie.js#L49](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49). `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179. | | token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | false | true | A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at ([pad.js#L55-L66](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66)). This cookie is always set by the client (at [pad.js#L153-L158](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158)) without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at [SecurityManager.js#L33](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33). | diff --git a/settings.json.docker b/settings.json.docker index 890f225d30a..8fdd51de01e 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -205,6 +205,12 @@ **/ "enableDarkMode": "${ENABLE_DARK_MODE:true}", + /** + * Enable creator-owned Pad-wide Settings and new-pad default seeding from My View. + * Disabled by default to preserve the legacy single-settings behavior. + **/ + "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* * Node native SSL support * diff --git a/settings.json.template b/settings.json.template index 4ee63fe9bb1..0d1493c2b40 100644 --- a/settings.json.template +++ b/settings.json.template @@ -643,6 +643,12 @@ **/ "enableDarkMode": "${ENABLE_DARK_MODE:true}", + /** + * Enable creator-owned Pad-wide Settings and new-pad default seeding from My View. + * Disabled by default to preserve the legacy single-settings behavior. + **/ + "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited * diff --git a/src/locales/en.json b/src/locales/en.json index 964619bca8e..6305b43802a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -83,13 +83,19 @@ "pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame", "pad.permissionDenied": "You do not have permission to access this pad", - "pad.settings.padSettings": "Pad Settings", + "pad.settings.title": "Settings", + "pad.settings.padSettings": "Pad-wide Settings", + "pad.settings.userSettings": "User Settings", "pad.settings.myView": "My View", + "pad.settings.disablechat": "Disable Chat", + "pad.settings.darkMode": "Dark mode", "pad.settings.stickychat": "Chat always on screen", "pad.settings.chatandusers": "Show Chat and Users", "pad.settings.colorcheck": "Authorship colors", "pad.settings.linenocheck": "Line numbers", "pad.settings.rtlcheck": "Read content from right to left?", + "pad.settings.enforceSettings": "Enforce settings for other users", + "pad.settings.enforcedNotice": "These settings are locked for you by this pad's creator. Ask the pad creator if you need them changed.", "pad.settings.fontType": "Font type:", "pad.settings.fontType.normal": "Normal", "pad.settings.language": "Language:", diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index 821487cda08..7f400623336 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -27,6 +27,22 @@ import pad_utils from "../../static/js/pad_utils"; import {SmartOpAssembler} from "../../static/js/SmartOpAssembler"; import {timesLimit} from "async"; +type PadViewSettings = { + showAuthorColors: boolean; + showLineNumbers: boolean; + rtlIsTrue: boolean; + padFontFamily: string; +}; + +type PadSettings = { + enforceSettings: boolean; + showChat: boolean; + alwaysShowChat: boolean; + chatAndUsers: boolean; + lang: string; + view: PadViewSettings; +}; + /** * Copied from the Etherpad source code. It converts Windows line breaks to Unix * line breaks and convert Tabs to spaces @@ -47,6 +63,7 @@ class Pad { private publicStatus: boolean; private id: string; private savedRevisions: any[]; + private padSettings: PadSettings; /** * @param id * @param [database] - Database object to access this pad's records (and only this pad's records; @@ -64,6 +81,26 @@ class Pad { this.publicStatus = false; this.id = id; this.savedRevisions = []; + this.padSettings = Pad.normalizePadSettings(); + } + + static normalizePadSettings(rawPadSettings: any = {}): PadSettings { + const rawView = rawPadSettings.view ?? {}; + return { + enforceSettings: !!rawPadSettings.enforceSettings, + showChat: rawPadSettings.showChat == null ? settings.padOptions.showChat !== false : + !!rawPadSettings.showChat, + alwaysShowChat: !!rawPadSettings.alwaysShowChat, + chatAndUsers: !!rawPadSettings.chatAndUsers, + lang: typeof rawPadSettings.lang === 'string' ? rawPadSettings.lang : 'en', + view: { + showAuthorColors: rawView.showAuthorColors == null ? true : !!rawView.showAuthorColors, + showLineNumbers: rawView.showLineNumbers == null ? + settings.padOptions.showLineNumbers !== false : !!rawView.showLineNumbers, + rtlIsTrue: !!rawView.rtlIsTrue, + padFontFamily: typeof rawView.padFontFamily === 'string' ? rawView.padFontFamily : '', + }, + }; } apool() { @@ -88,6 +125,22 @@ class Pad { return this.publicStatus; } + getPadSettings() { + return Pad.normalizePadSettings(this.padSettings); + } + + setPadSettings(rawPadSettings: any) { + const nextPadSettings = { + ...this.getPadSettings(), + ...rawPadSettings, + view: { + ...this.getPadSettings().view, + ...(rawPadSettings?.view ?? {}), + }, + }; + this.padSettings = Pad.normalizePadSettings(nextPadSettings); + } + /** * Appends a new revision * @param {Object} aChangeset The changeset to append to the pad @@ -400,6 +453,7 @@ class Pad { const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool); await this.appendRevision(firstChangeset, authorId); } + this.padSettings = Pad.normalizePadSettings(this.padSettings); await hooks.aCallAll('padLoad', {pad: this}); } diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 072ae648ba5..8285a3a8a52 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -46,7 +46,7 @@ import {RateLimiterMemory} from 'rate-limiter-flexible'; import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest"; import {APool, AText, PadAuthor, PadType} from "../types/PadType"; import {ChangeSet} from "../types/ChangeSet"; -import {ChatMessageMessage, ClientReadyMessage, ClientSaveRevisionMessage, ClientSuggestUserName, ClientUserChangesMessage, ClientVarMessage, CustomMessage, PadDeleteMessage, UserNewInfoMessage} from "../../static/js/types/SocketIOMessage"; +import {ChatMessageMessage, ClientReadyMessage, ClientSaveRevisionMessage, ClientSuggestUserName, ClientUserChangesMessage, ClientVarMessage, CustomMessage, PadDeleteMessage, PadOptionsMessage, UserNewInfoMessage} from "../../static/js/types/SocketIOMessage"; import {Builder} from "../../static/js/Builder"; const webaccess = require('../hooks/express/webaccess'); const { checkValidRev } = require('../utils/checkValidRev'); @@ -263,6 +263,41 @@ const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) } } +const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0); + +const handlePadOptionsMessage = async ( + socket: any, message: PadOptionsMessage & {data: {payload: PadOptionsMessage}}) => { + const session = sessioninfos[socket.id]; + if (!session || !session.author || !session.padId) throw new Error('session not ready'); + if (!settings.enablePadWideSettings) return; + if (!await padManager.doesPadExist(session.padId)) { + messageLogger.warn(`Ignoring padoptions for missing pad ${session.padId}`); + return; + } + const pad = await padManager.getPad(session.padId, null, session.author); + if (!await isPadCreator(pad, session.author)) { + socket.emit('shout', { + type: 'COLLABROOM', + data: { + type: 'shoutMessage', + payload: { + message: { + message: 'Only the pad creator can change pad settings', + sticky: false, + }, + timestamp: Date.now(), + }, + }, + }); + return; + } + pad.setPadSettings(message.data.payload.options); + await pad.saveToDatabase(); + _getRoomSockets(session.padId).forEach((socket) => { + socket.emit('message', message); + }); +}; + /** * Handles a message from a user @@ -413,6 +448,11 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => { try { switch (type) { case 'suggestUserName': handleSuggestUserName(socket, message as unknown as ClientSuggestUserName); break; + case 'padoptions': + await handlePadOptionsMessage( + socket, + message as unknown as PadOptionsMessage & {data: {payload: PadOptionsMessage}}); + break; default: throw new Error('unknown message type'); } } catch (err) { @@ -883,8 +923,13 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { ]); ({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author)); + const padExisted = await padManager.doesPadExist(sessionInfo.padId); // load the pad-object from the database const pad = await padManager.getPad(sessionInfo.padId, null, sessionInfo.author); + if (settings.enablePadWideSettings && !padExisted && message.padSettingsDefaults) { + pad.setPadSettings(message.padSettingsDefaults); + await pad.saveToDatabase(); + } // these db requests all need the pad object (timestamp of latest revision, author data) const authors = pad.getAllAuthors(); @@ -1025,6 +1070,8 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { // Warning: never ever send sessionInfo.padId to the client. If the client is read only you // would open a security hole 1 swedish mile wide... + const canEditPadSettings = settings.enablePadWideSettings && + !sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author); const clientVars:MapArrayType = { skinName: settings.skinName, skinVariants: settings.skinVariants, @@ -1033,9 +1080,10 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { maxRevisions: 100, }, enableDarkMode: settings.enableDarkMode, + enablePadWideSettings: settings.enablePadWideSettings, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], - initialOptions: {}, + initialOptions: pad.getPadSettings(), savedRevisions: pad.getSavedRevisions(), collab_client_vars: { initialAttributedText: atext, @@ -1060,6 +1108,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { numConnectedUsers: roomSockets.length + 1, // +1 for this user (not yet in room) readOnlyId: sessionInfo.readOnlyPadId, readonly: sessionInfo.readonly, + canEditPadSettings, serverTimestamp: Date.now(), sessionRefreshInterval: settings.cookie.sessionRefreshInterval, userId: sessionInfo.author, diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 56fec21f6f9..0b250e494c3 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -172,6 +172,7 @@ export type SettingsType = { }, updateServer: string, enableDarkMode: boolean, + enablePadWideSettings: boolean, skinName: string | null, skinVariants: string, ip: string, @@ -294,7 +295,7 @@ export type SettingsType = { lowerCasePadIds: boolean, randomVersionString: string, gitVersion: string - getPublicSettings: () => Pick, + getPublicSettings: () => Pick, } const settings: SettingsType = { @@ -328,6 +329,7 @@ const settings: SettingsType = { }, updateServer: "https://static.etherpad.org", enableDarkMode: true, + enablePadWideSettings: false, /* * Skin name. * @@ -657,6 +659,7 @@ const settings: SettingsType = { title: settings.title, skinName: settings.skinName, skinVariants: settings.skinVariants, + enablePadWideSettings: settings.enablePadWideSettings, } }, gitVersion: getGitCommit(), diff --git a/src/static/js/chat.ts b/src/static/js/chat.ts index 6357663c4f6..fdd8b017b3c 100644 --- a/src/static/js/chat.ts +++ b/src/static/js/chat.ts @@ -34,6 +34,7 @@ exports.chat = (() => { let chatMentions = 0; return { show() { + if (pad.settings.hideChat) return; $('#chaticon').removeClass('visible'); $('#chatbox').addClass('visible'); this.scrollDown(true); @@ -49,7 +50,7 @@ exports.chat = (() => { }, 100); }, // Make chat stick to right hand side of screen - stickToScreen(fromInitialCall) { + stickToScreen(fromInitialCall, persistPreference = true) { if ($('#options-stickychat').prop('checked')) { $('#options-stickychat').prop('checked', false); } @@ -65,13 +66,13 @@ exports.chat = (() => { $('#chatbox').css('display', 'flex'); }, 0); - padcookie.setPref('chatAlwaysVisible', isStuck); + if (persistPreference) padcookie.setPref('chatAlwaysVisible', isStuck); $('#options-stickychat').prop('checked', isStuck); }, - chatAndUsers(fromInitialCall) { + chatAndUsers(fromInitialCall, persistPreference = true) { const toEnable = $('#options-chatandusers').is(':checked'); if (toEnable || !userAndChat || fromInitialCall) { - this.stickToScreen(true); + this.stickToScreen(true, persistPreference); $('#options-stickychat').prop('checked', true); $('#options-chatandusers').prop('checked', true); $('#options-stickychat').prop('disabled', true); @@ -80,7 +81,7 @@ exports.chat = (() => { $('#options-stickychat').prop('disabled', false); userAndChat = false; } - padcookie.setPref('chatAndUsers', userAndChat); + if (persistPreference) padcookie.setPref('chatAndUsers', userAndChat); $('#users, .sticky-container') .toggleClass('chatAndUsers popup-show stickyUsers', userAndChat); $('#chatbox').toggleClass('chatAndUsersChat', userAndChat); @@ -204,7 +205,7 @@ exports.chat = (() => { count++; $('#chatcounter').text(count); - if (!chatOpen && ctx.duration > 0) { + if (!pad.settings.hideChat && !chatOpen && ctx.duration > 0) { const text = $('

') .append($('').addClass('author-name').text(ctx.authorName)) // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index d9cc4e902ed..fd80dbd1a08 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -80,10 +80,13 @@ const getParameters = [ name: 'showChat', checkVal: null, callback: (val) => { + clientVars.initialOptions.showChat = val !== 'false'; if (val === 'false') { settings.hideChat = true; chat.hide(); $('#chaticon').hide(); + } else { + settings.hideChat = false; } }, }, @@ -176,6 +179,43 @@ const getParams = () => { const getUrlVars = () => new URL(window.location.href).searchParams; +const getCookieLanguage = () => { + const cp = (window as any).clientVars?.cookiePrefix || ''; + return Cookies.get(`${cp}language`) || Cookies.get('language'); +}; + +const getMyViewOverrides = () => { + const language = getCookieLanguage(); + const overrides = { + showChat: padcookie.getPref('showChat'), + alwaysShowChat: padcookie.getPref('chatAlwaysVisible'), + chatAndUsers: padcookie.getPref('chatAndUsers'), + lang: language, + view: { + showAuthorColors: padcookie.getPref('showAuthorshipColors'), + showLineNumbers: padcookie.getPref('showLineNumbers'), + rtlIsTrue: padcookie.getPref('rtlIsTrue'), + padFontFamily: padcookie.getPref('padFontFamily'), + }, + }; + if (language == null) delete overrides.lang; + return overrides; +}; + +const normalizeChatOptions = (options) => { + if (options.showChat === false) { + options.alwaysShowChat = false; + options.chatAndUsers = false; + } + if (options.chatAndUsers === true) { + options.showChat = true; + options.alwaysShowChat = true; + } else if (options.alwaysShowChat === true) { + options.showChat = true; + } + return options; +}; + const sendClientReady = (isReconnect) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces @@ -204,7 +244,7 @@ const sendClientReady = (isReconnect) => { name: params.get('userName'), }; - const msg = { + const msg: any = { component: 'pad', type: 'CLIENT_READY', padId, @@ -212,6 +252,16 @@ const sendClientReady = (isReconnect) => { token, userInfo, }; + const overrides = getMyViewOverrides(); + const viewOverrides = Object.fromEntries( + Object.entries(overrides.view || {}).filter(([, v]) => v != null)); + const hasTopLevelOverrides = ['showChat', 'alwaysShowChat', 'chatAndUsers', 'lang'] + .some((k) => overrides[k] != null); + if (Object.keys(viewOverrides).length > 0 || hasTopLevelOverrides) { + if (Object.keys(viewOverrides).length > 0) overrides.view = viewOverrides; + else delete overrides.view; + msg.padSettingsDefaults = overrides; + } // this is a reconnect, lets tell the server our revisionnumber if (isReconnect) { @@ -405,12 +455,123 @@ const pad = { getClientIp: () => clientVars.clientIp, getColorPalette: () => clientVars.colorPalette, getPrivilege: (name) => clientVars.accountPrivs[name], + canEditPadSettings: () => !!clientVars.canEditPadSettings, getUserId: () => pad.myUserInfo.userId, getUserName: () => pad.myUserInfo.name, userList: () => paduserlist.users(), + isPadSettingsEnforcedForMe: () => !!pad.padOptions.enforceSettings && !pad.canEditPadSettings(), sendClientMessage: (msg) => { pad.collabClient.sendClientMessage(msg); }, + getEffectivePadOptions: () => { + const effectiveOptions = $.extend(true, {}, pad.padOptions); + if (pad.isPadSettingsEnforcedForMe()) return normalizeChatOptions(effectiveOptions); + const overrides = getMyViewOverrides(); + for (const key of ['showChat', 'alwaysShowChat', 'chatAndUsers', 'lang']) { + if (overrides[key] != null) effectiveOptions[key] = overrides[key]; + } + if (!effectiveOptions.view) effectiveOptions.view = {}; + for (const [key, value] of Object.entries(overrides.view)) { + if (value != null) effectiveOptions.view[key] = value; + } + return normalizeChatOptions(effectiveOptions); + }, + refreshPadSettingsControls: () => { + const padOptions = normalizeChatOptions($.extend(true, {}, pad.padOptions || {})); + const view = padOptions.view || {}; + $('#padsettings-options-disablechat').prop('checked', padOptions.showChat === false); + $('#padsettings-options-stickychat').prop('checked', !!padOptions.alwaysShowChat); + $('#padsettings-options-chatandusers').prop('checked', !!padOptions.chatAndUsers); + $('#padsettings-options-colorscheck').prop('checked', view.showAuthorColors !== false); + $('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false); + $('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue); + $('#padsettings-viewfontmenu').val(view.padFontFamily || ''); + $('#padsettings-languagemenu').val(padOptions.lang || 'en'); + $('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings); + $('#padsettings-options-stickychat, #padsettings-options-chatandusers') + .prop('disabled', padOptions.showChat === false); + if ($('select').niceSelect) $('select').niceSelect('update'); + }, + refreshMyViewControls: () => { + const effectiveOptions = pad.getEffectivePadOptions(); + const disabled = pad.isPadSettingsEnforcedForMe(); + $('#options-disablechat').prop('checked', effectiveOptions.showChat === false); + $('#options-stickychat').prop('checked', !!effectiveOptions.alwaysShowChat); + $('#options-chatandusers').prop('checked', !!effectiveOptions.chatAndUsers); + $('#options-colorscheck').prop('checked', effectiveOptions.view?.showAuthorColors !== false); + $('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false); + $('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue); + $('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || ''); + $('#languagemenu').val(effectiveOptions.lang || 'en'); + $('#settings input[id^="options-"]').prop('disabled', disabled); + $('#viewfontmenu, #languagemenu').prop('disabled', disabled); + $('#options-stickychat, #options-chatandusers') + .prop('disabled', disabled || effectiveOptions.showChat === false); + $('#enforce-settings-notice').prop('hidden', !disabled); + if ($('select').niceSelect) $('select').niceSelect('update'); + }, + setMyViewOption: (key, value) => { + switch (key) { + case 'showChat': + padcookie.setPref('showChat', value); + if (!value) { + padcookie.setPref('chatAlwaysVisible', false); + padcookie.setPref('chatAndUsers', false); + } + break; + case 'alwaysShowChat': + padcookie.setPref('chatAlwaysVisible', value); + if (value) padcookie.setPref('showChat', true); + break; + case 'chatAndUsers': + padcookie.setPref('chatAndUsers', value); + if (value) padcookie.setPref('chatAlwaysVisible', true); + if (value) padcookie.setPref('showChat', true); + break; + case 'showAuthorColors': + padcookie.setPref('showAuthorshipColors', value); + break; + default: + padcookie.setPref(key, value); + break; + } + pad.refreshMyViewControls(); + pad.applyOptionsChange(); + }, + setMyViewLanguage: (lang) => { + const cp = (window as any).clientVars?.cookiePrefix || ''; + Cookies.set(`${cp}language`, lang); + pad.refreshMyViewControls(); + pad.applyOptionsChange(); + }, + applyShowChat: (enabled) => { + settings.hideChat = !enabled; + if (enabled) { + if (!window.clientVars.readonly) $('#chaticon').show(); + } else { + $('#users, .sticky-container').removeClass('chatAndUsers popup-show stickyUsers'); + $('#chatbox').removeClass('chatAndUsersChat stickyChat visible').hide(); + $('#options-stickychat, #options-chatandusers').prop('checked', false); + $('#chaticon').hide(); + } + }, + applyStickyChat: (enabled) => { + const isSticky = $('#chatbox').hasClass('stickyChat'); + $('#options-stickychat').prop('checked', enabled); + if (enabled !== isSticky) chat.stickToScreen(enabled, false); + if (!enabled) $('#options-stickychat').prop('disabled', false); + }, + applyChatAndUsers: (enabled) => { + const isEnabled = $('#users').hasClass('chatAndUsers'); + $('#options-chatandusers').prop('checked', enabled); + if (enabled !== isEnabled) chat.chatAndUsers(enabled, false); + if (!enabled) $('#options-stickychat').prop('disabled', false); + }, + applyLanguage: (lang) => { + html10n.localize([lang, 'en']); + $('#languagemenu').val(lang); + if ($('select').niceSelect) $('select').niceSelect('update'); + }, init() { padutils.setupGlobalExceptionHandler(); @@ -449,32 +610,13 @@ const pad = { setTimeout(() => { padeditor.ace.focus(); }, 0); - const optionsStickyChat = $('#options-stickychat'); - optionsStickyChat.on('click', () => { chat.stickToScreen(); }); - // if we have a cookie for always showing chat then show it - if (padcookie.getPref('chatAlwaysVisible')) { - chat.stickToScreen(true); // stick it to the screen - optionsStickyChat.prop('checked', true); // set the checkbox to on - } - // if we have a cookie for always showing chat then show it - if (padcookie.getPref('chatAndUsers')) { - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } - if (padcookie.getPref('showAuthorshipColors') === false) { - pad.changeViewOption('showAuthorColors', false); - } - if (padcookie.getPref('showLineNumbers') === false) { - pad.changeViewOption('showLineNumbers', false); - } + pad.refreshPadSettingsControls(); + pad.applyOptionsChange(); + pad.refreshMyViewControls(); if (settings.rtlIsExplicit) { // URL or server config explicitly set RTL — takes priority over cookie pad.changeViewOption('rtlIsTrue', settings.rtlIsTrue === true); - } else if (padcookie.getPref('rtlIsTrue') === true) { - pad.changeViewOption('rtlIsTrue', true); } - pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); - $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); // Prevent sticky chat or chat and users to be checked for mobiles const checkChatAndUsersVisibility = (x) => { @@ -489,13 +631,13 @@ const pad = { $('#editorcontainer').addClass('initialized'); - if (window.clientVars.enableDarkMode) { - $('#theme-switcher').attr('style', 'display: flex;'); - } - if (window.location.hash.toLowerCase() !== '#skinvariantsbuilder' && window.clientVars.enableDarkMode && (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) && !skinVariants.isWhiteModeEnabledInLocalStorage()) { skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']); } + if (window.clientVars.enableDarkMode) { + $('#theme-toggle-row').prop('hidden', false); + $('#options-darkmode').prop('checked', skinVariants.isDarkMode()); + } hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; @@ -503,7 +645,7 @@ const pad = { // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); - padeditor.init(pad.padOptions.view || {}, this).then(postAceInit); + padeditor.init(pad.getEffectivePadOptions().view || {}, this).then(postAceInit); paduserlist.init(pad.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); @@ -592,7 +734,7 @@ const pad = { changePadOption: (key, value) => { const options = {}; options[key] = value; - pad.handleOptionsChange(options); + pad.applyPadSettings(options); pad.collabClient.sendClientMessage( { type: 'padoptions', @@ -600,26 +742,57 @@ const pad = { changedBy: pad.myUserInfo.name || 'unnamed', }); }, - changeViewOption: (key, value) => { + changePadViewOption: (key, value) => { const options = { view: {}, }; options.view[key] = value; - pad.handleOptionsChange(options); + pad.applyPadSettings(options); + pad.collabClient.sendClientMessage( + { + type: 'padoptions', + options, + changedBy: pad.myUserInfo.name || 'unnamed', + }); }, - handleOptionsChange: (opts) => { + changeViewOption: (key, value) => { + const effectiveOptions = pad.getEffectivePadOptions(); + if (!effectiveOptions.view) effectiveOptions.view = {}; + effectiveOptions.view[key] = value; + padeditor.setViewOptions(effectiveOptions.view); + }, + applyPadSettings: (opts = {}) => { // opts object is a full set of options or just // some options to change + for (const key of ['enforceSettings', 'showChat', 'alwaysShowChat', 'chatAndUsers', 'lang']) { + if (opts[key] == null) continue; + pad.padOptions[key] = key === 'lang' ? opts[key] : `${opts[key]}` === 'true'; + } if (opts.view) { if (!pad.padOptions.view) { pad.padOptions.view = {}; } for (const [k, v] of Object.entries(opts.view)) { pad.padOptions.view[k] = v; - padcookie.setPref(k, v); } - padeditor.setViewOptions(pad.padOptions.view); } + normalizeChatOptions(pad.padOptions); + pad.refreshPadSettingsControls(); + pad.applyOptionsChange(); + }, + applyOptionsChange: () => { + const effectiveOptions = pad.getEffectivePadOptions(); + padeditor.setViewOptions(effectiveOptions.view || {}); + pad.applyShowChat(effectiveOptions.showChat !== false); + if (effectiveOptions.showChat !== false) { + if (effectiveOptions.lang) pad.applyLanguage(effectiveOptions.lang); + pad.applyChatAndUsers(!!effectiveOptions.chatAndUsers); + if (!effectiveOptions.chatAndUsers) pad.applyStickyChat(!!effectiveOptions.alwaysShowChat); + } + pad.refreshMyViewControls(); + }, + handleOptionsChange: (opts) => { + pad.applyPadSettings(opts); }, // caller shouldn't mutate the object getPadOptions: () => pad.padOptions, @@ -699,39 +872,19 @@ const pad = { } }, handleIsFullyConnected: (isConnected, isInitialConnect) => { - pad.determineChatVisibility(isConnected && !isInitialConnect); - pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); - pad.determineAuthorshipColorsVisibility(); + pad.refreshMyViewControls(); setTimeout(() => { padeditbar.toggleDropDown('none'); }, 1000); }, determineChatVisibility: (asNowConnectedFeedback) => { - const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); - if (chatVisCookie) { // if the cookie is set for chat always visible - chat.stickToScreen(true); // stick it to the screen - $('#options-stickychat').prop('checked', true); // set the checkbox to on - } else { - $('#options-stickychat').prop('checked', false); // set the checkbox for off - } + pad.refreshMyViewControls(); }, determineChatAndUsersVisibility: (asNowConnectedFeedback) => { - const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); - if (chatAUVisCookie) { // if the cookie is set for chat always visible - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } else { - $('#options-chatandusers').prop('checked', false); // set the checkbox for off - } + pad.refreshMyViewControls(); }, determineAuthorshipColorsVisibility: () => { - const authColCookie = padcookie.getPref('showAuthorshipColors'); - if (authColCookie) { - pad.changeViewOption('showAuthorColors', true); - $('#options-colorscheck').prop('checked', true); - } else { - $('#options-colorscheck').prop('checked', false); - } + pad.refreshMyViewControls(); }, handleCollabAction: (action) => { if (action === 'commitPerformed') { diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index 7feb81e30ea..267ad5dd6d3 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -22,8 +22,7 @@ * limitations under the License. */ -import padutils,{Cookies} from "./pad_utils"; -const padcookie = require('./pad_cookie').padcookie; +import padutils from "./pad_utils"; const Ace2Editor = require('./ace').Ace2Editor; import html10n from '../js/vendors/html10n' const skinVariants = require('./skin_variants'); @@ -56,34 +55,86 @@ const padeditor = (() => { $('#viewbarcontents').show(); }, initViewOptions: () => { - // Line numbers + // My View + padutils.bindCheckboxChange($('#options-disablechat'), () => { + pad.setMyViewOption('showChat', !padutils.getCheckbox($('#options-disablechat'))); + }); + padutils.bindCheckboxChange($('#options-stickychat'), () => { + pad.setMyViewOption('alwaysShowChat', padutils.getCheckbox($('#options-stickychat'))); + }); + padutils.bindCheckboxChange($('#options-chatandusers'), () => { + pad.setMyViewOption('chatAndUsers', padutils.getCheckbox($('#options-chatandusers'))); + }); + padutils.bindCheckboxChange($('#options-colorscheck'), () => { + pad.setMyViewOption('showAuthorColors', padutils.getCheckbox($('#options-colorscheck'))); + }); padutils.bindCheckboxChange($('#options-linenoscheck'), () => { - pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); + pad.setMyViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); + }); + padutils.bindCheckboxChange($('#options-rtlcheck'), () => { + pad.setMyViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); + }); + $('#viewfontmenu').on('change', () => { + pad.setMyViewOption('padFontFamily', $('#viewfontmenu').val()); + }); + $('#languagemenu').on('change', () => { + pad.setMyViewLanguage($('#languagemenu').val()); + }); + + // Pad settings + padutils.bindCheckboxChange($('#padsettings-enforcecheck'), () => { + pad.changePadOption('enforceSettings', padutils.getCheckbox($('#padsettings-enforcecheck'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-disablechat'), () => { + pad.changePadOption('showChat', !padutils.getCheckbox($('#padsettings-options-disablechat'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-stickychat'), () => { + pad.changePadOption( + 'alwaysShowChat', padutils.getCheckbox($('#padsettings-options-stickychat'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-chatandusers'), () => { + pad.changePadOption( + 'chatAndUsers', padutils.getCheckbox($('#padsettings-options-chatandusers'))); + }); + // Line numbers + padutils.bindCheckboxChange($('#padsettings-options-linenoscheck'), () => { + pad.changePadViewOption( + 'showLineNumbers', padutils.getCheckbox($('#padsettings-options-linenoscheck'))); }); // Author colors - padutils.bindCheckboxChange($('#options-colorscheck'), () => { - padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck')); - pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); + padutils.bindCheckboxChange($('#padsettings-options-colorscheck'), () => { + pad.changePadViewOption( + 'showAuthorColors', padutils.getCheckbox('#padsettings-options-colorscheck')); }); // Right to left - padutils.bindCheckboxChange($('#options-rtlcheck'), () => { - pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); + padutils.bindCheckboxChange($('#padsettings-options-rtlcheck'), () => { + pad.changePadViewOption( + 'rtlIsTrue', padutils.getCheckbox($('#padsettings-options-rtlcheck'))); }); html10n.bind('localized', () => { - // Don't override RTL when explicitly set via URL/server or user cookie - if (settings && settings.rtlIsExplicit) return; - if (padcookie.getPref('rtlIsTrue') === true) return; - pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); + $('#languagemenu').val(html10n.getLanguage()); + $('#padsettings-languagemenu').val(html10n.getLanguage()); }); // font family change - $('#viewfontmenu').on('change', () => { - pad.changeViewOption('padFontFamily', $('#viewfontmenu').val()); + $('#padsettings-viewfontmenu').on('change', () => { + pad.changePadViewOption('padFontFamily', $('#padsettings-viewfontmenu').val()); + }); + + padutils.bindCheckboxChange($('#options-darkmode'), () => { + const isDark = padutils.getCheckbox($('#options-darkmode')); + skinVariants.setDarkModeInLocalStorage(isDark); + if (isDark) { + skinVariants.updateSkinVariantsClasses( + ['super-dark-editor', 'dark-background', 'super-dark-toolbar']); + } else { + skinVariants.updateSkinVariantsClasses( + ['super-light-toolbar super-light-editor light-background']); + } }); // delete pad @@ -116,20 +167,8 @@ const padeditor = (() => { } }) - // theme switch - $('#theme-switcher').on('click',()=>{ - if (skinVariants.isDarkMode()) { - skinVariants.setDarkModeInLocalStorage(false); - skinVariants.updateSkinVariantsClasses(['super-light-toolbar super-light-editor light-background']); - } else { - skinVariants.setDarkModeInLocalStorage(true); - skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']); - } - }) - // Language html10n.bind('localized', () => { - $('#languagemenu').val(html10n.getLanguage()); // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist // this does not interfere with html10n's normal value-setting because @@ -143,15 +182,14 @@ const padeditor = (() => { } }); }); - $('#languagemenu').val(html10n.getLanguage()); - $('#languagemenu').on('change', () => { - const cp = (window as any).clientVars?.cookiePrefix || ''; - Cookies.set(`${cp}language`, $('#languagemenu').val()); - html10n.localize([$('#languagemenu').val(), 'en']); - if ($('select').niceSelect) { - $('select').niceSelect('update'); - } + $('#padsettings-languagemenu').val(html10n.getLanguage()); + $('#padsettings-languagemenu').on('change', () => { + pad.changePadOption('lang', $('#padsettings-languagemenu').val()); }); + if (pad.canEditPadSettings()) { + $('#pad-settings-section').prop('hidden', false); + $('.settings-sections').addClass('has-pad-settings'); + } }, setViewOptions: (newOptions) => { const getOption = (key, defaultValue) => { @@ -183,6 +221,8 @@ const padeditor = (() => { } self.ace.setProperty('textface', newOptions.padFontFamily || ''); + $('#viewfontmenu').val(newOptions.padFontFamily || ''); + if ($('select').niceSelect) $('select').niceSelect('update'); }, dispose: () => { if (self.ace) { diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 690c293cba8..08be6a03ee5 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -73,8 +73,10 @@ export type ClientVarPayload = { chatHead: number, readonly: boolean, serverTimestamp: number, - initialOptions: MapArrayType, + initialOptions: PadOption, userId: string, + canEditPadSettings?: boolean, + enablePadWideSettings?: boolean, mode: string, randomVersionString: string, skinName: string @@ -184,6 +186,7 @@ export type ClientReadyMessage = { sessionID: string, token: string, userInfo: UserInfo, + padSettingsDefaults?: PadOption, reconnect?: boolean client_rev?: number } @@ -249,7 +252,7 @@ export type PadOption = { "alwaysShowChat"?: boolean, "chatAndUsers"?: boolean, "lang"?: null|string, - view? : MapArrayType + view? : MapArrayType } @@ -322,4 +325,3 @@ export type SocketClientReadyMessage = { reconnect?: boolean client_rev?: number } - diff --git a/src/static/skins/colibris/src/components/buttons.css b/src/static/skins/colibris/src/components/buttons.css index c9c3e0c2a3a..aea8f1e5627 100644 --- a/src/static/skins/colibris/src/components/buttons.css +++ b/src/static/skins/colibris/src/components/buttons.css @@ -55,3 +55,20 @@ button, .btn box-shadow: none; cursor: not-allowed; } + +.btn-danger { + background: #d1242f; + color: #fff; +} + +.btn-danger:hover { + background: #b71c26; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +.btn-danger:disabled { + background: #aaa; + color: #fff; + cursor: not-allowed; +} diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index db664743101..381c10d8726 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -26,6 +26,20 @@ color: var(--text-color); } +.settings-sections { + display: grid; + gap: 24px; +} + +.settings-section { + margin-bottom: 20px; + min-width: 0; +} + +#settings .settings-section > h2 { + margin-top: 0; +} + .popup:not(.comment-modal) p { margin: 10px 0; } @@ -41,6 +55,21 @@ min-width: 180px; } +.settings-notice { + color: var(--text-color); + font-size: 0.95rem; + line-height: 1.4; + border-left: 3px solid var(--primary-color); + padding-left: 10px; +} + +@media (min-width: 1100px) { + .settings-sections.has-pad-settings { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } +} + @media (prefers-reduced-motion) { .popup>.popup-content { transform: scale(1); @@ -83,45 +112,5 @@ } #delete-pad { - background-color: #ff7b72; -} - -#theme-switcher div { - position: relative; - width: 30px; - background-color: white; - height: 10px; - border-radius: 5px; - align-self: center; -} - -#theme-switcher { - display: flex; margin-top: 20px; - flex-direction: row; -} - -#theme-switcher div span { - width: 15px; - display: block; - height: 15px; - border-radius: 20px; - position: absolute; - top: -2px; - background-color: white; - transition: background-color 0.25s; -} - -html.super-light-editor #theme-switcher div { - background-color: #ccc; -} - -html.super-light-editor #theme-switcher div span { - left: 0; - background-color: var(--primary-color);; -} - -html.super-dark-editor #theme-switcher div span { - right: 0; - background-color: var(--primary-color);; } diff --git a/src/templates/pad.html b/src/templates/pad.html index 926d16c0305..5e593f6d7aa 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -115,68 +115,133 @@