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'
+ );
+ } );
}() );