From 0834b91f5699922a14d50a3bff90eed4bf81f781 Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Fri, 15 Jan 2016 14:11:33 -0800 Subject: [PATCH] Echo API layer Split and refactor Echo network handling and create a proper API layer for the UI to use consistently. Split Echo's API methods into its own module so they can be loaded along with the initialization script and manage the API requests. Change-Id: I0526a14bb8cc0d9729a303e24ab6e43259cc86bb --- Resources.php | 26 +- modules/api/mw.echo.api.APIHandler.js | 167 +++++++++++++ modules/api/mw.echo.api.EchoApi.js | 157 ++++++++++++ modules/api/mw.echo.api.ForeignAPIHandler.js | 41 ++++ modules/api/mw.echo.api.LocalAPIHandler.js | 97 ++++++++ modules/api/mw.echo.api.NetworkHandler.js | 101 ++++++++ modules/api/mw.echo.api.js | 4 + modules/ext.echo.init.js | 36 +-- .../mw.echo.ui.NotificationBadgeWidget.js | 71 +++--- .../handlers/mw.echo.dm.APIHandler.js | 130 ---------- .../handlers/mw.echo.dm.ForeignAPIHandler.js | 25 -- .../handlers/mw.echo.dm.LocalAPIHandler.js | 133 ---------- .../handlers/mw.echo.dm.NetworkHandler.js | 143 ----------- .../mw.echo.dm.NotificationGroupItem.js | 23 +- .../mw.echo.dm.NotificationsModel.js | 228 +++++++----------- .../test_mw.echo.dm.NotificationsModel.js | 26 +- 16 files changed, 735 insertions(+), 673 deletions(-) create mode 100644 modules/api/mw.echo.api.APIHandler.js create mode 100644 modules/api/mw.echo.api.EchoApi.js create mode 100644 modules/api/mw.echo.api.ForeignAPIHandler.js create mode 100644 modules/api/mw.echo.api.LocalAPIHandler.js create mode 100644 modules/api/mw.echo.api.NetworkHandler.js create mode 100644 modules/api/mw.echo.api.js delete mode 100644 modules/viewmodel/handlers/mw.echo.dm.APIHandler.js delete mode 100644 modules/viewmodel/handlers/mw.echo.dm.ForeignAPIHandler.js delete mode 100644 modules/viewmodel/handlers/mw.echo.dm.LocalAPIHandler.js delete mode 100644 modules/viewmodel/handlers/mw.echo.dm.NetworkHandler.js diff --git a/Resources.php b/Resources.php index 4e88e2d86..399c537ee 100644 --- a/Resources.php +++ b/Resources.php @@ -117,19 +117,14 @@ $wgResourceModules += array( 'viewmodel/mw.echo.dm.js', 'viewmodel/mw.echo.dm.List.js', 'viewmodel/mw.echo.dm.SortedList.js', - 'viewmodel/handlers/mw.echo.dm.APIHandler.js', - 'viewmodel/handlers/mw.echo.dm.LocalAPIHandler.js', - 'viewmodel/handlers/mw.echo.dm.ForeignAPIHandler.js', - 'viewmodel/handlers/mw.echo.dm.NetworkHandler.js', 'viewmodel/mw.echo.dm.NotificationItem.js', 'viewmodel/mw.echo.dm.NotificationGroupItem.js', 'viewmodel/mw.echo.dm.NotificationList.js', 'viewmodel/mw.echo.dm.NotificationsModel.js', ), 'dependencies' => array( - 'mediawiki.api', - 'mediawiki.ForeignApi', - 'oojs' + 'oojs', + 'ext.echo.api', ), 'messages' => array( 'echo-api-failure', @@ -137,6 +132,22 @@ $wgResourceModules += array( ), 'targets' => array( 'desktop', 'mobile' ), ), + 'ext.echo.api' => $echoResourceTemplate + array( + 'scripts' => array( + 'api/mw.echo.api.js', + 'api/mw.echo.api.EchoApi.js', + 'api/mw.echo.api.APIHandler.js', + 'api/mw.echo.api.LocalAPIHandler.js', + 'api/mw.echo.api.ForeignAPIHandler.js', + 'api/mw.echo.api.NetworkHandler.js', + ), + 'dependencies' => array( + 'mediawiki.api', + 'mediawiki.ForeignApi', + 'oojs' + ), + 'targets' => array( 'desktop', 'mobile' ), + ), 'ext.echo.base' => array( // This is a dummy module for backwards compatibility. // Most extensions that require ext.echo.base actually need @@ -154,6 +165,7 @@ $wgResourceModules += array( 'scripts' => array( 'ext.echo.init.js', ), + 'dependencies' => array( 'ext.echo.api' ), 'targets' => array( 'desktop' ), ), // Base no-js styles diff --git a/modules/api/mw.echo.api.APIHandler.js b/modules/api/mw.echo.api.APIHandler.js new file mode 100644 index 000000000..b16c1ff72 --- /dev/null +++ b/modules/api/mw.echo.api.APIHandler.js @@ -0,0 +1,167 @@ +( function ( mw, $ ) { + /** + * Abstract notification API handler + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration object + * @cfg {number} [limit=25] The limit on how many notifications to fetch + * @cfg {string} [userLang=mw.config.get( 'wgUserLanguage' )] User language. Defaults + * to the default user language configuration settings. + */ + mw.echo.api.APIHandler = function MwEchoApiAPIHandler( config ) { + config = config || {}; + + this.fetchNotificationsPromise = {}; + this.apiErrorState = {}; + + this.limit = config.limit || 25; + this.userLang = config.userLang || mw.config.get( 'wgUserLanguage' ); + + this.api = null; + + // Map the logical type to the type + // that the API recognizes + this.normalizedType = { + message: 'message', + alert: 'alert', + all: 'message|alert' + }; + + // Parameters that are sent through + // to the 'fetch notification' promise + // per type + this.typeParams = { + message: {}, + alert: {}, + all: {} + }; + }; + + /* Setup */ + + OO.initClass( mw.echo.api.APIHandler ); + + /** + * Fetch notifications from the API. + * + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that resolves with an object containing the + * notification items + */ + mw.echo.api.APIHandler.prototype.fetchNotifications = null; + + /** + * Create a new fetchNotifications promise that queries the API and overrides + * the cached promise. + * + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that resolves with an object containing the + * notification items + */ + mw.echo.api.APIHandler.prototype.createNewFetchNotificationPromise = function ( type ) { + var me = this, + params = $.extend( { + action: 'query', + meta: 'notifications', + notsections: this.normalizedType[ type ], + notformat: 'model', + notlimit: this.limit, + notunreadfirst: 1, + notprop: 'index|list|count', + uselang: this.userLang + }, this.getTypeParams( type ) ); + + this.apiErrorState[ type ] = false; + this.fetchNotificationsPromise[ type ] = this.api.get( params ) + .fail( function () { + // Mark API error state + me.apiErrorState[ type ] = true; + } ); + + }; + + /** + * Update the seen timestamp + * + * @param {string} [type] Notification type 'message', 'alert' or 'all'. + * @return {jQuery.Promise} A promise that resolves with the seen timestamp + */ + mw.echo.api.APIHandler.prototype.updateSeenTime = null; + + /** + * Mark all notifications as read + * + * @param {string|string[]} type Notification type 'message', 'alert' or 'all'. + * @return {jQuery.Promise} A promise that resolves when all notifications + * are marked as read. + */ + mw.echo.api.APIHandler.prototype.markAllRead = null; + + /** + * Mark multiple notification items as read using specific IDs + * + * @abstract + * @param {string[]} itemIdArray An array of notification item IDs + * @return {jQuery.Promise} A promise that resolves when all given notifications + * are marked as read. + */ + mw.echo.api.APIHandler.prototype.markItemsRead = null; + + /** + * Update the read status of a notification item in the API + * + * @param {string} itemId Item id + * @return {jQuery.Promise} A promise that resolves when the notifications + * are marked as read. + */ + mw.echo.api.APIHandler.prototype.markItemRead = function ( itemId ) { + this.markItemsRead( [ itemId ] ); + }; + + /** + * Query the API for unread count of the notifications in this model + * + * @param {string} type Notification type 'message', 'alert' or 'all'. + * @return {jQuery.Promise} jQuery promise that's resolved when the unread count is fetched + * and the badge label is updated. + */ + mw.echo.api.APIHandler.prototype.fetchUnreadCount = null; + + /** + * Check whether the model has an API error state flagged + * + * @param {string} type Notification type, 'alert', 'message' or 'all' + * @return {boolean} The model is in API error state + */ + mw.echo.api.APIHandler.prototype.isFetchingErrorState = function ( type ) { + return !!this.apiErrorState[ type ]; + }; + + /** + * Return the fetch notifications promise + * + * @param {string} type Notification type, 'alert', 'message' or 'all' + * @return {jQuery.Promise} Promise that is resolved when notifications are + * fetched from the API. + */ + mw.echo.api.APIHandler.prototype.getFetchNotificationPromise = function ( type ) { + if ( !this.fetchNotificationsPromise[ type ] ) { + this.createNewFetchNotificationPromise( type ); + } + return this.fetchNotificationsPromise[ type ]; + }; + + /** + * Get the extra parameters for fetching notifications for a given + * notification type. + * + * @param {string} type Notification type + * @return {Object} Extra API parameters for fetch notifications + */ + mw.echo.api.APIHandler.prototype.getTypeParams = function ( type ) { + return this.typeParams[ type ]; + }; +} )( mediaWiki, jQuery ); diff --git a/modules/api/mw.echo.api.EchoApi.js b/modules/api/mw.echo.api.EchoApi.js new file mode 100644 index 000000000..35709c95f --- /dev/null +++ b/modules/api/mw.echo.api.EchoApi.js @@ -0,0 +1,157 @@ +( function ( mw ) { + /** + * A class defining Echo API instructions and network operations + * + * @constructor + */ + mw.echo.api.EchoApi = function MwEchoApiEchoApi() { + this.network = new mw.echo.api.NetworkHandler(); + + this.fetchingPromise = null; + }; + + OO.initClass( mw.echo.api.EchoApi ); + + /** + * Fetch notifications from the server based on type + * @param {string} types An array of notification types to fetch: 'alert', 'message', 'all' + * @param {string} [source="local"] The source from which to fetch the notifications + * @param {boolean} [isForced] Force a refresh on the fetch notifications promise + * @return {[type]} Promise that is resolved with all notifications for the + * requested types. + */ + mw.echo.api.EchoApi.prototype.fetchNotifications = function ( type, source, isForced ) { + var api = this; + + source = source || 'local'; + + return this.network.getApiHandler( source ).fetchNotifications( type, isForced ) + .then( function ( result ) { + var id, s, + sources = {}, + rawData = OO.getProp( result.query, 'notifications' ), + sourceDefinitions = rawData.sources; + + for ( source in sourceDefinitions ) { + api.network.addApiHandler( source, sourceDefinitions[ source ], true ); + } + + for ( id in rawData.list ) { + if ( rawData.list[ id ].type === 'external' ) { + // Define sources + sources = {}; + for ( s = 0; s < rawData.list[ id ].sources.length; s++ ) { + sources[ rawData.list[ id ].sources[ s ] ] = sourceDefinitions[ rawData.list[ id ].sources[ s ] ].title; + } + rawData.list[ id ].sources = sources; + } + + } + + return rawData; + } ); + }; + + /** + * Fetch notifications from several sources + * + * @param {string[]} sourceArray An array of sources to fetch from the group + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that resolves with an array of promises for + * fetchNotifications per each given source in the source array. + */ + mw.echo.api.EchoApi.prototype.fetchNotificationGroups = function ( sourceArray, type ) { + var i, promise, + promises = []; + + for ( i = 0; i < sourceArray.length; i++ ) { + promise = this.network.getApiHandler( sourceArray[ i ] ).fetchNotifications( type ); + promises.push( promise ); + } + + return mw.echo.api.NetworkHandler.static.waitForAllPromises( promises ); + }; + + /** + * Mark items as read in the API. + * + * @param {string[]} itemIds An array of item IDs to mark as read + * @param {string} source The source that these items belong to + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that is resolved when the operation + * is complete, with the number of unread notifications still remaining + * for that type in the given source + */ + mw.echo.api.EchoApi.prototype.markItemsRead = function ( itemIds, source, type ) { + return this.network.getApiHandler( source ).markItemsRead( itemIds, type ); + }; + + /** + * Mark all notifications for a given type as read in the given source. + * + * @param {[type]} source Notifications source + * @param {[type]} type Notifications type + * @return {jQuery.Promise} A promise that is resolved when the operation + * is complete, with the number of unread notifications still remaining + * for that type in the given source + */ + mw.echo.api.EchoApi.prototype.markAllRead = function ( source, type ) { + // FIXME: This specific method sends an operation + // to the API that marks all notifications of the given type as read regardless + // of whether they were actually seen by the user. + // We should consider removing the use of this method and, instead, + // using strictly the 'markItemsRead' by giving the API only the + // notifications that are available to the user. + return this.network.getApiHandler( source ).markAllRead( type ); + }; + + /** + * Fetch the number of unread notifications for the given type in the given + * source. + * + * @param {string} source Notifications source + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that is resolved with the number of + * unread notifications for the given type and source. + */ + mw.echo.api.EchoApi.prototype.fetchUnreadCount = function ( source, type ) { + return this.network.getApiHandler( source ).fetchUnreadCount( type ); + }; + + /** + * Update the seenTime property for the given type and source. + * + * @param {string} source Notification source + * @param {string} type Notification type + * @return {jQuery.Promise} A promise that is resolved when the operation is complete. + */ + mw.echo.api.EchoApi.prototype.updateSeenTime = function ( source, type ) { + return this.network.getApiHandler( source ).updateSeenTime( type ); + }; + + /** + * Check whether the API promise for fetch notification is in an error + * state for the given source and notification type. + * + * @param {string} source Notification source. + * @param {string} type Notification type + * @return {Boolean} The API response for fetching notification has + * resolved in an error state, or is rejected. + */ + mw.echo.api.EchoApi.prototype.isFetchingErrorState = function ( source, type ) { + return this.network.getApiHandler( source ).isFetchingErrorState( type ); + }; + + /** + * Get the fetch notifications promise active for the current source and type. + * + * @param {string} source Notification source. + * @param {string} type Notification type + * @return {jQuery.Promise} Promise that is resolved when notifications are + * fetched from the API. + */ + mw.echo.api.EchoApi.prototype.getFetchNotificationPromise = function ( source, type ) { + return this.network.getApiHandler( source ).getFetchNotificationPromise( type ); + }; + +} )( mediaWiki ); diff --git a/modules/api/mw.echo.api.ForeignAPIHandler.js b/modules/api/mw.echo.api.ForeignAPIHandler.js new file mode 100644 index 000000000..5abc83590 --- /dev/null +++ b/modules/api/mw.echo.api.ForeignAPIHandler.js @@ -0,0 +1,41 @@ +( function ( mw, $ ) { + /** + * Foreign notification API handler + * + * @class + * @extends mw.echo.api.LocalAPIHandler + * + * @constructor + * @param {string} apiUrl A url for the access point of the + * foreign API. + * @param {Object} [config] Configuration object + */ + mw.echo.api.ForeignAPIHandler = function MwEchoApiForeignAPIHandler( apiUrl, config ) { + config = config || {}; + + // Parent constructor + mw.echo.api.ForeignAPIHandler.parent.call( this, config ); + + // Add 'noforn' setting to foreign APIs + $.extend( true, this.typeParams, { + message: { + notnoforn: 1, + notfilter: '!read' + }, + alert: { + notnoforn: 1, + notfilter: '!read' + }, + all: { + notnoforn: 1, + notfilter: '!read' + } + } ); + + this.api = new mw.ForeignApi( apiUrl ); + }; + + /* Setup */ + + OO.inheritClass( mw.echo.api.ForeignAPIHandler, mw.echo.api.LocalAPIHandler ); +} )( mediaWiki, jQuery ); diff --git a/modules/api/mw.echo.api.LocalAPIHandler.js b/modules/api/mw.echo.api.LocalAPIHandler.js new file mode 100644 index 000000000..5fade41b2 --- /dev/null +++ b/modules/api/mw.echo.api.LocalAPIHandler.js @@ -0,0 +1,97 @@ +( function ( mw ) { + /** + * Notification API handler + * + * @class + * @extends mw.echo.dm.APIHandler + * + * @constructor + * @param {Object} [config] Configuration object + */ + mw.echo.api.LocalAPIHandler = function MwEchoApiLocalAPIHandler( config ) { + config = config || {}; + + // Parent constructor + mw.echo.api.LocalAPIHandler.parent.call( this, config ); + + this.api = new mw.Api( { ajax: { cache: false } } ); + }; + + /* Setup */ + + OO.inheritClass( mw.echo.api.LocalAPIHandler, mw.echo.api.APIHandler ); + + /** + * @inheritdoc + */ + mw.echo.api.LocalAPIHandler.prototype.fetchNotifications = function ( type, isForced ) { + if ( isForced || this.isFetchingErrorState( type ) ) { + // Force new promise + this.createNewFetchNotificationPromise( type ); + } + + return this.getFetchNotificationPromise( type ); + }; + + /** + * @inheritdoc + */ + mw.echo.api.LocalAPIHandler.prototype.updateSeenTime = function ( type ) { + return this.api.postWithToken( 'edit', { + action: 'echomarkseen', + type: this.normalizedType[ type ] + } ) + .then( function ( data ) { + return data.query.echomarkseen.timestamp; + } ); + }; + + /** + * @inheritdoc + */ + mw.echo.api.LocalAPIHandler.prototype.markAllRead = function ( type ) { + var data = { + action: 'echomarkread', + sections: this.normalizedType[ type ] + }; + + return this.api.postWithToken( 'edit', data ) + .then( function ( result ) { + return OO.getProp( result.query, 'echomarkread', type, 'rawcount' ) || 0; + } ); + }; + + /** + * @inheritdoc + */ + mw.echo.api.LocalAPIHandler.prototype.markItemsRead = function ( itemIdArray ) { + var data = { + action: 'echomarkread', + list: itemIdArray.join( '|' ) + }; + + return this.api.postWithToken( 'edit', data ); + }; + + /** + * @inheritdoc + */ + mw.echo.api.LocalAPIHandler.prototype.fetchUnreadCount = function ( type ) { + var normalizedType = this.normalizedType[ type ], + apiData = { + action: 'query', + meta: 'notifications', + notsections: normalizedType, + notgroupbysection: 1, + notmessageunreadfirst: 1, + notlimit: this.limit, + notprop: 'count', + uselang: this.userLang + }; + + return this.api.get( apiData ) + .then( function ( result ) { + return OO.getProp( result.query, 'notifications', normalizedType, 'rawcount' ) || 0; + } ); + }; +} )( mediaWiki ); diff --git a/modules/api/mw.echo.api.NetworkHandler.js b/modules/api/mw.echo.api.NetworkHandler.js new file mode 100644 index 000000000..b47cb0a88 --- /dev/null +++ b/modules/api/mw.echo.api.NetworkHandler.js @@ -0,0 +1,101 @@ +( function ( mw, $ ) { + /** + * Network handler for echo notifications. Manages multiple APIHandlers + * according to their sources. + * + * @class + * + * @constructor + */ + mw.echo.api.NetworkHandler = function MwEchoApiNetworkHandler() { + this.handlers = {}; + + // Add initial local handler + this.addApiHandler( 'local', {} ); + }; + + /* Setup */ + + OO.initClass( mw.echo.api.NetworkHandler ); + + /* Static methods */ + /** + * Wait for all promises to finish either with a resolve or reject and + * return them to the caller once they do. + * + * @param {jQuery.Promise[]} promiseArray An array of promises + * @return {jQuery.Promise} A promise that resolves when all the promises + * finished with some resolution or rejection. + */ + mw.echo.api.NetworkHandler.static.waitForAllPromises = function ( promiseArray ) { + var i, + promises = promiseArray.slice( 0 ), + counter = 0, + deferred = $.Deferred(), + countPromises = function () { + counter++; + if ( counter === promises.length ) { + deferred.resolve( promises ); + } + }; + + if ( !promiseArray.length ) { + deferred.resolve(); + } + + for ( i = 0; i < promises.length; i++ ) { + promises[ i ].always( countPromises ); + } + + return deferred.promise(); + }; + + /* Methods */ + + /** + * Get the API handler that matches the symbolic name + * + * @param {string} name Symbolic name of the API handler + * @return {mw.echo.dm.APIHandler|undefined} API handler, if exists + */ + mw.echo.api.NetworkHandler.prototype.getApiHandler = function ( name ) { + return this.handlers[ name ]; + }; + + /** + * Add an API handler + * + * @param {string} name Symbolic name + * @param {Object} config Configuration details + * @param {boolean} isForeign Is a foreign API + * @throws {Error} If no URL was given for a foreign API + */ + mw.echo.api.NetworkHandler.prototype.addApiHandler = function ( name, config, isForeign ) { + // This must be here so that it short-circuits the object construction below + if ( this.handlers[ name ] ) { + return; + } + + if ( isForeign ) { + if ( !config.url ) { + throw new Error( 'Foreign APIs must have a valid url.' ); + } + this.addCustomApiHandler( name, new mw.echo.api.ForeignAPIHandler( config.url, config ) ); + } else { + this.addCustomApiHandler( name, new mw.echo.api.LocalAPIHandler( config ) ); + } + }; + + /** + * Add a custom API handler by passing in an instance of an mw.echo.api.APIHandler subclass directly. + * + * @param {string} name Symbolic name + * @param {mw.echo.api.APIHandler} handler Handler object + */ + mw.echo.api.NetworkHandler.prototype.addCustomApiHandler = function ( name, handler ) { + if ( !this.handlers[ name ] ) { + this.handlers[ name ] = handler; + } + }; + +} )( mediaWiki, jQuery ); diff --git a/modules/api/mw.echo.api.js b/modules/api/mw.echo.api.js new file mode 100644 index 000000000..edb2f8060 --- /dev/null +++ b/modules/api/mw.echo.api.js @@ -0,0 +1,4 @@ +( function ( mw ) { + mw.echo = mw.echo || {}; + mw.echo.api = {}; +} )( mediaWiki ); diff --git a/modules/ext.echo.init.js b/modules/ext.echo.init.js index 2d60233ce..4531fc700 100644 --- a/modules/ext.echo.init.js +++ b/modules/ext.echo.init.js @@ -3,23 +3,9 @@ mw.echo = mw.echo || {}; - mw.echo.apiCallParams = { - action: 'query', - meta: 'notifications', - // We have to send the API 'groupbysection' otherwise - // the 'messageunreadfirst' doesn't do anything. - // TODO: Fix the API. - notgroupbysection: 1, - notmessageunreadfirst: 1, - notformat: 'model', - notlimit: 25, - notprop: 'index|list|count', - uselang: mw.config.get( 'wgUserLanguage' ) - }; - // Activate ooui $( document ).ready( function () { - var apiRequest, myWidget, + var myWidget, echoApi, $existingAlertLink = $( '#pt-notifications-alert a' ), $existingMessageLink = $( '#pt-notifications-message a' ), numAlerts = $existingAlertLink.text(), @@ -45,7 +31,8 @@ $( this ).addClass( 'mw-echo-notifications-badge-dimmed' ); // Fire the notification API requests - apiRequest = new mw.Api( { ajax: { cache: false } } ).get( $.extend( { notsections: myType }, mw.echo.apiCallParams ) ) + echoApi = new mw.echo.api.EchoApi(); + echoApi.fetchNotifications( myType ) .then( function ( data ) { mw.track( 'timing.MediaWiki.echo.overlay.api', mw.now() - time ); return data; @@ -61,11 +48,7 @@ // Load message button and popup if messages exist if ( $existingMessageLink.length ) { messageNotificationsModel = new mw.echo.dm.NotificationsModel( - // Create a network handler - new mw.echo.dm.NetworkHandler( { - type: 'message', - baseParams: mw.echo.apiCallParams - } ), + echoApi, { type: 'message' } @@ -93,11 +76,7 @@ } // Load alerts popup and button alertNotificationsModel = new mw.echo.dm.NotificationsModel( - // Create a network handler - new mw.echo.dm.NetworkHandler( { - type: 'alert', - baseParams: mw.echo.apiCallParams - } ), + echoApi, { type: 'alert' } @@ -121,11 +100,8 @@ // HACK: Now that the module loaded, show the popup myWidget = myType === 'alert' ? mw.echo.ui.alertWidget : mw.echo.ui.messageWidget; - myWidget.populateNotifications( apiRequest ).then( function () { + myWidget.once( 'finishLoading', function () { // Log timing after notifications are shown - // FIXME: The notifications might not be shown yet if the API - // request finished before the UI loads, in which case it will - // only be shown after the toggle( true ) call below. mw.track( 'timing.MediaWiki.echo.overlay', mw.now() - time ); } ); myWidget.popup.toggle( true ); diff --git a/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js b/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js index 943edd3cc..840ba12ae 100644 --- a/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js +++ b/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js @@ -179,12 +179,22 @@ * All notifications were marked as read */ + /** + * @event finishLoading + * Notifications have successfully finished being processed and are fully loaded + */ + /* Methods */ /** * Respond to badge button click */ mw.echo.ui.NotificationBadgeWidget.prototype.onBadgeButtonClick = function () { + if ( !this.popup.isVisible() ) { + // Force a new API request for notifications + this.notificationsModel.fetchNotifications( true ); + } + this.popup.toggle(); }; @@ -248,51 +258,17 @@ this.notificationsModel.markAllRead(); }; - /** - * Populate notifications from the API. - * - * @param {jQuery.Promise} [fetchingApiRequest] An existing promise for fetching - * notifications from the API. This allows us to start fetching notifications - * externally. - * @return {jQuery.Promise} Promise that is resolved when the notifications populate - */ - mw.echo.ui.NotificationBadgeWidget.prototype.populateNotifications = function ( fetchingApiRequest ) { - var widget = this; - - // The model retrieves the ongoing promise or returns the existing one that it - // has. When the promise is completed successfuly, it nullifies itself so we can - // request for it to be rebuilt and the request to the API resent. - // However, in the case of an API failure, the promise does not nullify itself. - // In that case we also want the model to rebuild the request, so in this condition - // we must check both cases. - if ( !this.notificationsModel.isFetchingNotifications() || this.notificationsModel.isFetchingErrorState() ) { - this.pushPending(); - this.markAllReadButton.toggle( false ); - return this.notificationsModel.fetchNotifications( fetchingApiRequest ) - .then( function ( idArray ) { - // Clip again - widget.popup.clip(); - - // Log impressions - mw.echo.logger.logNotificationImpressions( this.type, idArray, mw.echo.Logger.static.context.popup ); - } ) - .always( function () { - // Pop pending - widget.popPending(); - // Nullify the promise; let the user fetch again - widget.fetchNotificationsPromise = null; - } ); - } else { - return this.notificationsModel.getFetchNotificationPromise(); - } - }; - /** * Extend the response to button click so we can also update the notification list. + * @fires finishLoading */ mw.echo.ui.NotificationBadgeWidget.prototype.onPopupToggle = function ( isVisible ) { var widget = this; + if ( this.promiseRunning ) { + return; + } + if ( !isVisible ) { // If the popup is closing, remove "initiallyUnseen" and leave this.notificationsWidget.resetNotificationItems(); @@ -316,14 +292,20 @@ this.popup.$clippable.css( 'height', '1px' ); this.popup.clip(); } + + this.pushPending(); + this.markAllReadButton.toggle( false ); + this.promiseRunning = true; // Always populate on popup open. The model and widget should handle // the case where the promise is already underway. - this.populateNotifications() + this.notificationsModel.fetchNotifications() .then( function () { var i, items = widget.notificationsWidget.getItems(); if ( widget.popup.isVisible() ) { + widget.popup.clip(); + // Update seen time widget.notificationsModel.updateSeenTime(); // Mark notifications as 'read' if markReadWhenSeen is set to true @@ -332,6 +314,8 @@ } // Log impressions + // TODO: Only log the impressions of notifications that + // are actually visible for ( i = 0; i < items.length; i++ ) { if ( items[ i ].getModel ) { mw.echo.logger.logInteraction( @@ -343,6 +327,13 @@ } } } + + widget.emit( 'finishLoading' ); + } ) + .always( function () { + // Pop pending + widget.popPending(); + widget.promiseRunning = false; } ); this.hasRunFirstTime = true; }; diff --git a/modules/viewmodel/handlers/mw.echo.dm.APIHandler.js b/modules/viewmodel/handlers/mw.echo.dm.APIHandler.js deleted file mode 100644 index ce03002b9..000000000 --- a/modules/viewmodel/handlers/mw.echo.dm.APIHandler.js +++ /dev/null @@ -1,130 +0,0 @@ -( function ( mw ) { - /** - * Abstract notification API handler - * - * @abstract - * @class - * @mixins OO.EventEmitter - * - * @constructor - * @param {Object} [config] Configuration object - * @cfg {Object} [baseParams] The base parameters for all API calls - * done by the API handler - * @cfg {number} [limit=25] The limit on how many notifications to fetch - * @cfg {string} [type='alert'] Notification type - * @cfg {string} [userLang='en'] User language - */ - mw.echo.dm.APIHandler = function MwEchoDmAPIHandler( config ) { - config = config || {}; - - // Mixin constructor - OO.EventEmitter.call( this ); - - this.fetchNotificationsPromise = null; - this.apiErrorState = false; - - this.type = config.type || 'alert'; - this.limit = config.limit || 25; - this.userLang = config.userLang || 'en'; - this.baseParams = config.baseParams || {}; - - this.api = null; - }; - - /* Setup */ - - OO.initClass( mw.echo.dm.APIHandler ); - OO.mixinClass( mw.echo.dm.APIHandler, OO.EventEmitter ); - - /** - * Fetch notifications from the API. - * - * @param {jQuery.Promise} [apiPromise] An existing promise querying the API for notifications. - * This allows us to send an API request foreign to the DM and have the model - * handle the operation as if it asked for the request itself, updating all that - * needs to be updated and emitting all proper events. - * @return {jQuery.Promise} A promise that resolves with an object containing the - * notification items - */ - mw.echo.dm.APIHandler.prototype.fetchNotifications = null; - - /** - * Update the seen timestamp - * - * @param {string|string[]} [type] Notification type 'message', 'alert' or - * an array of both. - * @return {jQuery.Promise} A promise that resolves with the seen timestamp - */ - mw.echo.dm.APIHandler.prototype.updateSeenTime = null; - - /** - * Mark all notifications as read - * - * @return {jQuery.Promise} A promise that resolves when all notifications - * are marked as read. - */ - mw.echo.dm.APIHandler.prototype.markAllRead = null; - - /** - * Mark multiple notification items as read using specific IDs - * - * @abstract - * @param {string[]} itemIdArray An array of notification item IDs - * @return {jQuery.Promise} A promise that resolves when all given notifications - * are marked as read. - */ - mw.echo.dm.APIHandler.prototype.markMultipleItemsRead = null; - - /** - * Update the read status of a notification item in the API - * - * @param {string} itemId Item id - * @return {jQuery.Promise} A promise that resolves when the notifications - * are marked as read. - */ - mw.echo.dm.APIHandler.prototype.markItemRead = null; - - /** - * Query the API for unread count of the notifications in this model - * - * @return {jQuery.Promise} jQuery promise that's resolved when the unread count is fetched - * and the badge label is updated. - */ - mw.echo.dm.APIHandler.prototype.fetchUnreadCount = null; - - /** - * Check whether the model is fetching notifications from the API - * - * @return {boolean} The model is in the process of fetching from the API - */ - mw.echo.dm.APIHandler.prototype.isFetchingNotifications = function () { - return !!this.fetchNotificationsPromise; - }; - - /** - * Check whether the model has an API error state flagged - * - * @return {boolean} The model is in API error state - */ - mw.echo.dm.APIHandler.prototype.isFetchingErrorState = function () { - return !!this.apiErrorState; - }; - - /** - * Return the fetch notifications promise - * @return {jQuery.Promise} Promise that is resolved when notifications are - * fetched from the API. - */ - mw.echo.dm.APIHandler.prototype.getFetchNotificationPromise = function () { - return this.fetchNotificationsPromise; - }; - - /** - * Get the base params associated with this API handler - * - * @return {Object} Base API params - */ - mw.echo.dm.APIHandler.prototype.getBaseParams = function () { - return this.baseParams; - }; -} )( mediaWiki ); diff --git a/modules/viewmodel/handlers/mw.echo.dm.ForeignAPIHandler.js b/modules/viewmodel/handlers/mw.echo.dm.ForeignAPIHandler.js deleted file mode 100644 index 3f0de6538..000000000 --- a/modules/viewmodel/handlers/mw.echo.dm.ForeignAPIHandler.js +++ /dev/null @@ -1,25 +0,0 @@ -( function ( mw ) { - /** - * Foreign notification API handler - * - * @class - * @extends mw.echo.dm.LocalAPIHandler - * - * @constructor - * @param {string} apiUrl A url for the access point of the - * foreign API. - * @param {Object} [config] Configuration object - */ - mw.echo.dm.ForeignAPIHandler = function MwEchoDmForeignAPIHandler( apiUrl, config ) { - config = config || {}; - - // Parent constructor - mw.echo.dm.ForeignAPIHandler.parent.call( this, config ); - - this.api = new mw.ForeignApi( apiUrl ); - }; - - /* Setup */ - - OO.inheritClass( mw.echo.dm.ForeignAPIHandler, mw.echo.dm.LocalAPIHandler ); -} )( mediaWiki ); diff --git a/modules/viewmodel/handlers/mw.echo.dm.LocalAPIHandler.js b/modules/viewmodel/handlers/mw.echo.dm.LocalAPIHandler.js deleted file mode 100644 index cb24b269f..000000000 --- a/modules/viewmodel/handlers/mw.echo.dm.LocalAPIHandler.js +++ /dev/null @@ -1,133 +0,0 @@ -( function ( mw, $ ) { - /** - * Notification API handler - * - * @class - * @extends mw.echo.dm.APIHandler - * - * @constructor - * @param {Object} [config] Configuration object - */ - mw.echo.dm.LocalAPIHandler = function MwEchoDmLocalAPIHandler( config ) { - config = config || {}; - - // Parent constructor - mw.echo.dm.LocalAPIHandler.parent.call( this, config ); - - this.api = new mw.Api( { ajax: { cache: false } } ); - }; - - /* Setup */ - - OO.inheritClass( mw.echo.dm.LocalAPIHandler, mw.echo.dm.APIHandler ); - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.fetchNotifications = function ( apiPromise ) { - var helper = this, - params = $.extend( { notsections: this.type }, this.getBaseParams() ); - - if ( !this.fetchNotificationsPromise || this.isFetchingErrorState() ) { - this.apiErrorState = false; - this.fetchNotificationsPromise = ( apiPromise || this.api.get( params ) ) - .fail( function () { - // Mark API error state - helper.apiErrorState = true; - } ) - .always( function () { - helper.fetchNotificationsPromise = null; - } ); - } - - return this.fetchNotificationsPromise; - }; - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.updateSeenTime = function ( type ) { - type = type || this.type; - - return this.api.postWithToken( 'edit', { - action: 'echomarkseen', - // TODO: The API should also work with piped types like - // getting notification lists - type: $.isArray( type ) ? 'all' : type - } ) - .then( function ( data ) { - return data.query.echomarkseen.timestamp; - } ); - }; - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.markAllRead = function () { - var model = this, - data = { - action: 'echomarkread', - uselang: this.userLang, - sections: $.isArray( this.type ) ? this.type.join( '|' ) : this.type - }; - - return this.api.postWithToken( 'edit', data ) - .then( function ( result ) { - return OO.getProp( result.query, 'echomarkread', model.type, 'rawcount' ) || 0; - } ); - }; - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.markMultipleItemsRead = function ( itemIdArray ) { - var model = this, - data = { - action: 'echomarkread', - uselang: this.userLang, - list: itemIdArray.join( '|' ) - }; - - return this.api.postWithToken( 'edit', data ) - .then( function ( result ) { - return OO.getProp( result.query, 'echomarkread', model.type, 'rawcount' ) || 0; - } ); - }; - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.markItemRead = function ( itemId ) { - var model = this, - data = { - action: 'echomarkread', - uselang: this.userLang, - list: itemId - }; - - return this.api.postWithToken( 'edit', data ) - .then( function ( result ) { - return OO.getProp( result.query, 'echomarkread', model.type, 'rawcount' ) || 0; - } ); - }; - - /** - * @inheritdoc - */ - mw.echo.dm.LocalAPIHandler.prototype.fetchUnreadCount = function () { - var apiData = { - action: 'query', - meta: 'notifications', - notsections: $.isArray( this.type ) ? this.type.join( '|' ) : this.type, - notmessageunreadfirst: 1, - notlimit: this.limit, - notprop: 'index|count', - uselang: this.userLang - }; - - return this.api.get( apiData ) - .then( function ( result ) { - return OO.getProp( result.query, 'notifications', 'rawcount' ) || 0; - } ); - }; -} )( mediaWiki, jQuery ); diff --git a/modules/viewmodel/handlers/mw.echo.dm.NetworkHandler.js b/modules/viewmodel/handlers/mw.echo.dm.NetworkHandler.js deleted file mode 100644 index 3ea913f52..000000000 --- a/modules/viewmodel/handlers/mw.echo.dm.NetworkHandler.js +++ /dev/null @@ -1,143 +0,0 @@ -( function ( mw, $ ) { - /** - * Network handler for echo notifications. Manages multiple APIHandlers - * according to their sources. - * - * @class - * @mixins OO.EventEmitter - * - * @constructor - * @param {Object} [config] Configuration object - * @cfg {string} [type="alert"] Notification type - * @cfg {Object} [baseParams] The base params to send to the - * APIs with every fetch notifications process. - */ - mw.echo.dm.NetworkHandler = function MwEchoDmNetworkHandler( config ) { - config = config || {}; - - // Mixin constructor - OO.EventEmitter.call( this ); - - this.type = config.type || 'alert'; - this.baseParams = config.baseParams || {}; - this.handlers = {}; - - // Add initial local handler - this.addApiHandler( 'local', {} ); - }; - - /* Setup */ - - OO.initClass( mw.echo.dm.NetworkHandler ); - OO.mixinClass( mw.echo.dm.NetworkHandler, OO.EventEmitter ); - - /* Static methods */ - /** - * Wait for all promises to finish either with a resolve or reject and - * return them to the caller once they do. - * - * @param {jQuery.Promise[]} promiseArray An array of promises - * @return {jQuery.Promise} A promise that resolves when all the promises - * finished with some resolution or rejection. - */ - mw.echo.dm.NetworkHandler.static.waitForAllPromises = function ( promiseArray ) { - var i, - promises = promiseArray.slice( 0 ), - counter = 0, - deferred = $.Deferred(), - countPromises = function () { - counter++; - if ( counter === promises.length ) { - deferred.resolve( promises ); - } - }; - - if ( !promiseArray.length ) { - deferred.resolve(); - } - - for ( i = 0; i < promises.length; i++ ) { - promises[ i ].always( countPromises ); - } - - return deferred.promise(); - }; - - /* Methods */ - - /** - * Fetch notifications from several sources - * - * @param {string[]} sourceArray Sources - * @return {jQuery.Promise} A promise that resolves with an array of promises for - * fetchNotifications per each given source in the source array. - */ - mw.echo.dm.NetworkHandler.prototype.fetchNotificationGroups = function ( sourceArray ) { - var i, promise, - promises = []; - - for ( i = 0; i < sourceArray.length; i++ ) { - promise = this.getApiHandler( sourceArray[ i ] ).fetchNotifications(); - promises.push( promise ); - } - - return this.constructor.static.waitForAllPromises( promises ); - }; - - /** - * Get the API handler that matches the symbolic name - * - * @param {string} name Symbolic name of the API handler - * @return {mw.echo.dm.APIHandler|undefined} API handler, if exists - */ - mw.echo.dm.NetworkHandler.prototype.getApiHandler = function ( name ) { - return this.handlers[ name ]; - }; - - /** - * Add an API handler - * - * @param {string} name Symbolic name - * @param {Object} config Configuration details - * @param {boolean} isForeign Is a foreign API - * @throws {Error} If no URL was given for a foreign API - */ - mw.echo.dm.NetworkHandler.prototype.addApiHandler = function ( name, config, isForeign ) { - var apiConfig; - - if ( !this.handlers[ name ] ) { - apiConfig = $.extend( true, {}, { baseParams: this.baseParams, type: this.getType() }, config ); - - if ( isForeign ) { - if ( !config.url ) { - throw new Error( 'Foreign APIs must have a valid url.' ); - } - this.addCustomApiHandler( name, new mw.echo.dm.ForeignAPIHandler( config.url, apiConfig ) ); - } else { - this.addCustomApiHandler( name, new mw.echo.dm.LocalAPIHandler( apiConfig ) ); - } - } - }; - - /** - * Add a custom API handler by passing in an instance of an mw.echo.dm.APIHandler subclass directly. - * - * @param {string} name Symbolic name - * @param {mw.echo.dm.APIHandler} handler Handler object - */ - mw.echo.dm.NetworkHandler.prototype.addCustomApiHandler = function ( name, handler ) { - if ( !this.handlers[ name ] ) { - this.handlers[ name ] = handler; - } - }; - - /** - * Get the type of notifications this network handler is associated with - * - * @return {string} Notification type - */ - mw.echo.dm.NetworkHandler.prototype.getType = function () { - return this.type; - }; - -} )( mediaWiki, jQuery ); diff --git a/modules/viewmodel/mw.echo.dm.NotificationGroupItem.js b/modules/viewmodel/mw.echo.dm.NotificationGroupItem.js index ca958d941..8607bef8d 100644 --- a/modules/viewmodel/mw.echo.dm.NotificationGroupItem.js +++ b/modules/viewmodel/mw.echo.dm.NotificationGroupItem.js @@ -7,7 +7,7 @@ * @mixins mw.echo.dm.SortedList * * @constructor - * @param {mw.echo.dm.NetworkHandler} networkHandler The network handler + * @param {mw.echo.api.EchoApi} api Echo API * @param {Object[]} sources An array of objects defining the sources * of its item's sub-items. * @param {number} id Notification id, @@ -17,7 +17,7 @@ * @cfg {number} [count=0] The number of items this group contains. This is used for both the * 'expand' label and also to potentially update the badge counters for local bundles. */ - mw.echo.dm.NotificationGroupItem = function mwEchoDmNotificationGroupItem( networkHandler, sources, id, config ) { + mw.echo.dm.NotificationGroupItem = function mwEchoDmNotificationGroupItem( api, sources, id, config ) { var source, item, items = []; @@ -49,18 +49,15 @@ this.removeReadNotifications = !!config.removeReadNotifications; this.sources = sources; - this.networkHandler = networkHandler; + this.api = api; this.notifModels = {}; this.anticipatedCount = config.count || 0; // Create notification models for each source for ( source in this.sources ) { - // Add foreign API handler - this.networkHandler.addApiHandler( source, { url: this.sources[ source ].url, baseParams: { notnoforn: 1, notfilter: '!read' } }, true ); - // Create a notifications model item = new mw.echo.dm.NotificationsModel( - this.networkHandler, + this.api, { type: this.getType(), source: source, @@ -124,19 +121,19 @@ fetchPromises = [], sourceKeys = Object.keys( this.sources ); - return this.networkHandler.fetchNotificationGroups( sourceKeys ) - .then( function ( promises ) { + return this.api.fetchNotificationGroups( sourceKeys, this.getType() ) + .then( function () { var i; for ( i = 0; i < sourceKeys.length; i++ ) { notifModel = model.getItemById( sourceKeys[ i ] ); if ( notifModel ) { - fetchPromises.push( notifModel.fetchNotifications( promises[ i ] ) ); + fetchPromises.push( notifModel.fetchNotifications() ); } } // Wait for all fetch processes to finish before we resolve this promise - return mw.echo.dm.NetworkHandler.static.waitForAllPromises( fetchPromises ); + return mw.echo.api.NetworkHandler.static.waitForAllPromises( fetchPromises ); } ) .then( function () { model.anticipatedCount = null; @@ -180,9 +177,9 @@ .then( ( function ( model ) { return function ( idArray ) { // Mark sub items as read in the UI - model.markAllRead(); + model.markAllRead( model.getSource(), model.getType() ); // Mark all existing items as read in the API - model.markExistingItemsReadInApi( idArray ); + model.markItemsReadInApi( idArray ); }; } )( notifModels[ i ] ) ); } diff --git a/modules/viewmodel/mw.echo.dm.NotificationsModel.js b/modules/viewmodel/mw.echo.dm.NotificationsModel.js index d9d8567a9..49a7ee8da 100644 --- a/modules/viewmodel/mw.echo.dm.NotificationsModel.js +++ b/modules/viewmodel/mw.echo.dm.NotificationsModel.js @@ -6,7 +6,7 @@ * @mixins OO.EventEmitter * * @constructor - * @param {mw.echo.dm.NetworkHandler} networkHandler Network handler + * @param {mw.echo.api.EchoApi} api Network handler * @param {Object} [config] Configuration object * @cfg {string} [id] Model id, used to refer to the model specifically. * Falls back to the model's unique source @@ -23,7 +23,7 @@ * means the model will only contain unread notifications. This is useful for * cross-wiki bundled notifications. */ - mw.echo.dm.NotificationsModel = function MwEchoDmNotificationsModel( networkHandler, config ) { + mw.echo.dm.NotificationsModel = function MwEchoDmNotificationsModel( api, config ) { config = config || {}; // Mixin constructor @@ -43,7 +43,7 @@ this.removeReadNotifications = !!config.removeReadNotifications; this.foreign = !!config.foreign; - this.networkHandler = networkHandler; + this.api = api; this.seenTime = mw.config.get( 'wgEchoSeenTime' ) || {}; @@ -186,7 +186,7 @@ // because the API takes a single request to mark all notifications // as read, and we don't need to send multiple individual requests. if ( !this.markingAllAsRead ) { - this.markItemReadInApi( id ); + this.markItemsReadInApi( id ); } if ( this.removeReadNotifications ) { // Remove this notification from the model @@ -341,7 +341,7 @@ // Only update seenTime in the API locally if ( !this.isForeign() ) { - promise = this.getApi().updateSeenTime( type ); + promise = this.api.updateSeenTime( this.getSource(), type ); } else { promise = $.Deferred().resolve(); } @@ -363,8 +363,8 @@ length = items.length; // Skip if this is an automatic "mark as read" and this model is - // external - if ( this.external && this.autoMarkReadInProcess ) { + // foreign + if ( this.foreign && this.autoMarkReadInProcess ) { return $.Deferred().resolve( 0 ).promise(); } @@ -382,7 +382,7 @@ this.markingAllAsRead = true; for ( i = 0, len = items.length; i < len; i++ ) { - // Skip items that are external if we are in automatic 'mark all as read' + // Skip items that are foreign if we are in automatic 'mark all as read' if ( !items[ i ].isForeign() || !this.autoMarkReadInProcess ) { items[ i ].toggleRead( true ); items[ i ].toggleSeen( true ); @@ -391,7 +391,7 @@ } this.markingAllAsRead = false; - return this.getApi().markAllRead(); + return this.api.markAllRead( this.getSource(), this.getType() ); }; /** @@ -408,40 +408,27 @@ var model = this; this.autoMarkReadInProcess = true; - this.markAllRead() + this.markAllRead( this.getSource(), this.getType() ) .then( function () { model.autoMarkReadInProcess = false; } ); }; /** - * Update the read status of a notification item in the API + * Update the read status of a notification item, or a list of items, in the API * - * @param {string} itemId Item id + * @param {string|string[]} itemIds Item id or an array of item Ids * @return {jQuery.Promise} A promise that resolves when the notifications * were marked as read. */ - mw.echo.dm.NotificationsModel.prototype.markItemReadInApi = function ( itemId ) { + mw.echo.dm.NotificationsModel.prototype.markItemsReadInApi = function ( itemIds ) { if ( !this.unreadNotifications.getItemCount() ) { return $.Deferred().resolve( 0 ).promise(); } - return this.getApi().markItemRead( itemId ); - }; + itemIds = $.isArray( itemIds ) ? itemIds : [ itemIds ]; - /** - * Update the read status in the API only for the existing items in this model. - * If an item id array is given, those items will be updated. Otherwise, all items - * in the model are updated. - * - * @param {string[]} [itemIds] Array of item ids - * @return {jQuery.Promise} A promise that resolves when the notifications - * were marked as read. - */ - mw.echo.dm.NotificationsModel.prototype.markExistingItemsReadInApi = function ( itemIds ) { - itemIds = itemIds || this.getAllItemIds(); - - return this.getApi().markMultipleItemsRead( itemIds ); + return this.api.markItemsRead( itemIds, this.getSource(), this.getType() ); }; /** @@ -464,100 +451,95 @@ /** * Fetch notifications from the API and update the notifications list. * - * @param {jQuery.Promise} An existing promise querying the API for notifications. - * This allows us to send an API request external to the DM and have the model - * handle the operation as if it asked for the request itself, updating all that - * needs to be updated and emitting all proper events. + * @param {boolean} [isForced] Force a renewed fetching promise. If set to false, the + * model will request the stored/cached fetching promise from the API. A 'true' value + * will force the API to re-request that information from the server and update the + * notifications. * @return {jQuery.Promise} A promise that resolves with an array of notification IDs */ - mw.echo.dm.NotificationsModel.prototype.fetchNotifications = function ( apiPromise ) { + mw.echo.dm.NotificationsModel.prototype.fetchNotifications = function ( isForced ) { var model = this; // Rebuild the notifications promise either when it is null or when // it exists in a failed state - return ( apiPromise || this.getApi().fetchNotifications() ) + return this.api.fetchNotifications( this.getType(), this.getSource(), !!isForced ) .then( // Success - function ( result ) { - var notifData, id, t, tlen, s, - notificationModel, types, content, + function ( data ) { + var notifData, id, s, + notificationModel, content, newNotifData = {}, sources = {}, optionItems = [], idArray = [], - data = OO.getProp( result.query, 'notifications', model.type ) || { index: [] }, - sourceDefinitions = OO.getProp( result.query, 'notifications', 'sources' ) || {}; + sourceDefinitions = data.sources || {}; - types = $.isArray( model.type ) ? model.type : [ model.type ]; + data = data || {}; - for ( t = 0, tlen = types.length; t < tlen; t++ ) { - data = OO.getProp( result.query, 'notifications', types[ t ] ) || { list: [] }; - for ( id in data.list ) { - notifData = data.list[ id ]; - content = notifData[ '*' ] || {}; + for ( id in data.list ) { + notifData = data.list[ id ]; + content = notifData[ '*' ] || {}; - if ( model.getItemById( id ) ) { - // Skip if we already have the item - // TODO: Instead of skipping, we should consider repopulating - // the item, in case there are any changes that would result - // in repositioning/resorting in the future. - continue; - } - - // Collect common data - newNotifData = { - read: !!notifData.read, - seen: !!notifData.read || notifData.timestamp.mw <= model.getSeenTime(), - timestamp: notifData.timestamp.utcmw, - category: notifData.category, - content: { - header: content.header, - body: content.body - }, - iconURL: content.iconUrl, - iconType: content.icon, - type: model.getType(), - foreign: model.isForeign(), - source: model.getSource(), - primaryUrl: OO.getProp( content.links, 'primary', 'url' ), - secondaryUrls: OO.getProp( content.links, 'secondary' ) || [] - }; - - if ( notifData.type === 'foreign' ) { - // Define sources - sources = {}; - for ( s = 0; s < notifData.sources.length; s++ ) { - sources[ notifData.sources[ s ] ] = sourceDefinitions[ notifData.sources[ s ] ]; - } - - // Create model - notificationModel = new mw.echo.dm.NotificationGroupItem( - model.networkHandler, - sources, - id, - $.extend( true, {}, newNotifData, { - // This should probably be separated by bundled - // type. Some types don't have read messages, but - // some do - removeReadNotifications: true, - // Override the foreign flag to 'true' for cross-wiki - // notifications. - // For bundles that are not foreign (like regular - // bundles of notifications) this flag should be false - foreign: true, - count: notifData.count - } ) - ); - } else { - notificationModel = new mw.echo.dm.NotificationItem( - id, - newNotifData - ); - } - - idArray.push( notifData.id ); - optionItems.push( notificationModel ); + if ( model.getItemById( id ) ) { + // Skip if we already have the item + // TODO: Instead of skipping, we should consider repopulating + // the item, in case there are any changes that would result + // in repositioning/resorting in the future. + continue; } + + // Collect common data + newNotifData = { + read: !!notifData.read, + seen: !!notifData.read || notifData.timestamp.mw <= model.getSeenTime(), + timestamp: notifData.timestamp.utcmw, + category: notifData.category, + content: { + header: content.header, + body: content.body + }, + iconURL: content.iconUrl, + iconType: content.icon, + type: model.getType(), + foreign: model.isForeign(), + source: model.getSource(), + primaryUrl: OO.getProp( content.links, 'primary', 'url' ), + secondaryUrls: OO.getProp( content.links, 'secondary' ) || [] + }; + + if ( notifData.type === 'foreign' ) { + // Define sources + for ( s = 0; s < notifData.sources.length; s++ ) { + sources[ notifData.sources[ s ] ] = sourceDefinitions[ notifData.sources[ s ] ]; + } + + // Create model + notificationModel = new mw.echo.dm.NotificationGroupItem( + model.api, + sources, + id, + $.extend( true, {}, newNotifData, { + // This should probably be separated by bundled + // type. Some types don't have read messages, but + // some do + removeReadNotifications: true, + // Override the foreign flag to 'true' for cross-wiki + // notifications. + // For bundles that are not foreign (like regular + // bundles of notifications) this flag should be false + foreign: true, + count: notifData.count + } ) + ); + } else { + notificationModel = new mw.echo.dm.NotificationItem( + id, + newNotifData + ); + } + + idArray.push( notifData.id ); + optionItems.push( notificationModel ); } // Add to the model @@ -641,19 +623,9 @@ * Query the API for unread count of the notifications in this model * * @return {jQuery.Promise} jQuery promise that's resolved when the unread count is fetched - * and the badge label is updated. */ mw.echo.dm.NotificationsModel.prototype.fetchUnreadCountFromApi = function () { - return this.getApi().fetchUnreadCount(); - }; - - /** - * Check whether the model is fetching notifications from the API - * - * @return {boolean} The model is in the process of fetching from the API - */ - mw.echo.dm.NotificationsModel.prototype.isFetchingNotifications = function () { - return this.getApi().isFetchingNotifications(); + return this.api.fetchUnreadCount( this.getSource(), this.getType() ); }; /** @@ -662,7 +634,7 @@ * @return {boolean} The model is in api error state */ mw.echo.dm.NotificationsModel.prototype.isFetchingErrorState = function () { - return this.getApi().isFetchingErrorState(); + return this.api.isFetchingErrorState( this.getSource(), this.getType() ); }; /** @@ -671,7 +643,7 @@ * fetched from the API. */ mw.echo.dm.NotificationsModel.prototype.getFetchNotificationPromise = function () { - return this.getApi().getFetchNotificationPromise(); + return this.api.getFetchNotificationPromise( this.getSource(), this.getType() ); }; /** @@ -687,24 +659,6 @@ return items[ 0 ] && items[ 0 ].getTimestamp(); }; - /** - * Get the API handler associated with this model's source - * - * @return {mw.echo.dm.APIHandler} API handler - */ - mw.echo.dm.NotificationsModel.prototype.getApi = function () { - return this.networkHandler.getApiHandler( this.source ); - }; - - /** - * Get the network handler - * - * @return {mw.echo.dm.NetworkHandler} Network handler - */ - mw.echo.dm.NotificationsModel.prototype.getNetworkHandler = function () { - return this.networkHandler; - }; - /** * Get the source this model is associated with * diff --git a/tests/qunit/viewmodel/test_mw.echo.dm.NotificationsModel.js b/tests/qunit/viewmodel/test_mw.echo.dm.NotificationsModel.js index 3a4217469..f344b1a45 100644 --- a/tests/qunit/viewmodel/test_mw.echo.dm.NotificationsModel.js +++ b/tests/qunit/viewmodel/test_mw.echo.dm.NotificationsModel.js @@ -22,12 +22,17 @@ TestApiHandler.parent.call( this ); } /* Setup */ - OO.inheritClass( TestApiHandler, mw.echo.dm.APIHandler ); + OO.inheritClass( TestApiHandler, mw.echo.api.APIHandler ); // Override api call - TestApiHandler.prototype.markItemRead = function () { + TestApiHandler.prototype.markItemsRead = function () { return $.Deferred().resolve( 0 ); }; + // Create an Echo API instance + var echoApi = new mw.echo.api.EchoApi(); + // HACK: Reach into the EchoAPI to create a test handler + echoApi.network.addCustomApiHandler( 'test', new TestApiHandler() ); + QUnit.test( 'Adding notifications', function ( assert ) { var initialItems = [ new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ), @@ -88,15 +93,13 @@ cases.forEach( function ( test ) { var r, runCase, runItem, - networkHandler = new mw.echo.dm.NetworkHandler(), - model = new mw.echo.dm.NotificationsModel( networkHandler, { + model = new mw.echo.dm.NotificationsModel( echoApi, { type: 'alert', source: 'test', limit: 25, userLang: 'en' } ); - networkHandler.addCustomApiHandler( 'test', new TestApiHandler() ); model.addItems( test.items ); if ( test.add ) { @@ -115,8 +118,7 @@ } ); QUnit.test( 'Deleting notifications', 2, function ( assert ) { - var networkHandler = new mw.echo.dm.NetworkHandler(), - model = new mw.echo.dm.NotificationsModel( networkHandler, { + var model = new mw.echo.dm.NotificationsModel( echoApi, { type: 'alert', source: 'test', limit: 25, @@ -135,7 +137,6 @@ new mw.echo.dm.NotificationItem( 10, { content: '10', timestamp: '20150828172900' } ) ]; - networkHandler.addCustomApiHandler( 'test', new TestApiHandler() ); // Add initial notifications model.addItems( items ); @@ -151,7 +152,6 @@ QUnit.test( 'Clearing notifications', function ( assert ) { var i, ilen, model, actual, test, - networkHandler = new mw.echo.dm.NetworkHandler(), cases = [ { prepare: [ @@ -182,15 +182,13 @@ assert.expect( cases.length ); for ( i = 0, ilen = cases.length; i < ilen; i++ ) { - model = new mw.echo.dm.NotificationsModel( networkHandler, { + model = new mw.echo.dm.NotificationsModel( echoApi, { type: 'alert', source: 'test', limit: 25, userLang: 'en' } ); - networkHandler.addCustomApiHandler( 'test', new TestApiHandler() ); - test = cases[ i ]; // Run preparation @@ -204,7 +202,6 @@ QUnit.test( 'Changing read/unread status', function ( assert ) { var i, - networkHandler = new mw.echo.dm.NetworkHandler(), initialItems = [ new mw.echo.dm.NotificationItem( 0, { timestamp: '20150828173000', read: false } ), new mw.echo.dm.NotificationItem( 1, { timestamp: '20150828173100', read: false } ), @@ -231,14 +228,13 @@ QUnit.expect( cases.length ); cases.forEach( function ( test ) { - var model = new mw.echo.dm.NotificationsModel( networkHandler, { + var model = new mw.echo.dm.NotificationsModel( echoApi, { type: 'alert', source: 'test', limit: 25, userLang: 'en' } ); - networkHandler.addCustomApiHandler( 'test', new TestApiHandler() ); model.addItems( test.items ); if ( test.markRead ) {