mediawiki-extensions-Echo/modules/controller/mw.echo.Controller.js
Moriel Schottlender 3101bfc537 Separate model's symbolic name and model's source
This should have been done from the beginning; the model manager
pulls models by their symbolic names. So far, we've used the source
for that, but that assumes that two modules always have different
sources, and that is absolutely not necessarily the case.

For example, internal local bundles will each be a model, but have
the same ('local') source. They should still be differentiated in
the manager by their names, but the source should state clearly that
it is local.

For this, the models now have "getName" method and the name is
created separately from their source. Items also preserve a
reference to their parent's symbolic name so they can provide
that for items that require the controller to manipulate a specific
model.

Change-Id: I8c39d5d28383d11fb330addce21e07d5c424da6f
2016-06-09 16:20:57 -07:00

556 lines
17 KiB
JavaScript

( function ( mw, $ ) {
/**
* Controller for Echo notifications
*
* @param {mw.echo.api.EchoApi} echoApi Echo API
* @param {mw.echo.dm.ModelManager} manager Model manager
* @param {Object} [config] Configuration
*/
mw.echo.Controller = function MwEchoController( echoApi, manager, config ) {
config = config || {};
this.api = echoApi;
this.manager = manager;
};
/* Initialization */
OO.initClass( mw.echo.Controller );
/**
* Update a filter value
*
* @param {string} filter Filter name
* @param {string} value Filter value
*/
mw.echo.Controller.prototype.setFilter = function ( filter, value ) {
if ( filter === 'readState' ) {
this.manager.getFiltersModel().setReadState( value );
}
};
/**
* 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 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 ) {
var controller = this,
pagination = this.manager.getPaginationModel(),
filters = this.manager.getFiltersModel(),
// When we have multiple possible sources, this will change
currentSource = 'local',
continueValue = pagination.getPageContinue( page || pagination.getCurrPageIndex() );
pagination.setItemsPerPage( this.api.getLimit() );
return this.api.fetchNotifications(
this.manager.getTypeString(),
'local',
true,
continueValue,
filters.getReadState()
)
.then( function ( data ) {
var i, notifData, newNotifData, date, itemModel, symbolicName, count,
dateItemIds = {},
dateItems = {},
models = {};
data = data || { list: [] };
// Go over the data
for ( i = 0; i < data.list.length; i++ ) {
notifData = data.list[ i ];
// Collect common data
newNotifData = controller.createNotificationData( notifData );
if ( notifData.type !== 'foreign' ) {
date = newNotifData.timestamp.substring( 0, 8 );
newNotifData.modelName = 'local_' + date;
newNotifData.source = currentSource;
// Single notifications
itemModel = new mw.echo.dm.NotificationItem(
notifData.id,
newNotifData
);
dateItems[ date ] = dateItems[ date ] || [];
dateItems[ date ].push( itemModel );
dateItemIds[ date ] = dateItemIds[ date ] || [];
dateItemIds[ date ].push( notifData.id );
}
}
// Fill in the models
for ( date in dateItems ) {
symbolicName = 'local_' + date;
// Set up model
models[ symbolicName ] = new mw.echo.dm.NotificationsList( {
type: controller.manager.getTypes(),
name: symbolicName,
source: currentSource,
title: date,
timestamp: date
} );
models[ symbolicName ].setItems( dateItems[ date ] );
}
// Register local sources
controller.api.registerLocalSources( Object.keys( models ) );
// Update the manager
controller.manager.setNotificationModels( models );
// Update the pagination
count = controller.manager.getAllNotificationCount();
if ( count < pagination.getItemsPerPage() ) {
pagination.setLastPageItemCount(
controller.manager.getAllNotificationCount()
);
}
pagination.setNextPageContinue( data.continue );
return dateItemIds;
} );
};
/**
* 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 ) {
var controller = this,
// Create a new local list model
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 )
.then(
// Success
function ( data ) {
var i, notifData, content, newNotifData,
foreignListModel, source, itemModel,
allModels = { local: localListModel };
data = data || { list: [] };
// Go over the data
for ( i = 0; i < data.list.length; i++ ) {
notifData = data.list[ i ];
content = notifData[ '*' ] || {};
// Collect common data
newNotifData = controller.createNotificationData( notifData );
if ( notifData.type === 'foreign' ) {
// x-wiki notification multi-group
// We need to request a new list model
newNotifData.name = 'xwiki';
allModels.xwiki = foreignListModel = new mw.echo.dm.CrossWikiNotificationItem( notifData.id, newNotifData );
foreignListModel.setForeign( true );
// Register foreign sources
controller.api.registerForeignSources( notifData.sources );
// Add the lists according to the sources
for ( source in notifData.sources ) {
foreignListModel.getList().addGroup(
source,
notifData.sources[ source ]
);
}
} else {
// Local single notifications
itemModel = new mw.echo.dm.NotificationItem(
notifData.id,
newNotifData
);
idArray.push( notifData.id );
localItems.push( itemModel );
}
}
// Refresh local items
localListModel.addItems( localItems );
// Update the controller
controller.manager.setNotificationModels( allModels );
return idArray;
},
// Failure
function ( errCode, errObj ) {
if ( !controller.manager.getNotificationModel( 'local' ) ) {
// Update the controller
controller.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 ) {
var content = apiData[ '*' ] || {};
return {
// type: controller.type,
foreign: false,
source: 'local',
count: apiData.count,
read: !!apiData.read,
seen: !!apiData.read || apiData.timestamp.mw <= this.manager.getSeenTime(),
timestamp: apiData.timestamp.utcmw,
category: apiData.category,
content: {
header: content.header,
body: content.body
},
iconURL: content.iconUrl,
iconType: content.icon,
primaryUrl: OO.getProp( content.links, 'primary', 'url' ),
secondaryUrls: OO.getProp( content.links, 'secondary' ) || [],
bundledIds: content.bundledIds
};
};
/**
* 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
* @return {jQuery.Promise} Promise that is resolved when all items
* were marked as read.
*/
mw.echo.Controller.prototype.markEntireListModelRead = function ( modelName ) {
var i, items, item,
itemIds = [],
model = this.manager.getNotificationModel( modelName || 'local' );
if ( !model ) {
// Model doesn't exist
return $.Deferred().reject();
}
items = model.getItems();
for ( i = 0; i < items.length; i++ ) {
item = items[ i ];
if ( !item.isRead() ) {
itemIds = itemIds.concat( item.getAllIds() );
}
}
return this.markItemsRead( itemIds, model.getName(), true );
};
/**
* 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 () {
var controller = this,
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() )
.then(
function ( groupList ) {
var i, notifData, listModel, group, groupItems,
items = [];
for ( group in groupList ) {
listModel = xwikiModel.getItemBySource( group );
groupItems = groupList[ group ];
items = [];
for ( i = 0; i < groupItems.length; i++ ) {
notifData = controller.createNotificationData( groupItems[ i ] );
items.push(
new mw.echo.dm.NotificationItem( groupItems[ i ].id, $.extend( notifData, {
modelName: group,
source: group,
bundled: true,
foreign: true
} ) )
);
}
// Add items
listModel.setItems( items );
}
},
function ( errCode, errObj ) {
return {
errCode: errCode,
errInfo: errCode === 'http' ?
mw.msg( 'echo-api-failure-cross-wiki' ) :
OO.getProp( errObj, 'error', 'info' )
};
}
);
};
/**
* Mark a single item as read. The item can be a local item or an item
* that is part of an xwiki foreign list.
*
* @param {number} itemId Item ID
* @param {string} modelName The name of the model that these items belong to
* @param {boolean} [isForeign=false] The model is foreign, inside a cross-wiki
* bundle.
* @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.markSingleItemRead = function ( itemId, modelName, isForeign, isRead ) {
if ( isForeign ) {
return this.markCrossWikiItemsRead( [ itemId ], modelName, isRead );
}
return this.markItemsRead( [ itemId ], modelName, isRead );
};
/**
* 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 ) {
var allIds = [],
model = this.manager.getNotificationModel( modelName );
itemIds = Array.isArray( itemIds ) ? itemIds : [ itemIds ];
// Default to true
isRead = isRead === undefined ? true : isRead;
model.findByIds( itemIds ).forEach( function ( notification ) {
allIds = allIds.concat( notification.getAllIds() );
notification.toggleRead( isRead );
} );
this.manager.getUnreadCounter().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} modelName The symbolic 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, modelName ) {
var sourceModel,
notifs = [],
xwikiModel = this.manager.getNotificationModel( 'xwiki' );
if ( !xwikiModel ) {
return $.Deferred().reject().promise();
}
this.manager.getUnreadCounter().estimateChange( -itemIds.length );
itemIds = Array.isArray( itemIds ) ? itemIds : [ itemIds ];
sourceModel = xwikiModel.getList().getGroupBySource( modelName );
notifs = sourceModel.findByIds( itemIds );
sourceModel.discardItems( notifs );
return this.api.markItemsRead( itemIds, modelName, 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 () {
var controller = this,
xwikiModel = this.manager.getNotificationModel( 'xwiki' );
if ( !xwikiModel ) {
return $.Deferred().reject().promise();
}
return this.api.fetchNotificationGroups( xwikiModel.getSourceNames(), this.manager.getTypeString() )
.then( function ( groupList ) {
var i, listModel, group, groupItems,
promises = [],
idArray = [],
itemCounter = 0;
for ( group in groupList ) {
listModel = xwikiModel.getItemBySource( group );
groupItems = groupList[ group ];
idArray = [];
for ( i = 0; i < groupItems.length; i++ ) {
idArray = idArray.concat( groupItems[ i ].id ).concat( groupItems[ i ][ '*' ].bundledIds || [] );
}
itemCounter += idArray.length;
// Mark items as read in the API
promises.push(
controller.markCrossWikiItemsRead( idArray, listModel.getSource() )
);
}
// Synchronously remove this model from the widget
controller.removeCrossWikiItem();
controller.manager.counter.estimateChange( -itemCounter );
return mw.echo.api.NetworkHandler.static.waitForAllPromises( promises );
} );
};
/**
* 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 local seenTime for the given types
*
* @return {jQuery.Promise} A promise that is resolved when the
* seenTime was updated for all given types.
*/
mw.echo.Controller.prototype.updateLocalSeenTime = function () {
var controller = this,
promises = [],
types = this.manager.getTypes();
types.forEach( function ( type ) {
promises.push( controller.api.updateSeenTime( 'local', type ) );
} );
return mw.echo.api.NetworkHandler.static.waitForAllPromises( promises )
.then( function ( promises ) {
var i;
// Update the seen time object
// The promises are in the same order as the types
// so we can use the same iterator for both
for ( i = 0; i < promises.length; i++ ) {
promises[ i ].done( controller.manager.updateSeenTimeObject.bind( controller.manager, types[ i ] ) );
}
} )
.then( controller.manager.setLocalModelItemsSeen.bind( controller.manager ) );
};
/**
* 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();
};
} )( mediaWiki, jQuery );