diff --git a/includes/ResourceLoader/ClientHtml.php b/includes/ResourceLoader/ClientHtml.php index b6806fa956a..7f1a69eca15 100644 --- a/includes/ResourceLoader/ClientHtml.php +++ b/includes/ResourceLoader/ClientHtml.php @@ -264,19 +264,40 @@ document.documentElement.className = {$jsClassJson}; if ( $this->options['clientPrefEnabled'] ) { $cookiePrefix = $this->options['clientPrefCookiePrefix']; + // ( T339268 ) Add/Modify client preference classes to documentElement from cookie + // this is done to have preferences available before the DOM, so it renders as intended $script .= << "vector-feature-foo-clientpref-1" + // "mw-pref-bar-clientpref-2" -> "mw-pref-bar-clientpref-4" + // "mw-pref-bar-display-clientpref-dark" -> "mw-pref-bar-display-clientpref-light" + document.documentElement.className = document.documentElement.className.replace( + /(^| )([^ ]+)-clientpref-[a-zA-Z0-9]+( |$)/g, + function ( match, before, key, after ) { + if ( clientPreferences.hasOwnProperty( key ) ) { + return before + key + '-clientpref-' + clientPreferences[ key ] + after; + } + return match; + } + ); } () ); JS; } diff --git a/resources/src/mediawiki.user.js b/resources/src/mediawiki.user.js index 2ab2e0be1a7..b4aec54a834 100644 --- a/resources/src/mediawiki.user.js +++ b/resources/src/mediawiki.user.js @@ -4,6 +4,10 @@ */ ( function () { var userInfoPromise, tempUserNamePromise, pageviewRandomId, sessionId; + var COOKIE_NAME = 'mwclientpreferences', + FEATURE_SUFFIX = '-clientpref-', + FEATURE_DELIMITER = '!', + KEY_VALUE_DELIMITER = '~'; /** * Get the current user's groups or rights @@ -18,6 +22,50 @@ return userInfoPromise; } + /** + * Updates the existing client preferences stored in cookies along with a newly set + * feature/value pair + * + * @param {string} feature that was just modified. + * @param {string} value of newly modified feature + */ + function syncHTMLWithCookie( feature, value ) { + var existingCookie = mw.cookie.get( COOKIE_NAME ); + var storeFromCookie = {}; + if ( existingCookie ) { + existingCookie.split( FEATURE_DELIMITER ).forEach( function ( keyValuePair ) { + var kV = keyValuePair.split( KEY_VALUE_DELIMITER ); + storeFromCookie[ kV[ 0 ] ] = kV[ 1 ]; + } ); + } + storeFromCookie[ feature ] = value; + var cookieValue = Object.keys( storeFromCookie ).map( function ( key ) { + return key + KEY_VALUE_DELIMITER + storeFromCookie[ key ]; + } ).join( FEATURE_DELIMITER ); + mw.cookie.set( COOKIE_NAME, cookieValue ); + } + + /** + * Checks if the feature is composed of valid characters. + * A valid feature name can contain letters, numbers of "-" character. + * + * @param {string} value + * @return {boolean} + */ + function isValidFeatureName( value ) { + return value.match( /^[a-zA-Z0-9-]+$/ ) !== null; + } + + /** + * Checks if the value is composed of valid characters. + * + * @param {string} value + * @return {boolean} + */ + function isValidFeatureValue( value ) { + return value.match( /^[a-zA-Z0-9]+$/ ) !== null; + } + // mw.user with the properties options and tokens gets defined in mediawiki.base.js. Object.assign( mw.user, { @@ -261,6 +309,63 @@ function ( userInfo ) { return userInfo.rights; }, function () { return []; } ).then( callback ); + }, + + /** + * Client preferences store's management + */ + clientPrefs: { + /** + * Change the class of the document element, and set feature value in clientPreferencesStore + * + * @param {string} feature + * @param {string} value + * @return {boolean} true if feature was stored successfully, false if the value + * uses a forbidden character or the feature is not recognised + * e.g. an appropriate class has not been defined on the body. + */ + set: function ( feature, value ) { + if ( !isValidFeatureName( feature ) || !isValidFeatureValue( value ) ) { + return false; + } + var currentValue = this.get( feature ); + // the feature is not recognized + if ( !currentValue ) { + return false; + } + var oldFeatureClass = feature + FEATURE_SUFFIX + currentValue; + var newFeatureClass = feature + FEATURE_SUFFIX + value; + // The following classes are removed here: + // * feature-name-clientpref- + // * e.g. vector-font-size--clientpref-small + document.documentElement.classList.remove( oldFeatureClass ); + // The following classes are added here: + // * feature-name-clientpref- + // * e.g. vector-font-size--clientpref-xlarge + document.documentElement.classList.add( newFeatureClass ); + syncHTMLWithCookie( feature, value ); + return true; + }, + + /** + * Retrieve the current value of the feature from the HTML element + * + * @param {string} feature + * @return {string|boolean} returns boolean if the feature is not recognized + * returns string if a feature was found. + */ + get: function ( feature ) { + var featurePrefix = feature + FEATURE_SUFFIX; + var docClass = document.documentElement.classList.toString(); + var featureRegEx = new RegExp( + '(^| )' + mw.util.escapeRegExp( featurePrefix ) + '([a-zA-Z0-9]+)( |$)' + ); + var match = docClass.match( featureRegEx ); + + // check no further matches if we replaced this occurance. + var isAmbiguous = docClass.replace( featureRegEx, '$1$3' ).match( featureRegEx ) !== null; + return !isAmbiguous && match ? match[ 2 ] : false; + } } } ); diff --git a/tests/qunit/resources/mediawiki.user.test.js b/tests/qunit/resources/mediawiki.user.test.js index 84a1c0aae35..3c4b8540574 100644 --- a/tests/qunit/resources/mediawiki.user.test.js +++ b/tests/qunit/resources/mediawiki.user.test.js @@ -1,6 +1,12 @@ ( function () { + var CLIENT_PREF_COOKIE_NAME = 'mwclientpreferences'; + var docClass; QUnit.module( 'mediawiki.user', QUnit.newMwEnvironment( { beforeEach: function () { + docClass = document.documentElement.getAttribute( 'class' ); + document.documentElement.setAttribute( 'class', '' ); + // reset any cookies + mw.cookie.set( CLIENT_PREF_COOKIE_NAME, null ); this.server = this.sandbox.useFakeServer(); this.server.respondImmediately = true; // Cannot stub by simple assignment because read-only. @@ -11,6 +17,8 @@ this.msCrypto = Object.getOwnPropertyDescriptor( window, 'msCrypto' ); }, afterEach: function () { + mw.cookie.set( CLIENT_PREF_COOKIE_NAME, null ); + document.documentElement.setAttribute( 'class', docClass ); if ( this.crypto ) { Object.defineProperty( window, 'crypto', this.crypto ); } @@ -139,4 +147,108 @@ assert.strictEqual( result.trim(), result, 'no leading or trailing whitespace' ); assert.strictEqual( result2, result, 'retained' ); } ); + + QUnit.test( 'clientPrefs.get: client preferences are always obtained from HTML element the one source of truth', function ( assert ) { + document.documentElement.setAttribute( 'class', 'client-js font-size-clientpref-1 font-size-clientpref-unrelated-class invalid-clientpref-bad-value ambiguous-clientpref-off ambiguous-clientpref-on' ); + var result = mw.user.clientPrefs.get( 'font-size' ); + var badValue = mw.user.clientPrefs.get( 'invalid' ); + var ambiguousValue = mw.user.clientPrefs.get( 'ambiguous' ); + assert.strictEqual( result, '1', 'client preferences are read from HTML element' ); + assert.strictEqual( badValue, false, 'classes in the wrong format are ignored.' ); + assert.strictEqual( ambiguousValue, false, 'ambiguous values are resolved to false' ); + } ); + + QUnit.test( 'clientPrefs.get: client preferences never read from cookie', function ( assert ) { + mw.cookie.set( CLIENT_PREF_COOKIE_NAME, 'unknown~500' ); + var resultUnknown = mw.user.clientPrefs.get( 'unknown' ); + assert.false( resultUnknown, + 'if an appropriate class is not on the HTML element it returns false even if there is a value in the cookie' ); + } ); + + QUnit.test( 'clientPrefs.set: can set client valid preferences', function ( assert ) { + document.documentElement.classList.add( 'limited-width-clientpref-1', 'font-size-clientpref-100' ); + var resultLimitedWidth = mw.user.clientPrefs.set( 'limited-width', '0' ); + var resultFontSize = mw.user.clientPrefs.set( 'font-size', '10' ); + assert.true( resultLimitedWidth, 'the client preference limited width was set correctly' ); + assert.true( resultFontSize, 'the client preference font size was set correctly' ); + assert.true( document.documentElement.classList.contains( 'limited-width-clientpref-0' ), + 'the limited width class on the document was updated' ); + assert.true( document.documentElement.classList.contains( 'font-size-clientpref-10' ), + 'the font size classes on the document was updated correctly' ); + const mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME ); + assert.true( + mwclientpreferences && + mwclientpreferences.includes( 'limited-width~0' ) && + mwclientpreferences.includes( 'font-size~10' ) && + mwclientpreferences.includes( '!' ), + 'cookie was set correctly' + ); + } ); + + QUnit.test( 'clientPrefs.set: cannot set invalid valid preferences', function ( assert ) { + document.documentElement.classList.add( 'client-js' ); + var result = mw.user.clientPrefs.set( 'client', 'nojs' ); + assert.false( result, 'the client preference was rejected (lacking -clientpref- suffix)' ); + assert.true( document.documentElement.classList.contains( 'client-js' ), 'the classes on the document were not changed' ); + } ); + + QUnit.test( 'clientPrefs.set: cannot set preferences with invalid characters', function ( assert ) { + document.documentElement.setAttribute( 'class', 'client-js bar-clientpref-1' ); + [ + [ 'client-js bar', 'nojs' ], + [ 'bar-clientpref', '50' ], + [ 'bar', ' client-nojs' ], + [ 'bar', 'client-nojs' ], + [ '', 'nothing' ], + [ 'feature', '' ], + [ 'foo!client~no-js!bar', 'hi' ] + ].forEach( function ( test, i ) { + var result = mw.user.clientPrefs.set( test[ 0 ], test[ 1 ] ); + assert.false( result, 'the client preference was rejected (invalid characters in name) (test case ' + i + ')' ); + } ); + + assert.strictEqual( document.documentElement.getAttribute( 'class' ), 'client-js bar-clientpref-1' ); + } ); + + QUnit.test( 'clientPrefs.set: always set cookie when manipulating preferences', function ( assert ) { + document.documentElement.setAttribute( 'class', 'dark-mode-clientpref-enabled' ); + var result = mw.user.clientPrefs.set( 'dark-mode', 'disabled' ); + assert.true( result, 'the client preference was stored successfully' ); + assert.strictEqual( + document.documentElement.getAttribute( 'class' ), + 'dark-mode-clientpref-disabled', + 'the class was modified' + ); + let mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME ); + assert.true( + mwclientpreferences && + mwclientpreferences.includes( 'dark-mode~disabled' ), + 'it was stored to a cookie' + ); + result = mw.user.clientPrefs.set( 'dark-mode', 'enabled' ); + assert.true( result, 'the 2nd client preference was also stored successfully' ); + assert.strictEqual( + document.documentElement.getAttribute( 'class' ), + 'dark-mode-clientpref-enabled', + 'the class was also modified again' + ); + mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME ); + assert.strictEqual( + mwclientpreferences, + 'dark-mode~enabled', + 'always store even if it matches default as we have no knowledge of what the default could be' + ); + } ); + + QUnit.test( 'clientPrefs.set: Only store values that are explicitly set', function ( assert ) { + // set cookie and body classes + document.documentElement.setAttribute( 'class', 'client-js not-a-feature-clientpref-bad-value font-clientpref-32 dark-mode-clientpref-32 limited-width-clientpref-enabled' ); + mw.user.clientPrefs.set( 'dark-mode', 'enabled' ); + const mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME ); + assert.strictEqual( + mwclientpreferences, + 'dark-mode~enabled', + 'always store even if it matches default as we have no knowledge of what the default could be' + ); + } ); }() );