diff --git a/modules/controller/mw.echo.Controller.js b/modules/controller/mw.echo.Controller.js
index 41f6a0681..adbd5ed16 100644
--- a/modules/controller/mw.echo.Controller.js
+++ b/modules/controller/mw.echo.Controller.js
@@ -202,7 +202,7 @@
items.push(
new mw.echo.dm.NotificationItem( groupItems[ i ].id, $.extend( notifData, {
source: group,
- bundle: true,
+ bundled: true,
foreign: true
} ) )
);
@@ -293,7 +293,7 @@
sourceModel = xwikiModel.getList().getGroupBySource( modelSource );
notifs = sourceModel.findByIds( itemIds );
- sourceModel.removeItems( notifs );
+ sourceModel.discardItems( notifs );
return this.api.markItemsRead( itemIds, modelSource, true )
.then( this.refreshUnreadCount.bind( this ) );
diff --git a/modules/model/mw.echo.dm.NotificationItem.js b/modules/model/mw.echo.dm.NotificationItem.js
index 9cfa7d9f5..06ab66e03 100644
--- a/modules/model/mw.echo.dm.NotificationItem.js
+++ b/modules/model/mw.echo.dm.NotificationItem.js
@@ -23,6 +23,7 @@
* @cfg {string} [timestamp] Notification timestamp in Mediawiki timestamp format
* @cfg {string} [primaryUrl] Notification primary link in raw url format
* @cfg {boolean} [foreign=false] This notification is from a foreign source
+ * @cfg {boolean} [bundled=false] This notification is part of a bundle
* @cfg {string} [source] The source this notification is coming from, if it is foreign
* @cfg {Object[]} [secondaryUrls] An array of objects defining the secondary URLs
* for this notification. The secondary URLs are expected to have this structure:
@@ -58,6 +59,7 @@
this.category = config.category || '';
this.type = config.type || 'message';
this.foreign = !!config.foreign;
+ this.bundled = !!config.bundled;
this.source = config.source || '';
this.iconType = config.iconType;
this.iconURL = config.iconURL;
@@ -157,6 +159,15 @@
return this.foreign;
};
+ /**
+ * Check whether this notification item is part of a bundle
+ *
+ * @return {boolean} Notification item is part of a bundle
+ */
+ mw.echo.dm.NotificationItem.prototype.isBundled = function () {
+ return this.bundled;
+ };
+
/**
* Set this notification item as foreign
*
diff --git a/modules/model/mw.echo.dm.NotificationsList.js b/modules/model/mw.echo.dm.NotificationsList.js
index faa0f1fd6..3fcc2935c 100644
--- a/modules/model/mw.echo.dm.NotificationsList.js
+++ b/modules/model/mw.echo.dm.NotificationsList.js
@@ -72,7 +72,7 @@
/**
* Set the items in this list
*
- * @param {mw.echo.dm.NotificationItem} items Items to insert into the list
+ * @param {mw.echo.dm.NotificationItem[]} items Items to insert into the list
* @fires update
*/
mw.echo.dm.NotificationsList.prototype.setItems = function ( items ) {
@@ -81,6 +81,22 @@
this.emit( 'update', this.getItems() );
};
+ /**
+ * Discard items from the list.
+ *
+ * This is a more precise operation than 'removeItems' because when
+ * the list is resorting the position of a single item, it removes
+ * the item and reinserts it, which makes the 'remove' event unhelpful
+ * to differentiate between actually discarding items, and only
+ * temporarily moving them.
+ *
+ * @param {mw.echo.dm.NotificationItem[]} items Items to insert into the list
+ */
+ mw.echo.dm.NotificationsList.prototype.discardItems = function ( items ) {
+ this.removeItems( items );
+ this.emit( 'discard', items );
+ };
+
/**
* Get an array of all items' IDs.
*
@@ -176,4 +192,5 @@
mw.echo.dm.NotificationsList.prototype.isGroup = function () {
return false;
};
+
} )( mediaWiki );
diff --git a/modules/styles/mw.echo.ui.CrossWikiNotificationItemWidget.less b/modules/styles/mw.echo.ui.CrossWikiNotificationItemWidget.less
index 5d48b5efa..f979a173f 100644
--- a/modules/styles/mw.echo.ui.CrossWikiNotificationItemWidget.less
+++ b/modules/styles/mw.echo.ui.CrossWikiNotificationItemWidget.less
@@ -37,4 +37,15 @@
border-bottom: 1px #dddddd solid;
margin-bottom: 0.4em;
}
+
+ .mw-echo-ui-subGroupListWidget-header {
+ margin-bottom: @bundle-group-padding;
+
+ &-row-title {
+ // Override OOUI's line height for labels
+ line-height: 1em !important;
+ font-weight: bold;
+ color: #666666;
+ }
+ }
}
diff --git a/modules/styles/mw.echo.ui.SubGroupListWidget.less b/modules/styles/mw.echo.ui.SubGroupListWidget.less
index bfa6eaa90..95035eb3f 100644
--- a/modules/styles/mw.echo.ui.SubGroupListWidget.less
+++ b/modules/styles/mw.echo.ui.SubGroupListWidget.less
@@ -6,12 +6,25 @@
padding-top: @bundle-group-padding;
}
- &-title {
- // Override OOUI's line height for labels
- line-height: 1em !important;
- font-weight: bold;
- color: #666666;
- margin-bottom: @bundle-group-padding;
+ &-header {
+ display: table;
+ width: 100%;
+
+ &-row {
+ display: table-row;
+
+ &-title {
+ display: table-cell;
+ width: 100%;
+ vertical-align: bottom;
+ }
+
+ &-markAllReadButton {
+ display: table-cell;
+ text-align: right;
+ padding-bottom: 0.5em;
+ }
+ }
}
.mw-echo-ui-sortedListWidget {
diff --git a/modules/ui/mw.echo.ui.SubGroupListWidget.js b/modules/ui/mw.echo.ui.SubGroupListWidget.js
index 74364bf55..c823fba1b 100644
--- a/modules/ui/mw.echo.ui.SubGroupListWidget.js
+++ b/modules/ui/mw.echo.ui.SubGroupListWidget.js
@@ -8,11 +8,14 @@
* @param {mw.echo.dm.SortedList} listModel Notifications list model for this source
* @param {Object} config Configuration object
* @cfg {boolean} [showTitle=false] Show the title of this group
+ * @cfg {boolean} [showMarkAllRead=false] Show a mark all read button for this group
* @cfg {jQuery} [$overlay] A jQuery element functioning as an overlay
* for popups.
*/
mw.echo.ui.SubGroupListWidget = function MwEchoUiSubGroupListWidget( controller, listModel, config ) {
- var sourceURL;
+ var sourceURL,
+ $header = $( '
' )
+ .addClass( 'mw-echo-ui-subGroupListWidget-header' );
config = config || {};
@@ -23,6 +26,7 @@
mw.echo.ui.SubGroupListWidget.parent.call( this, $.extend( { data: this.getSource() }, config ) );
this.showTitle = !!config.showTitle;
+ this.showMarkAllRead = !!config.showMarkAllRead;
this.$overlay = config.$overlay || this.$element;
this.listWidget = new mw.echo.ui.SortedListWidget(
@@ -45,28 +49,60 @@
sourceURL = this.model.getSourceURL() ?
this.model.getSourceURL().replace( '$1', 'Special:Notifications' ) :
null;
- this.title = new OO.ui.ButtonWidget( {
- framed: false,
- classes: [ 'mw-echo-ui-subGroupListWidget-title' ],
- href: sourceURL
- } );
+ if ( sourceURL ) {
+ this.title = new OO.ui.ButtonWidget( {
+ framed: false,
+ classes: [ 'mw-echo-ui-subGroupListWidget-header-row-title' ],
+ href: sourceURL
+ } );
+ } else {
+ this.title = new OO.ui.LabelWidget( {
+ classes: [ 'mw-echo-ui-subGroupListWidget-header-row-title' ]
+ } );
+ }
+
if ( this.model.getTitle() ) {
this.title.setLabel( this.model.getTitle() );
}
this.title.toggle( this.showTitle );
- // Events
- this.model.connect( this, {
- // We really only need to listen to 'remove' item here
- // There is no other update event worthwhile in this list.
- remove: 'onModelRemoveItem',
- update: 'onModelUpdate' // Adding all items
+ // Mark all as read button
+ this.markAllReadButton = new OO.ui.ButtonWidget( {
+ framed: true,
+ label: mw.msg( 'echo-mark-all-as-read' ),
+ classes: [ 'mw-echo-ui-subGroupListWidget-header-row-markAllReadButton' ]
} );
+ // Events
+ this.model.connect( this, {
+ // Cross-wiki items can be discarded when marked as read.
+ // We need to differentiate this explicit action from the
+ // action of 'remove' because 'remove' is also used when
+ // an item is resorted by OO.SortedEmitterWidget before
+ // it is re-added again
+ discard: 'onModelDiscardItems',
+ // Update all items
+ update: 'resetItemsFromModel'
+ } );
+ this.markAllReadButton.connect( this, { click: 'onMarkAllReadButtonClick' } );
+ // We must aggregate on item update, so we know when and if all
+ // items are read and can hide/show the 'mark all read' button
+ this.model.aggregate( { update: 'itemUpdate' } );
+ this.model.connect( this, { itemUpdate: 'toggleMarkAllReadButton' } );
+
+ // Initialize
+ this.toggleMarkAllReadButton();
this.$element
.addClass( 'mw-echo-ui-subGroupListWidget' )
.append(
- this.title.$element,
+ $header.append(
+ $( '
' )
+ .addClass( 'mw-echo-ui-subGroupListWidget-header-row' )
+ .append(
+ this.title.$element,
+ this.markAllReadButton.$element
+ )
+ ),
this.listWidget.$element
);
};
@@ -78,14 +114,46 @@
/* Methods */
/**
- * Respond to model update event
- *
- * @param {mw.echo.dm.NotificationItem[]} items Item models that are added
+ * Toggle the visibility of the mark all read button for this group
+ * based on whether there are unread notifications
*/
- mw.echo.ui.SubGroupListWidget.prototype.onModelUpdate = function ( items ) {
+ mw.echo.ui.SubGroupListWidget.prototype.toggleMarkAllReadButton = function () {
+ this.markAllReadButton.toggle( this.hasUnread() );
+ };
+
+ /**
+ * Respond to 'mark all as read' button click
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.onMarkAllReadButtonClick = function () {
+ this.controller.markEntireListModelRead( this.model.getSource() );
+ };
+
+ /**
+ * Check whether this sub group list has any unread notifications
+ *
+ * @return {boolean} Sub group has unread notifications
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.hasUnread = function () {
+ var isUnread = function ( item ) {
+ return !item.isRead();
+ },
+ items = this.model.getItems();
+
+ return items.some( isUnread );
+ };
+
+ /**
+ * Reset the items and rebuild them according to the model.
+ *
+ * @param {mw.echo.dm.NotificationItem[]} [items] Item models that are added.
+ * If this is empty, the widget will request all the items from the model.
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.resetItemsFromModel = function ( items ) {
var i,
itemWidgets = [];
+ items = items || this.model.getItems();
+
for ( i = 0; i < items.length; i++ ) {
itemWidgets.push(
new mw.echo.ui.SingleNotificationItemWidget(
@@ -93,7 +161,7 @@
items[ i ],
{
$overlay: this.$overlay,
- bundle: true
+ bundle: items[ i ].isBundled()
}
)
);
@@ -106,13 +174,19 @@
};
/**
- * Respond to mode remove event. This may happen when an item
+ * Respond to model remove event. This may happen when an item
* is marked as read.
*
- * @param {mw.echo.dm.NotificationItem} item Notification item model
+ * @param {mw.echo.dm.NotificationItem[]} items Notification item models
*/
- mw.echo.ui.SubGroupListWidget.prototype.onModelRemoveItem = function ( item ) {
- this.listWidget.removeItems( [ this.listWidget.getItemFromId( item.getId() ) ] );
+ mw.echo.ui.SubGroupListWidget.prototype.onModelDiscardItems = function ( items ) {
+ var i,
+ itemWidgets = [];
+
+ for ( i = 0; i < items.length; i++ ) {
+ itemWidgets.push( this.listWidget.getItemFromId( items[ i ].getId() ) );
+ }
+ this.listWidget.removeItems( itemWidgets );
};
/**
@@ -157,6 +231,35 @@
return this.model.getSource();
};
+ /**
+ * Get an array of IDs of all of the items in this group
+ *
+ * @return {number[]} Array of item IDs
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.getAllItemIDs = function () {
+ return this.model.getAllItemIds();
+ };
+
+ /**
+ * Get an array of IDs of all of the items in this group that
+ * correspond to a specific type
+ *
+ * @param {string} type Item type
+ * @return {number[]} Array of item IDs
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.getAllItemIDsByType = function ( type ) {
+ return this.model.getAllItemIdsByType( type );
+ };
+
+ /**
+ * Check whether this group is foreign
+ *
+ * @return {boolean} This group is foreign
+ */
+ mw.echo.ui.SubGroupListWidget.prototype.isForeign = function () {
+ return this.model.isForeign();
+ };
+
/**
* Get the group id, which is represented by its source.
* This is meant for sorting callbacks that fallback on