( function () { /* global moment:false */ /** * Controller for Echo notifications * * @param {mw.echo.api.EchoApi} echoApi Echo API * @param {mw.echo.dm.ModelManager} manager Model manager */ mw.echo.Controller = function MwEchoController( echoApi, manager ) { this.api = echoApi; this.manager = manager; }; /* Initialization */ OO.initClass( mw.echo.Controller ); /** * Update a filter value. * The method accepts a filter name and as many arguments * as needed. * * @param {string} filter Filter name */ mw.echo.Controller.prototype.setFilter = function ( filter ) { const filtersModel = this.manager.getFiltersModel(), values = Array.prototype.slice.call( arguments ); values.shift(); if ( filter === 'readState' ) { filtersModel.setReadState( values[ 0 ] ); } else if ( filter === 'sourcePage' ) { filtersModel.setCurrentSourcePage( values[ 0 ], values[ 1 ] ); this.manager.getLocalCounter().setSource( filtersModel.getSourcePagesModel().getCurrentSource() ); } // Reset pagination this.manager.getPaginationModel().reset(); }; /** * Fetch the next page by date * * @return {jQuery.Promise} A promise that resolves with an object where the keys are * days and the items are item IDs. */ mw.echo.Controller.prototype.fetchNextPageByDate = function () { this.manager.getPaginationModel().forwards(); return this.fetchLocalNotificationsByDate(); }; /** * Fetch the previous page by date * * @return {jQuery.Promise} A promise that resolves with an object where the keys are * days and the items are item IDs. */ mw.echo.Controller.prototype.fetchPrevPageByDate = function () { this.manager.getPaginationModel().backwards(); return this.fetchLocalNotificationsByDate(); }; /** * Fetch the first page by date * * @return {jQuery.Promise} A promise that resolves with an object where the keys are * days and the items are item IDs. */ mw.echo.Controller.prototype.fetchFirstPageByDate = function () { this.manager.getPaginationModel().setCurrPageIndex( 0 ); return this.fetchLocalNotificationsByDate(); }; /** * Fetch unread pages in all wikis and create foreign API sources * as needed. * * @return {jQuery.Promise} A promise that resolves when the page filter * model is updated with the unread notification count per page per wiki */ mw.echo.Controller.prototype.fetchUnreadPagesByWiki = function () { const filterModel = this.manager.getFiltersModel(), sourcePageModel = filterModel.getSourcePagesModel(); return this.api.fetchUnreadNotificationPages() .then( ( data ) => { const result = {}, foreignSources = {}; for ( const source in data ) { if ( source !== mw.config.get( 'wgWikiID' ) ) { // Collect sources for API foreignSources[ source ] = data[ source ].source; } result[ source === mw.config.get( 'wgWikiID' ) ? 'local' : source ] = data[ source ]; } // Register the foreign sources in the API this.api.registerForeignSources( foreignSources, false ); // Register pages sourcePageModel.setAllSources( result ); } ); }; /** * Fetch notifications from the local API and sort them by date. * This method ignores cross-wiki notifications and bundles. * * @param {number} [page] Page number. If not given, it defaults to the current * page. * @return {jQuery.Promise} A promise that resolves with an object where the keys are * days and the items are item IDs. */ mw.echo.Controller.prototype.fetchLocalNotificationsByDate = function ( page ) { const pagination = this.manager.getPaginationModel(), filters = this.manager.getFiltersModel(), currentSource = filters.getSourcePagesModel().getCurrentSource(), continueValue = pagination.getPageContinue( page || pagination.getCurrPageIndex() ); pagination.setItemsPerPage( this.api.getLimit() ); return this.api.fetchFilteredNotifications( this.manager.getTypeString(), currentSource, { continue: continueValue, readState: filters.getReadState(), titles: filters.getSourcePagesModel().getGroupedPagesForCurrentTitle() } ) .then( ( data ) => { const dateItemIds = {}, dateItems = {}, models = {}; data = data || { list: [] }; // Go over the data for ( let i = 0; i < data.list.length; i++ ) { const notifData = data.list[ i ]; // Set source's seenTime // TODO: This query brings up mixed alert and message notifications. // Regularly, each of those will have a different seenTime that is // calculated for each badge, but for this page, both are fetched. // For the moment, we are picking the max seenTime from // either alert or notice and updating both, since the page gives // us a mixed view which will update both seenTime to be the same // anyways. const maxSeenTime = data.seenTime.alert < data.seenTime.notice ? data.seenTime.notice : data.seenTime.alert; this.manager.getSeenTimeModel().setSeenTime( maxSeenTime ); // Collect common data const newNotifData = this.createNotificationData( notifData ); if ( notifData.type !== 'foreign' ) { const localizedDate = moment.utc( newNotifData.timestamp ).local().format( 'YYYYMMDD' ); newNotifData.modelName = 'local_' + localizedDate; newNotifData.source = currentSource; // Single notifications const itemModel = new mw.echo.dm.NotificationItem( notifData.id, newNotifData ); dateItems[ localizedDate ] = dateItems[ localizedDate ] || []; dateItems[ localizedDate ].push( itemModel ); dateItemIds[ localizedDate ] = dateItemIds[ localizedDate ] || []; dateItemIds[ localizedDate ].push( notifData.id ); } } // Fill in the models for ( const date in dateItems ) { const symbolicName = 'local_' + date; // Set up model models[ symbolicName ] = new mw.echo.dm.NotificationsList( { type: this.manager.getTypes(), name: symbolicName, source: currentSource, title: date, timestamp: date, sortingCallback: function ( a, b ) { // Reverse sorting. In the special page we want the // items sorted only by timestamp, regardless of // read/unread state if ( b.getTimestamp() < a.getTimestamp() ) { return -1; } else if ( b.getTimestamp() > a.getTimestamp() ) { return 1; } // Fallback on IDs return b.getId() - a.getId(); } } ); models[ symbolicName ].setItems( dateItems[ date ] ); } // Register local sources this.api.registerLocalSources( Object.keys( models ) ); // Update the manager this.manager.setNotificationModels( models ); // Update the pagination pagination.setNextPageContinue( data.continue ); // Update the local counter this.manager.getLocalCounter().update(); return dateItemIds; } ) .then( null, ( errCode, errObj ) => ( { errCode: errCode, errInfo: OO.getProp( errObj, 'error', 'info' ) } ) ); }; /** * Fetch notifications from the local API and update the notifications list. * * @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.Controller.prototype.fetchLocalNotifications = function ( isForced ) { // Create a new local list model const localListModel = new mw.echo.dm.NotificationsList( { type: this.manager.getTypes() } ), localItems = [], idArray = []; this.manager.counter.update(); // Fetch the notifications from the database // Initially, we're going to have to split the operation // between local notifications and x-wiki notifications // until the backend gives us the x-wiki notifications as // part of the original response. return this.api.fetchNotifications( this.manager.getTypeString(), 'local', !!isForced, { unreadFirst: true, bundle: true } /* filters */ ) .then( // Success ( data ) => { const allModels = { local: localListModel }, createBundledNotification = ( modelName, rawBundledNotifData ) => { const bundleNotifData = this.createNotificationData( rawBundledNotifData ); bundleNotifData.bundled = true; bundleNotifData.modelName = modelName; return new mw.echo.dm.NotificationItem( rawBundledNotifData.id, bundleNotifData ); }; data = data || { list: [] }; // Go over the data for ( let i = 0; i < data.list.length; i++ ) { const notifData = data.list[ i ]; // Set source's seenTime this.manager.getSeenTimeModel().setSeenTime( this.getTypes().length > 1 ? ( data.seenTime.alert < data.seenTime.notice ? data.seenTime.notice : data.seenTime.alert ) : data.seenTime[ this.getTypeString() ] ); // Collect common data const newNotifData = this.createNotificationData( notifData ); if ( notifData.type === 'foreign' ) { // x-wiki notification multi-group // We need to request a new list model newNotifData.name = 'xwiki'; const foreignListModel = allModels.xwiki = new mw.echo.dm.CrossWikiNotificationItem( notifData.id, newNotifData ); foreignListModel.setForeign( true ); // Register foreign sources this.api.registerForeignSources( notifData.sources, true ); // Add the lists according to the sources for ( const source in notifData.sources ) { foreignListModel.getList().addGroup( source, notifData.sources[ source ] ); } } else if ( Array.isArray( newNotifData.bundledNotifications ) ) { // local bundle newNotifData.modelName = 'bundle_' + notifData.id; const itemModel = new mw.echo.dm.BundleNotificationItem( notifData.id, newNotifData.bundledNotifications.map( createBundledNotification.bind( null, newNotifData.modelName ) ), newNotifData ); allModels[ newNotifData.modelName ] = itemModel; } else { // Local single notifications const itemModel = new mw.echo.dm.NotificationItem( notifData.id, newNotifData ); idArray.push( notifData.id ); localItems.push( itemModel ); if ( newNotifData.bundledNotifications ) { // This means that bundledNotifications is truthy // but is not an array. We should log this in the console mw.log.warn( 'newNotifData.bundledNotifications is expected to be an array,' + 'but instead received "' + typeof newNotifData.bundledNotifications + '"' ); } } } // Refresh local items localListModel.addItems( localItems ); // Update the this this.manager.setNotificationModels( allModels ); return idArray; }, // Failure ( errCode, errObj ) => { if ( !this.manager.getNotificationModel( 'local' ) ) { // Update the this this.manager.setNotificationModels( { local: localListModel } ); } return { errCode: errCode, errInfo: OO.getProp( errObj, 'error', 'info' ) }; } ); }; /** * Create notification data config object for notification items from the * given API data. * * @param {Object} apiData API data * @return {Object} Notification config data object */ mw.echo.Controller.prototype.createNotificationData = function ( apiData ) { const content = apiData[ '*' ] || {}; let utcTimestamp; if ( apiData.timestamp.utciso8601 ) { utcTimestamp = apiData.timestamp.utciso8601; } else { // Temporary until c05133283af0486e08c9a97a468bc075e238f2d2 rolls out to the // whole WMF cluster const utcIsoMoment = moment.utc( apiData.timestamp.utcunix * 1000 ); utcTimestamp = utcIsoMoment.format( 'YYYY-MM-DD[T]HH:mm:ss[Z]' ); } return { type: apiData.section, foreign: false, source: 'local', count: apiData.count, read: !!apiData.read, seen: ( !!apiData.read || utcTimestamp <= this.manager.getSeenTime() ), timestamp: utcTimestamp, category: apiData.category, content: { header: content.header, compactHeader: content.compactHeader, body: content.body }, iconURL: content.iconUrl, iconType: content.icon, primaryUrl: OO.getProp( content.links, 'primary', 'url' ), secondaryUrls: OO.getProp( content.links, 'secondary' ) || [], bundledIds: apiData.bundledIds, bundledNotifications: apiData.bundledNotifications }; }; /** * Mark all items within a given list model as read. * * NOTE: This method is strictly for list models, and will not work for * group list models. To mark items as read in the xwiki model, whether * it is pre-populated or not, please see #markEntireCrossWikiItemAsRead * * @param {string} [modelName] Symbolic name for the model * @param {boolean} [isRead=true] * @return {jQuery.Promise} Promise that is resolved when all items * were marked as read. */ mw.echo.Controller.prototype.markEntireListModelRead = function ( modelName, isRead ) { const itemIds = [], model = this.manager.getNotificationModel( modelName || 'local' ); if ( !model ) { // Model doesn't exist return $.Deferred().reject(); } // Default to true isRead = isRead === undefined ? true : isRead; const items = model.getItems(); for ( let i = 0; i < items.length; i++ ) { const item = items[ i ]; if ( item.isRead() !== isRead ) { itemIds.push( item.getId() ); } } return this.markItemsRead( itemIds, model.getName(), isRead ); }; /** * Mark all notifications of a certain source as read, even those that * are not currently displayed. * * @param {string} [source] Notification source. If not given, the currently * selected source is used. * @return {jQuery.Promise} A promise that is resolved after * all notifications for the given source were marked as read */ mw.echo.Controller.prototype.markAllRead = function ( source ) { const itemIds = [], readState = this.manager.getFiltersModel().getReadState(), localCounter = this.manager.getLocalCounter(); source = source || this.manager.getFiltersModel().getSourcePagesModel().getCurrentSource(); this.manager.getNotificationsBySource( source ).forEach( ( notification ) => { if ( !notification.isRead() ) { itemIds.push( ...notification.getAllIds() ); notification.toggleRead( true ); if ( readState === 'unread' ) { // Remove the items if we are in 'unread' filter state const model = this.manager.getNotificationModel( notification.getModelName() ); model.discardItems( notification ); } } } ); // Update pagination count this.manager.updateCurrentPageItemCount(); localCounter.estimateChange( -itemIds.length ); return this.api.markAllRead( source, this.getTypes() ).then( this.refreshUnreadCount.bind( this ) ).then( localCounter.update.bind( localCounter, true ) ); }; /** * Mark all local notifications as read * * @return {jQuery.Promise} Promise that is resolved when all * local notifications have been marked as read. */ mw.echo.Controller.prototype.markLocalNotificationsRead = function () { const readState = this.manager.getFiltersModel().getReadState(), modelItems = {}; this.manager.getLocalNotifications().forEach( ( notification ) => { if ( !notification.isRead() ) { notification.toggleRead( true ); const modelName = notification.getModelName(); modelItems[ modelName ] = modelItems[ modelName ] || []; modelItems[ modelName ].push( notification ); } } ); // Remove the items if we are in 'unread' filter state if ( readState === 'unread' ) { for ( const name in modelItems ) { const model = this.manager.getNotificationModel( name ); model.discardItems( modelItems[ name ] ); } } // Update pagination count this.manager.updateCurrentPageItemCount(); this.manager.getLocalCounter().setCount( 0, false ); return this.api.markAllRead( 'local', this.getTypeString() ).then( this.refreshUnreadCount.bind( this ) ); }; /** * Fetch notifications from the cross-wiki sources. * * @return {jQuery.Promise} Promise that is resolved when all items * from the cross-wiki sources are populated into the cross-wiki * model. */ mw.echo.Controller.prototype.fetchCrossWikiNotifications = function () { const xwikiModel = this.manager.getNotificationModel( 'xwiki' ); if ( !xwikiModel ) { // There is no xwiki notifications model, so we can't // fetch into it return $.Deferred().reject().promise(); } return this.api.fetchNotificationGroups( xwikiModel.getSourceNames(), this.manager.getTypeString(), true ) .then( ( groupList ) => { for ( const group in groupList ) { const listModel = xwikiModel.getItemBySource( group ); const groupItems = groupList[ group ]; const items = []; for ( let i = 0; i < groupItems.length; i++ ) { const notifData = this.createNotificationData( groupItems[ i ] ); items.push( new mw.echo.dm.NotificationItem( groupItems[ i ].id, Object.assign( notifData, { modelName: 'xwiki', source: group, bundled: true, foreign: true } ) ) ); } // Add items listModel.setItems( items ); } }, ( errCode, errObj ) => ( { errCode: errCode, errInfo: errCode === 'http' ? mw.msg( 'echo-api-failure-cross-wiki' ) : OO.getProp( errObj, 'error', 'info' ) } ) ); }; /** * Mark local items as read in the API. * * @param {string[]|string} itemIds An array of item IDs, or a single item ID, to mark as read * @param {string} modelName The name of the model that these items belong to * @param {boolean} [isRead=true] The read state of the item; true for marking the * item as read, false for marking the item as unread * @return {jQuery.Promise} A promise that is resolved when the operation * is complete, with the number of unread notifications still remaining * for the set type of this controller, in the given source. */ mw.echo.Controller.prototype.markItemsRead = function ( itemIds, modelName, isRead ) { const model = this.manager.getNotificationModel( modelName ), readState = this.manager.getFiltersModel().getReadState(), allIds = []; itemIds = Array.isArray( itemIds ) ? itemIds : [ itemIds ]; // Default to true isRead = isRead === undefined ? true : isRead; const items = model.findByIds( itemIds ); // If we are only looking at specific read state, // then we need to make sure the items are removed // from the visible list, because they no longer // correspond with the chosen state filter if ( readState === 'read' && !isRead ) { model.discardItems( items ); } else if ( readState === 'unread' && isRead ) { model.discardItems( items ); // TODO: We should also find a way to update the pagination // here properly. Do we pull more items from the next page // when items are cleared? Do we set some threshhold for // removed items where if it is reached, we update the list // to reflect the new pagination? etc. } items.forEach( ( notification ) => { allIds.push( ...notification.getAllIds() ); if ( readState === 'all' ) { notification.toggleRead( isRead ); } } ); // Update pagination count this.manager.updateCurrentPageItemCount(); this.manager.getUnreadCounter().estimateChange( isRead ? -allIds.length : allIds.length ); if ( modelName !== 'xwiki' ) { // For the local counter, we should only estimate the change if the items // are not cross-wiki this.manager.getLocalCounter().estimateChange( isRead ? -allIds.length : allIds.length ); } return this.api.markItemsRead( allIds, model.getSource(), isRead ).then( this.refreshUnreadCount.bind( this ) ); }; /** * Mark cross-wiki items as read in the API. * * @param {string[]|string} itemIds An array of item IDs, or a single item ID, to mark as read * @param {string} source The name for the source list that these items belong to * @return {jQuery.Promise} A promise that is resolved when the operation * is complete, with the number of unread notifications still remaining * for the set type of this controller, in the given source. */ mw.echo.Controller.prototype.markCrossWikiItemsRead = function ( itemIds, source ) { const allIds = [], xwikiModel = this.manager.getNotificationModel( 'xwiki' ); if ( !xwikiModel ) { return $.Deferred().reject().promise(); } itemIds = Array.isArray( itemIds ) ? itemIds : [ itemIds ]; const sourceModel = xwikiModel.getList().getGroupByName( source ); const notifs = sourceModel.findByIds( itemIds ); sourceModel.discardItems( notifs ); // Update pagination count this.manager.updateCurrentPageItemCount(); notifs.forEach( ( notif ) => { allIds.push( ...notif.getAllIds() ); } ); this.manager.getUnreadCounter().estimateChange( -allIds.length ); return this.api.markItemsRead( allIds, source, true ) .then( this.refreshUnreadCount.bind( this ) ); }; /** * Mark all cross-wiki notifications from all sources as read * * @return {jQuery.Promise} Promise that is resolved when all notifications * are marked as read */ mw.echo.Controller.prototype.markEntireCrossWikiItemAsRead = function () { const xwikiModel = this.manager.getNotificationModel( 'xwiki' ); if ( !xwikiModel ) { return $.Deferred().reject().promise(); } this.manager.getUnreadCounter().estimateChange( -xwikiModel.getCount() ); return this.api.fetchNotificationGroups( xwikiModel.getSourceNames(), this.manager.getTypeString() ) .then( ( groupList ) => { const promises = []; for ( const group in groupList ) { const listModel = xwikiModel.getItemBySource( group ); const groupItems = groupList[ group ]; const idArray = []; for ( let i = 0; i < groupItems.length; i++ ) { idArray.push( groupItems[ i ].id, ...( groupItems[ i ].bundledIds || [] ) ); } // Mark items as read in the API promises.push( this.api.markItemsRead( idArray, listModel.getName(), true ) ); } // Synchronously remove this model from the widget this.removeCrossWikiItem(); return mw.echo.api.NetworkHandler.static.waitForAllPromises( promises ).then( this.refreshUnreadCount.bind( this ) ); } ); }; /** * Remove the entire cross-wiki model. */ mw.echo.Controller.prototype.removeCrossWikiItem = function () { this.manager.removeNotificationModel( 'xwiki' ); }; /** * Refresh the unread notifications counter * * @return {jQuery.Promise} A promise that is resolved when the counter * is updated with the actual unread count from the server. */ mw.echo.Controller.prototype.refreshUnreadCount = function () { return this.manager.getUnreadCounter().update(); }; /** * Update global seenTime for all sources * * @return {jQuery.Promise} A promise that is resolved when the * seenTime was updated for all the controller's types and sources. */ mw.echo.Controller.prototype.updateSeenTime = function () { return this.api.updateSeenTime( this.getTypes(), // For consistency, use current source, though seenTime // will be updated globally this.manager.getFiltersModel().getSourcePagesModel().getCurrentSource() ) .then( ( time ) => { this.manager.getSeenTimeModel().setSeenTime( time ); } ); }; /** * Perform a dynamic action * * @param {Object} data Action data for the network * @param {string} [source] Requested source to query. Defaults to currently * selected source. * @return {jQuery.Promise} jQuery promise that resolves when the action is done */ mw.echo.Controller.prototype.performDynamicAction = function ( data, source ) { source = source || this.manager.getFiltersModel().getSourcePagesModel().getCurrentSource(); return this.api.queryAPI( data, source ); }; /** * Get the types associated with the controller and model * * @return {string[]} Notification types */ mw.echo.Controller.prototype.getTypes = function () { return this.manager.getTypes(); }; /** * Return a string representation of the notification type. * It could be 'alert', 'message' or, if both are set, 'all' * * @return {string} String representation of notifications type */ mw.echo.Controller.prototype.getTypeString = function () { return this.manager.getTypeString(); }; }() );