diff --git a/doc/docker.md b/doc/docker.md index fe31ea031ab..8e22ba51837 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -115,6 +115,7 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_ALWAYS_SHOW_CHAT` | | `false` | | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | +| `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS` | Fade each author's caret/background toward white as they go inactive. Set to `false` on busy pads (every faded author counts as a second on-screen color, so 30 contributors visually become 60), when users pick light colors that fade into the background, or whenever inactivity tracking is undesirable. | `true` | ### Shortcuts diff --git a/settings.json.docker b/settings.json.docker index 97a02746928..1f1d6d05bca 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -318,7 +318,8 @@ "rtl": "${PAD_OPTIONS_RTL:false}", "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", - "lang": "${PAD_OPTIONS_LANG:null}" + "lang": "${PAD_OPTIONS_LANG:null}", + "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}" }, /* diff --git a/settings.json.template b/settings.json.template index 3b5148318a9..4fc59819c94 100644 --- a/settings.json.template +++ b/settings.json.template @@ -302,7 +302,13 @@ "rtl": false, "alwaysShowChat": false, "chatAndUsers": false, - "lang": null + "lang": null, + /* + * When true (default), each author's caret/background color fades toward white + * as the author goes inactive. Set to false if users pick light colors and the + * faded variants become visually indistinguishable. + */ + "fadeInactiveAuthorColors": true }, /* diff --git a/src/locales/en.json b/src/locales/en.json index a9943ea6e25..e33cdef8bbb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -108,6 +108,7 @@ "pad.settings.stickychat": "Chat always on screen", "pad.settings.chatandusers": "Show Chat and Users", "pad.settings.colorcheck": "Authorship colors", + "pad.settings.fadeInactiveAuthorColors": "Fade inactive author colors", "pad.settings.linenocheck": "Line numbers", "pad.settings.rtlcheck": "Read content from right to left?", "pad.settings.enforceSettings": "Enforce settings for other users", diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index e2e1f0830d2..ad135f21c99 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -33,6 +33,7 @@ type PadViewSettings = { showLineNumbers: boolean; rtlIsTrue: boolean; padFontFamily: string; + fadeInactiveAuthorColors: boolean; }; type PadSettings = { @@ -103,6 +104,9 @@ class Pad { settings.padOptions.showLineNumbers !== false : !!rawView.showLineNumbers, rtlIsTrue: !!rawView.rtlIsTrue, padFontFamily: typeof rawView.padFontFamily === 'string' ? rawView.padFontFamily : '', + fadeInactiveAuthorColors: rawView.fadeInactiveAuthorColors == null ? + settings.padOptions.fadeInactiveAuthorColors !== false : + !!rawView.fadeInactiveAuthorColors, }, }; } diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index ebedc693794..26876cd08cf 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -206,6 +206,7 @@ export type SettingsType = { alwaysShowChat: boolean, chatAndUsers: boolean, lang: string | null, + fadeInactiveAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -439,6 +440,7 @@ const settings: SettingsType = { alwaysShowChat: false, chatAndUsers: false, lang: null, + fadeInactiveAuthorColors: true, }, /** * Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this. diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index 04b7569fc25..b0b7df0d1d3 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -141,6 +141,8 @@ function Ace2Inner(editorInfo, cssManagers) { let doesWrap = true; let hasLineNumbers = true; let isStyled = true; + let fadeInactiveAuthorColors = + window.clientVars?.padOptions?.fadeInactiveAuthorColors !== false; let console = (DEBUG && window.console); @@ -236,7 +238,13 @@ function Ace2Inner(editorInfo, cssManagers) { cssManagers.parent.removeSelectorStyle(authorSelector); } else if (info.bgcolor) { let bgcolor = info.bgcolor; - if ((typeof info.fade) === 'number') { + // The fade is controlled at runtime by the fadeInactiveAuthorColors flag (default + // true), which tracks padOptions.view.fadeInactiveAuthorColors and is updated by + // ace_setProperty when the user/pad-settings checkbox flips. Disabling it keeps + // each author's background at their chosen value — useful on busy pads where each + // faded author would otherwise count as a second on-screen color, or when + // inactivity tracking is undesirable for whatever reason. + if (fadeInactiveAuthorColors && (typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } const textColor = @@ -667,6 +675,15 @@ function Ace2Inner(editorInfo, cssManagers) { targetBody.classList.toggle('ltr', !value); document.documentElement.dir = value ? 'rtl' : 'ltr'; }, + fadeinactiveauthorcolors: (value) => { + fadeInactiveAuthorColors = `${value}` !== 'false'; + // Re-apply styles for every known author so that pre-faded backgrounds + // refresh immediately when the toggle flips, instead of waiting for the + // next fade tick (which only fires on join/leave). + for (const [author, info] of Object.entries(authorInfos)) { + if (info) setAuthorStyle(author, info); + } + }, }; const setter = setters[key.toLowerCase()]; diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index cc49f458e47..4a97bf9022f 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -71,6 +71,15 @@ const getParameters = [ $('#clearAuthorship').hide(); }, }, + { + name: 'fadeInactiveAuthorColors', + checkVal: 'false', + callback: (val) => { + if (!clientVars.initialOptions) return; + if (!clientVars.initialOptions.view) clientVars.initialOptions.view = {}; + clientVars.initialOptions.view.fadeInactiveAuthorColors = false; + }, + }, { name: 'showControls', checkVal: 'true', @@ -214,6 +223,7 @@ const getMyViewOverrides = () => { showLineNumbers: padcookie.getPref('showLineNumbers'), rtlIsTrue: padcookie.getPref('rtlIsTrue'), padFontFamily: padcookie.getPref('padFontFamily'), + fadeInactiveAuthorColors: padcookie.getPref('fadeInactiveAuthorColors'), }, }; if (language == null) delete overrides.lang; @@ -549,6 +559,8 @@ const pad = { $('#padsettings-options-stickychat').prop('checked', !!padOptions.alwaysShowChat); $('#padsettings-options-chatandusers').prop('checked', !!padOptions.chatAndUsers); $('#padsettings-options-colorscheck').prop('checked', view.showAuthorColors !== false); + $('#padsettings-options-fadeauthorcheck') + .prop('checked', view.fadeInactiveAuthorColors !== false); $('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false); $('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue); $('#padsettings-viewfontmenu').val(view.padFontFamily || ''); @@ -565,6 +577,8 @@ const pad = { $('#options-stickychat').prop('checked', !!effectiveOptions.alwaysShowChat); $('#options-chatandusers').prop('checked', !!effectiveOptions.chatAndUsers); $('#options-colorscheck').prop('checked', effectiveOptions.view?.showAuthorColors !== false); + $('#options-fadeauthorcheck') + .prop('checked', effectiveOptions.view?.fadeInactiveAuthorColors !== false); $('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false); $('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue); $('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || ''); diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index a828bb38d6a..96db7879a28 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -68,6 +68,11 @@ const padeditor = (() => { padutils.bindCheckboxChange($('#options-colorscheck'), () => { pad.setMyViewOption('showAuthorColors', padutils.getCheckbox($('#options-colorscheck'))); }); + padutils.bindCheckboxChange($('#options-fadeauthorcheck'), () => { + pad.setMyViewOption( + 'fadeInactiveAuthorColors', + padutils.getCheckbox($('#options-fadeauthorcheck'))); + }); padutils.bindCheckboxChange($('#options-linenoscheck'), () => { pad.setMyViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); }); @@ -108,6 +113,13 @@ const padeditor = (() => { 'showAuthorColors', padutils.getCheckbox('#padsettings-options-colorscheck')); }); + // Fade inactive author colors + padutils.bindCheckboxChange($('#padsettings-options-fadeauthorcheck'), () => { + pad.changePadViewOption( + 'fadeInactiveAuthorColors', + padutils.getCheckbox('#padsettings-options-fadeauthorcheck')); + }); + // Right to left padutils.bindCheckboxChange($('#padsettings-options-rtlcheck'), () => { pad.changePadViewOption( @@ -248,6 +260,10 @@ const padeditor = (() => { $('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v); padutils.setCheckbox($('#options-colorscheck'), v); + v = getOption('fadeInactiveAuthorColors', true); + self.ace.setProperty('fadeInactiveAuthorColors', v); + padutils.setCheckbox($('#options-fadeauthorcheck'), v); + // Override from parameters if true if (settings.noColors !== false) { self.ace.setProperty('showsauthorcolors', !settings.noColors); diff --git a/src/templates/pad.html b/src/templates/pad.html index c1715a37580..ecac35ad607 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -156,6 +156,10 @@
++ + +
@@ -217,6 +221,10 @@
++ + +
diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index cf515fcac03..23290156f9a 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -186,4 +186,15 @@ describe(__filename, function () { } }); }); + + // Regression test for ether/etherpad#7138. + // padOptions.fadeInactiveAuthorColors must default to true so existing + // installations keep the legacy fade-on-inactive behavior, and must be + // overridable via PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS in docker. + describe('padOptions.fadeInactiveAuthorColors (issue #7138)', function () { + it('defaults to true so existing deployments are unchanged', function () { + const settings = require('../../../node/utils/Settings'); + assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true); + }); + }); }); diff --git a/src/tests/frontend-new/specs/inactive_color_fade.spec.ts b/src/tests/frontend-new/specs/inactive_color_fade.spec.ts new file mode 100644 index 00000000000..b070a369a6c --- /dev/null +++ b/src/tests/frontend-new/specs/inactive_color_fade.spec.ts @@ -0,0 +1,53 @@ +import {expect, test} from "@playwright/test"; +import {appendQueryParams, goToNewPad} from "../helper/padHelper"; +import {showSettings} from "../helper/settingsHelper"; + +test.beforeEach(async ({page}) => { + // clearCookies on the page's own context — `browser.newContext()` + // creates a separate context that the `page` fixture doesn't use, + // so clearing cookies on it is a no-op (Qodo review feedback). + await page.context().clearCookies(); + await goToNewPad(page); +}); + +test.describe('fadeInactiveAuthorColors (issue #7138)', function () { + test('server-side default is true (legacy fade behavior preserved)', async function ({page}) { + const fade = await page.evaluate( + () => (window as any).clientVars?.padOptions?.fadeInactiveAuthorColors); + expect(fade).toBe(true); + }); + + test('per-pad view default propagates from server settings', async function ({page}) { + const fade = await page.evaluate( + () => (window as any).clientVars?.initialOptions?.view?.fadeInactiveAuthorColors); + expect(fade).toBe(true); + }); + + test('?fadeInactiveAuthorColors=false flips the per-pad view value', async function ({page}) { + await appendQueryParams(page, {fadeInactiveAuthorColors: 'false'}); + const fade = await page.evaluate( + () => (window as any).clientVars?.initialOptions?.view?.fadeInactiveAuthorColors); + expect(fade).toBe(false); + }); + + test('My View checkbox toggles the per-user cookie', async function ({page}) { + // Open the settings popup, untick the box, confirm the cookie pref now + // overrides the server-default. + await showSettings(page); + const checkbox = page.locator('#options-fadeauthorcheck'); + await expect(checkbox).toBeChecked(); + // The label is i18n'd, not hardcoded — assert the localized string actually + // rendered (catches missing keys, not just a present DOM node). + await expect(page.locator('label[for="options-fadeauthorcheck"]')) + .toHaveText('Fade inactive author colors'); + await page.locator('label[for="options-fadeauthorcheck"]').click(); + await expect(checkbox).not.toBeChecked(); + // padcookie stores prefs as a single JSON cookie. The name is `prefs` over + // HTTPS and `prefsHttp` over HTTP, optionally with a `cookiePrefix`. + const cookies = await page.context().cookies(); + const prefsCookie = cookies.find((c) => /(prefs|prefsHttp)$/.test(c.name)); + expect(prefsCookie, 'expected a prefs cookie after toggling').toBeDefined(); + const decoded = JSON.parse(decodeURIComponent(prefsCookie!.value)); + expect(decoded.fadeInactiveAuthorColors).toBe(false); + }); +});