diff --git a/MultimediaViewer.php b/MultimediaViewer.php index 0b23e9a3d..338c65b38 100644 --- a/MultimediaViewer.php +++ b/MultimediaViewer.php @@ -745,6 +745,16 @@ $wgResourceModules += array( ), ), + 'mmv.Config' => $wgMediaViewerResourceTemplate + array( + 'scripts' => array( + 'mmv/mmv.Config.js', + ), + + 'dependencies' => array( + 'mmv.base', + ), + ), + 'mmv' => $wgMediaViewerResourceTemplate + array( 'scripts' => array( 'mmv/mmv.js', @@ -793,6 +803,7 @@ $wgResourceModules += array( 'dependencies' => array( 'jquery.hashchange', 'mediawiki.Title', + 'mmv.Config', 'mmv.ActionLogger', 'mmv.HtmlUtils', 'mmv.DurationLogger', diff --git a/MultimediaViewerHooks.php b/MultimediaViewerHooks.php index cc23e36e8..4ee3f0f39 100644 --- a/MultimediaViewerHooks.php +++ b/MultimediaViewerHooks.php @@ -189,6 +189,7 @@ class MultimediaViewerHooks { 'tests/qunit/mmv/mmv.lightboximage.test.js', 'tests/qunit/mmv/mmv.ThumbnailWidthCalculator.test.js', 'tests/qunit/mmv/mmv.EmbedFileFormatter.test.js', + 'tests/qunit/mmv/mmv.Config.test.js', 'tests/qunit/mmv/mmv.HtmlUtils.test.js', 'tests/qunit/mmv/mmv.performance.test.js', 'tests/qunit/mmv/mmv.ActionLogger.test.js', diff --git a/docs/categories.json b/docs/categories.json index c2b016c0f..fdf52279f 100644 --- a/docs/categories.json +++ b/docs/categories.json @@ -7,6 +7,7 @@ "classes": [ "mw.mmv.Api", "mw.mmv.ActionLogger", + "mw.mmv.Config", "mw.mmv.DurationLogger", "mw.mmv.EmbedFileFormatter", "mw.mmv.HtmlUtils", @@ -84,6 +85,7 @@ "mw", "mw.Api", "mw.Title", + "mw.Map", "mw.eventLog" ] }, diff --git a/docs/external.js b/docs/external.js index 83d4930c2..49c6a9ee0 100644 --- a/docs/external.js +++ b/docs/external.js @@ -19,6 +19,12 @@ * */ +/** + * @class mw.Map + * Associative array which is used for various configuration objects, most prominently mw.config: + * + */ + /** * @class HTMLElement * An HTML element. diff --git a/resources/mmv/mmv.Config.js b/resources/mmv/mmv.Config.js new file mode 100644 index 000000000..341cdebe8 --- /dev/null +++ b/resources/mmv/mmv.Config.js @@ -0,0 +1,175 @@ +/* + * This file is part of the MediaWiki extension MediaViewer. + * + * MediaViewer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * MediaViewer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MediaViewer. If not, see . + */ + +( function ( mw, $ ) { + var CP; + + /** + * @class mw.mmv.Config + * Contains/retrieves configuration/environment information for MediaViewer. + * @constructor + */ + function Config( viewerConfig, mwConfig, mwUser, api, localStorage ) { + /** + * A plain object storing MediaViewer-specific settings + * @type {Object} + */ + this.viewerConfig = viewerConfig; + + /** + * The mw.config object, for dependency injection + * @type {mw.Map} + */ + this.mwConfig = mwConfig; + + /** + * mw.user object, for dependency injection + * @type {Object} + */ + this.mwUser = mwUser; + + /** + * API object, for dependency injction + * @type {mw.Api} + */ + this.api = api; + + /** + * The localStorage object, for dependency injection + * @type {Object} + */ + this.localStorage = localStorage; + } + CP = Config.prototype; + + /** + * Get value from local storage or fail gracefully. + * @param {string} key + * @param {*} [fallback] value to return when key is not set or localStorage is not supported + * @returns {*} stored value or fallback or null if neither exists + */ + CP.getFromLocalStorage = function ( key, fallback ) { + var value = null; + if ( this.localStorage ) { + value = this.localStorage.getItem( key ); + } + if ( value === null && fallback !== undefined ) { + value = fallback; + } + return value; + }; + + /** + * Set item in local storage or fail gracefully. + * @param {string} key + * @param {*} value + * @return {boolean} whether storing the item was successful + */ + CP.setInLocalStorage = function ( key, value ) { + var success = false; + if ( this.localStorage ) { + try { + this.localStorage.setItem( key, value ); + success = true; + } catch ( e ) {} + } + return success; + }; + + /** + * Remove item from local storage or fail gracefully. + * @param {string} key + * @return {boolean} whether storing the item was successful + */ + CP.removeFromLocalStorage = function ( key ) { + if ( this.localStorage ) { + try { + this.localStorage.removeItem( key ); + return true; + } catch ( e ) { + return false; + } + } + return true; // since we never even stored the value, this is considered a success + }; + + /** + * Set user preference via AJAX + * @param {string} key + * @param {string} value + * @returns {jQuery.Promise} a deferred which resolves/rejects on success/failure respectively + */ + CP.setUserPreference = function ( key, value ) { + return this.api.postWithToken( 'options', { + action: 'options', + optionname: key, + optionvalue: value + } ); + }; + + /** + * Returns true if MediaViewer should handle thumbnail clicks. + */ + CP.isMediaViewerEnabledOnClick = function () { + // IMPORTANT: mmv.head.js uses the same logic but does not use this class to be lightweight. Make sure to keep it in sync. + return this.mwConfig.get( 'wgMediaViewer' ) // global opt-out switch, can be set in user JS + && this.mwConfig.get( 'wgMediaViewerOnClick' ) // thumbnail opt-out, can be set in preferences + && ( !this.mwUser.isAnon() || this.getFromLocalStorage( 'wgMediaViewerOnClick', 1 ) === 1 ); // thumbnail opt-out for anons + }; + + /** + * (Semi-)permanently stores the setting whether MediaViewer should handle thumbnail clicks. + * - for logged-in users, we use preferences + * - for anons, we use localStorage + * - for anons with old browsers, we don't do anything + * @param {boolean} enabled + * @return {jQuery.Promise} a deferred which resolves/rejects on success/failure respectively + */ + CP.setMediaViewerEnabledOnClick = function ( enabled ) { + var config = this, + success = true; + + if ( this.mwUser.isAnon() ) { + if ( !enabled ) { + success = this.setInLocalStorage( 'wgMediaViewerOnClick', '0' ); // localStorage stringifies everything, best use strings in the first place + } else { + success = this.removeFromLocalStorage( 'wgMediaViewerOnClick' ); + } + if ( success ) { + config.mwConfig.set( 'wgMediaViewerOnClick', enabled ); + return $.Deferred().resolve(); + } else { + return $.Deferred().reject(); + } + } else { + return this.setUserPreference( 'multimediaviewer-enable', enabled ? true : '').then( function () { // wow our prefs API sucks + // make the change work without a reload + config.mwConfig.set( 'wgMediaViewerOnClick', enabled ); + } ); + } + }; + + /** + * Returns true if #setMediaViewerEnabledOnClick() is supported. + * @return {boolean} + */ + CP.canSetMediaViewerEnabledOnClick = function () { + return !this.mwUser.isAnon() || !!this.localStorage; + }; + + mw.mmv.Config = Config; +} ( mediaWiki, jQuery ) ); diff --git a/resources/mmv/mmv.bootstrap.js b/resources/mmv/mmv.bootstrap.js index 7d11b02b1..41bd60145 100755 --- a/resources/mmv/mmv.bootstrap.js +++ b/resources/mmv/mmv.bootstrap.js @@ -39,6 +39,17 @@ this.readinessWaitDuration = 100; this.hoverWaitDuration = 200; + // TODO lazy-load config and htmlUtils + + /** @property {mw.mmv.Config} config - */ + this.config = new mw.mmv.Config( + mw.config.get( 'wgMultimediaViewer', {} ), + mw.config, + mw.user, + new mw.Api(), + window.localStorage + ); + /** @property {mw.mmv.HtmlUtils} htmlUtils - */ this.htmlUtils = new mw.mmv.HtmlUtils(); @@ -190,7 +201,7 @@ // If this is a thumb, we preload JS/CSS when the mouse cursor hovers the thumb container (thumb image + caption + border) $thumbContain.mouseenter( function() { // There is no point preloading if clicking the thumb won't open Media Viewer - if ( mw.config.get( 'wgMediaViewerOnClick' ) !== true ) { + if ( !bs.config.isMediaViewerEnabledOnClick() ) { return; } bs.preloadOnHoverTimer = setTimeout( function() { @@ -253,7 +264,7 @@ } // Don't load if someone has specifically stopped us from doing so - if ( mw.config.get( 'wgMediaViewerOnClick' ) !== true && overridePreference !== true ) { + if ( !this.config.isMediaViewerEnabledOnClick() && overridePreference !== true ) { return; } diff --git a/resources/mmv/mmv.head.js b/resources/mmv/mmv.head.js index 3e2d2e9e7..48e804cb8 100644 --- a/resources/mmv/mmv.head.js +++ b/resources/mmv/mmv.head.js @@ -25,7 +25,10 @@ // If the user disabled MediaViewer in his preferences, we do not set up click handling. // This is loaded before user JS so we cannot check wgMediaViewer. - if ( mw.config.get( 'wgMediaViewerOnClick' ) !== true ) { + if ( + mw.config.get( 'wgMediaViewerOnClick' ) !== true + || mw.user.isAnon() && window.localStorage && localStorage.getItem( 'wgMediaViewerOnClick' ) === false + ) { return; } diff --git a/tests/qunit/mmv/mmv.Config.test.js b/tests/qunit/mmv/mmv.Config.test.js new file mode 100644 index 000000000..8f0d2261f --- /dev/null +++ b/tests/qunit/mmv/mmv.Config.test.js @@ -0,0 +1,176 @@ +/* + * This file is part of the MediaWiki extension MediaViewer. + * + * MediaViewer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * MediaViewer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MediaViewer. If not, see . + */ + +( function ( mw, $ ) { + QUnit.module( 'mmv.Config', QUnit.newMwEnvironment() ); + + QUnit.test( 'Constructor sanity test', 1, function ( assert ) { + var config = new mw.mmv.Config( {}, {}, {}, {}, null ); + assert.ok( config ); + } ); + + QUnit.test( 'Localstorage get', 8, function ( assert ) { + var localStorage, config; + + localStorage = undefined; // no browser support + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.getFromLocalStorage( 'foo' ), null, 'Returns null when not supported' ); + assert.strictEqual( config.getFromLocalStorage( 'foo', 'bar' ), 'bar', 'Returns fallback when not supported' ); + + localStorage = null; // browser supports it but disabled + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.getFromLocalStorage( 'foo' ), null, 'Returns null when disabled' ); + assert.strictEqual( config.getFromLocalStorage( 'foo', 'bar' ), 'bar', 'Returns fallback when disabled' ); + + localStorage = { getItem: this.sandbox.stub() }; + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + + localStorage.getItem.withArgs( 'foo' ).returns( null ); + assert.strictEqual( config.getFromLocalStorage( 'foo' ), null, 'Returns null when key not set' ); + assert.strictEqual( config.getFromLocalStorage( 'foo', 'bar' ), 'bar', 'Returns fallback when key not set' ); + + localStorage.getItem.reset(); + localStorage.getItem.withArgs( 'foo' ).returns( 'boom' ); + assert.strictEqual( config.getFromLocalStorage( 'foo' ), 'boom', 'Returns correct value' ); + assert.strictEqual( config.getFromLocalStorage( 'foo', 'bar' ), 'boom', 'Returns correct value ignoring fallback' ); + } ); + + QUnit.test( 'Localstorage set', 4, function ( assert ) { + var localStorage, config; + + localStorage = undefined; // no browser support + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.setInLocalStorage( 'foo', 'bar' ), false, 'Returns false when not supported' ); + + localStorage = null; // browser supports it but disabled + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.setInLocalStorage( 'foo', 'bar' ), false, 'Returns false when disabled' ); + + localStorage = { setItem: this.sandbox.stub() }; + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + + assert.strictEqual( config.setInLocalStorage( 'foo', 'bar' ), true, 'Returns true when works' ); + + localStorage.setItem.throwsException( 'localStorage full!' ); + assert.strictEqual( config.setInLocalStorage( 'foo', 'bar' ), false, 'Returns false on error' ); + } ); + + QUnit.test( 'Localstorage remove', 4, function ( assert ) { + var localStorage, config; + + localStorage = undefined; // no browser support + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.removeFromLocalStorage( 'foo' ), true, 'Returns true when not supported' ); + + localStorage = null; // browser supports it but disabled + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + assert.strictEqual( config.removeFromLocalStorage( 'foo' ), true , 'Returns true when disabled' ); + + localStorage = { removeItem: this.sandbox.stub() }; + config = new mw.mmv.Config( {}, {}, {}, {}, localStorage ); + + assert.strictEqual( config.removeFromLocalStorage( 'foo' ), true, 'Returns true when works' ); + + localStorage.removeItem.throwsException( 'cannot write localStorage!' ); + assert.strictEqual( config.removeFromLocalStorage( 'foo' ), false, 'Returns false on error' ); + } ); + + QUnit.test( 'isMediaViewerEnabledOnClick', 7, function ( assert ) { + var localStorage = { getItem: this.sandbox.stub() }, + mwConfig = { get: this.sandbox.stub() }, + mwUser = { isAnon: this.sandbox.stub() }, + config = new mw.mmv.Config( {}, mwConfig, mwUser, {}, localStorage ); + + mwUser.isAnon.returns( false ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( true ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( true ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), true, 'Returns true for logged-in with standard settings' ); + + mwUser.isAnon.returns( false ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( false ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( true ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), false, 'Returns false if opted out via user JS flag' ); + + mwUser.isAnon.returns( false ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( true ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( false ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), false, 'Returns false if opted out via preferences' ); + + mwUser.isAnon.returns( true ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( false ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( true ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), false, 'Returns false if anon user opted out via user JS flag' ); + + mwUser.isAnon.returns( true ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( true ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( false ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), false, 'Returns false if anon user opted out in some weird way' ); // apparently someone created a browser extension to do this + + mwUser.isAnon.returns( true ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( true ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( true ); + localStorage.getItem.withArgs( 'wgMediaViewerOnClick').returns( null ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), true, 'Returns true for anon with standard settings' ); + + mwUser.isAnon.returns( true ); + mwConfig.get.withArgs( 'wgMediaViewer' ).returns( true ); + mwConfig.get.withArgs( 'wgMediaViewerOnClick' ).returns( true ); + localStorage.getItem.withArgs( 'wgMediaViewerOnClick').returns( '0' ); + assert.strictEqual( config.isMediaViewerEnabledOnClick(), false, 'Returns true for anon opted out via localSettings' ); + } ); + + QUnit.test( 'setMediaViewerEnabledOnClick sanity check', 3, function ( assert ) { + var localStorage = { setItem: this.sandbox.stub(), removeItem: this.sandbox.stub() }, + mwUser = { isAnon: this.sandbox.stub() }, + mwConfig = { set: this.sandbox.stub() }, + api = { postWithToken: this.sandbox.stub() }, + config = new mw.mmv.Config( {}, mwConfig, mwUser, api, localStorage ); + + mwUser.isAnon.returns( false ); + api.postWithToken.returns( $.Deferred().resolve() ); + config.setMediaViewerEnabledOnClick( false ); + assert.ok( api.postWithToken.called, 'For logged-in users, pref change is via API' ); + + mwUser.isAnon.returns( true ); + config.setMediaViewerEnabledOnClick( false ); + assert.ok( localStorage.setItem.called, 'For anons, opt-out is set in localStorage' ); + + mwUser.isAnon.returns( true ); + config.setMediaViewerEnabledOnClick( true ); + assert.ok( localStorage.removeItem.called, 'For anons, opt-in means clearing localStorage' ); + } ); + + QUnit.test( 'canSetMediaViewerEnabledOnClick', 4, function ( assert ) { + var mwUser = { isAnon: this.sandbox.stub() }, + config = new mw.mmv.Config( {}, {}, mwUser, {}, {} ); + + mwUser.isAnon.returns( false ); + assert.strictEqual( config.canSetMediaViewerEnabledOnClick(), true, 'Logged-in users can always disable' ); + + mwUser.isAnon.returns( true ); + config = new mw.mmv.Config( {}, {}, mwUser, {}, {} ); + assert.strictEqual( config.canSetMediaViewerEnabledOnClick(), true, 'Anons can disable when they have localStorage support' ); + + mwUser.isAnon.returns( true ); + config = new mw.mmv.Config( {}, {}, mwUser, {}, null ); + assert.strictEqual( config.canSetMediaViewerEnabledOnClick(), false, 'Anons cannot disable when they have no localStorage support' ); + + mwUser.isAnon.returns( true ); + config = new mw.mmv.Config( {}, {}, mwUser, {}, undefined ); + assert.strictEqual( config.canSetMediaViewerEnabledOnClick(), false, 'Anons cannot disable when disabled localStorage' ); + } ); +} ( mediaWiki, jQuery ) ); diff --git a/tests/qunit/mmv/mmv.bootstrap.test.js b/tests/qunit/mmv/mmv.bootstrap.test.js index d0f41772f..d01eb2934 100644 --- a/tests/qunit/mmv/mmv.bootstrap.test.js +++ b/tests/qunit/mmv/mmv.bootstrap.test.js @@ -1,16 +1,12 @@ ( function ( mw, $ ) { - var backup = {}; - QUnit.module( 'mmv.bootstrap', QUnit.newMwEnvironment( { setup: function () { - backup.onclick = mw.config.get( 'wgMediaViewerOnClick' ); + mw.config.set( 'wgMediaViewer', true ); mw.config.set( 'wgMediaViewerOnClick', true ); + this.sandbox.stub( mw.user, 'isAnon').returns( false ); this.clock = this.sandbox.useFakeTimers(); - }, - - teardown: function () { - mw.config.set( 'wgMediaViewerOnClick', backup.onclick ); - } } ) ); + } + } ) ); function createGallery( imageSrc ) { var div = $( '
' ).addClass( 'gallery' ).appendTo( '#qunit-fixture' ),