jenkins-bot has submitted this change and it was merged.

Change subject: Add a cross-wiki sidebar to the Special:Notifications page
......................................................................


Add a cross-wiki sidebar to the Special:Notifications page

Add a sidebar with cross-wiki sources and pages of unread notifications.
The filter allows the user to fetch notifications from a foreign source
and specific pages if those exist.

Bug: T129366
Change-Id: I57d827a47f80274d75364c2099a9624049a26834
---
M Resources.php
M i18n/en.json
M i18n/qqq.json
M modules/api/mw.echo.api.APIHandler.js
M modules/api/mw.echo.api.EchoApi.js
M modules/api/mw.echo.api.ForeignAPIHandler.js
M modules/api/mw.echo.api.LocalAPIHandler.js
M modules/controller/mw.echo.Controller.js
M modules/echo.variables.less
M modules/model/mw.echo.dm.FiltersModel.js
M modules/model/mw.echo.dm.ModelManager.js
M modules/model/mw.echo.dm.NotificationItem.js
M modules/model/mw.echo.dm.NotificationsList.js
M modules/model/mw.echo.dm.PaginationModel.js
A modules/model/mw.echo.dm.SourcePagesModel.js
A modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less
M modules/styles/mw.echo.ui.NotificationsInboxWidget.less
A modules/styles/mw.echo.ui.PageFilterWidget.less
A modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less
A modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js
M modules/ui/mw.echo.ui.DatedNotificationsWidget.js
M modules/ui/mw.echo.ui.NotificationsInboxWidget.js
A modules/ui/mw.echo.ui.PageFilterWidget.js
A modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js
M modules/ui/mw.echo.ui.SortedListWidget.js
M modules/ui/mw.echo.ui.SubGroupListWidget.js
26 files changed, 1,142 insertions(+), 98 deletions(-)

Approvals:
  Catrope: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/Resources.php b/Resources.php
index e815aea..99922bf 100644
--- a/Resources.php
+++ b/Resources.php
@@ -170,6 +170,7 @@
                'scripts' => array(
                        'mw.echo.js',
                        'model/mw.echo.dm.js',
+                       'model/mw.echo.dm.SourcePagesModel.js',
                        'model/mw.echo.dm.PaginationModel.js',
                        'model/mw.echo.dm.FiltersModel.js',
                        'model/mw.echo.dm.ModelManager.js',
@@ -286,6 +287,9 @@
                        'ui/mw.echo.ui.DatedSubGroupListWidget.js',
                        'ui/mw.echo.ui.DatedNotificationsWidget.js',
                        'ui/mw.echo.ui.ReadStateButtonSelectWidget.js',
+                       'ui/mw.echo.ui.PageNotificationsOptionWidget.js',
+                       'ui/mw.echo.ui.PageFilterWidget.js',
+                       'ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js',
                        'ui/mw.echo.ui.NotificationsInboxWidget.js',
                        'special/ext.echo.special.js',
                ),
@@ -294,6 +298,9 @@
                        'styles/mw.echo.ui.DatedSubGroupListWidget.less',
                        'styles/mw.echo.ui.DatedNotificationsWidget.less',
                        'styles/mw.echo.ui.NotificationsInboxWidget.less',
+                       'styles/mw.echo.ui.PageNotificationsOptionWidget.less',
+                       'styles/mw.echo.ui.PageFilterWidget.less',
+                       'styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less',
                ),
                'dependencies' => array(
                        'ext.echo.ui',
@@ -307,6 +314,8 @@
                        'echo-notification-placeholder-filters',
                        'echo-specialpage-pagination-numnotifications',
                        'echo-specialpage-pagination-range',
+                       'echo-specialpage-pagefilters-title',
+                       'echo-specialpage-pagefilters-subtitle',
                        'echo-more-info',
                        'echo-feedback',
                        'echo-specialpage-section-markread',
diff --git a/i18n/en.json b/i18n/en.json
index e7422bb..b175404 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -99,6 +99,8 @@
        "echo-specialpage-markasread-invalid-id": "Invalid event ID",
        "echo-specialpage-pagination-numnotifications": "$1 
{{PLURAL:$1|notification|notifications}}",
        "echo-specialpage-pagination-range": "$1 - $2",
+       "echo-specialpage-pagefilters-title": "Recent activity",
+       "echo-specialpage-pagefilters-subtitle": "Pages with unread 
notifications",
        "notificationsmarkread-legend": "Mark notification as read",
        "echo-anon": "To receive notifications, [$1 create an account] or [$2 
log in].",
        "echo-none": "You have no notifications.",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 7ffb45f..f6c2648 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -90,6 +90,8 @@
        "echo-specialpage-markasread-invalid-id": "Error message shown to users 
who try to mark a notification as read with an invalid event ID.",
        "echo-specialpage-pagination-numnotifications": "Label noting the 
number of notifications displayed in the page. This only appears if there is a 
single page of results.\n\nParameters:\n* $1 - Number of notifications in the 
page.\n{{Identical|Notification}}",
        "echo-specialpage-pagination-range": "Label noting the range of the 
notifications displayed in the page. This only appears if there are multiple 
pages of results available.\n\nParameters:\n* $1 - Number of the first item.\n* 
$2 - Number of the last item.",
+       "echo-specialpage-pagefilters-title": "Title of the page filter box in 
Special:Notifications page.",
+       "echo-specialpage-pagefilters-subtitle": "Subtitle of the page filter 
box in Special:Notifications page.",
        "notificationsmarkread-legend": "Title for the form that marks a 
notification as read in [[Special:NotificationsMarkAsRead]]",
        "echo-anon": "Error message shown to users who try to visit 
[[Special:Notifications]] as an anon.\n\nParameters:\n* $1 - URL of signup 
page, with returnto pointing to Special:Notifications\n* $2 - URL of login 
page, with returnto pointing to Special:Notifications",
        "echo-none": "Message shown to users who have no notifications. Also 
shown in the overlay.",
diff --git a/modules/api/mw.echo.api.APIHandler.js 
b/modules/api/mw.echo.api.APIHandler.js
index dce0d6d..213e87a 100644
--- a/modules/api/mw.echo.api.APIHandler.js
+++ b/modules/api/mw.echo.api.APIHandler.js
@@ -57,6 +57,48 @@
        mw.echo.api.APIHandler.prototype.fetchNotifications = null;
 
        /**
+        * Fetch all pages with unread notifications in them per wiki
+        *
+        * @param {string|string[]} [sources=*] Requested sources. If not given
+        *  or if a '*' is given, all available sources will be queried
+        * @return {jQuery.Promise} Promise that is resolved with an object
+        *      of pages with the number of unread notifications per wiki
+        */
+       mw.echo.api.APIHandler.prototype.fetchUnreadNotificationPages = 
function ( sources ) {
+               var params = {
+                               action: 'query',
+                               meta: 'unreadnotificationpages'
+                       };
+
+               if ( !sources || sources === '*' ) {
+                       params.unpwikis = '*';
+               } else {
+                       sources = Array.isArray( sources ) ? sources : [ 
sources ];
+                       params.unpwikis = sources.join( '|' );
+               }
+
+               return this.api.get( params );
+       };
+
+       /**
+        * Check if the given source is local
+        *
+        * @param {string|string[]} sources Source names
+        * @return {boolean} Source is local
+        */
+       mw.echo.api.APIHandler.prototype.isSourceLocal = function ( sources ) {
+               return Array.isArray( sources ) ?
+                       (
+                               sources.indexOf( 'local' ) !== -1 ||
+                               sources.indexOf( mw.config.get( 'wgDBname' ) ) 
!== -1
+                       ) :
+                       (
+                               sources === 'local' ||
+                               sources === mw.config.get( 'wgDBname' )
+                       );
+       };
+
+       /**
         * Create a new fetchNotifications promise that queries the API and 
overrides
         * the cached promise.
         *
@@ -82,7 +124,7 @@
                                uselang: this.userLang
                        }, this.getTypeParams( type ) );
 
-               if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === 
-1 ) {
+               if ( !this.isSourceLocal( sources ) ) {
                        params.notwikis = sources.join( '|' );
                        params.notfilter = '!read';
                        fetchingSource = 'foreign';
@@ -172,7 +214,7 @@
        mw.echo.api.APIHandler.prototype.isFetchingErrorState = function ( 
type, sources ) {
                var fetchingSource = 'local';
 
-               if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === 
-1 ) {
+               if ( !this.isSourceLocal( sources ) ) {
                        fetchingSource = 'foreign';
                }
                return !!( this.apiErrorState[ type ] && this.apiErrorState[ 
type ][ fetchingSource ] );
@@ -191,7 +233,7 @@
        mw.echo.api.APIHandler.prototype.getFetchNotificationPromise = function 
( type, sources, overrideParams ) {
                var fetchingSource = 'local';
 
-               if ( Array.isArray( sources ) && sources.indexOf( 'local' ) === 
-1 ) {
+               if ( !this.isSourceLocal( sources ) ) {
                        fetchingSource = 'foreign';
                }
                if ( overrideParams || !this.fetchNotificationsPromise[ type ] 
|| !this.fetchNotificationsPromise[ type ][ fetchingSource ] ) {
diff --git a/modules/api/mw.echo.api.EchoApi.js 
b/modules/api/mw.echo.api.EchoApi.js
index bd5ad6d..a37a0b7 100644
--- a/modules/api/mw.echo.api.EchoApi.js
+++ b/modules/api/mw.echo.api.EchoApi.js
@@ -1,4 +1,4 @@
-( function ( mw ) {
+( function ( mw, $ ) {
        /**
         * A class defining Echo API instructions and network operations
         *
@@ -23,11 +23,20 @@
         * Register a set of foreign sources.
         *
         * @param {Object} sources Object mapping source names to config objects
+        * @param {boolean} [unreadOnly=false] Fetch only unread notifications
+        * @param {number} [limit] Specific limit of notifications. Defaults to
+        *  the default limit stated in the class.
         */
-       mw.echo.api.EchoApi.prototype.registerForeignSources = function ( 
sources ) {
+       mw.echo.api.EchoApi.prototype.registerForeignSources = function ( 
sources, unreadOnly, limit ) {
                var s;
+
+               limit = limit || this.limit;
+
                for ( s in sources ) {
-                       this.network.setApiHandler( s, new 
mw.echo.api.ForeignAPIHandler( sources[ s ].url ) );
+                       this.network.setApiHandler( s, new 
mw.echo.api.ForeignAPIHandler( sources[ s ].url, {
+                               unreadOnly: !!unreadOnly,
+                               limit: limit
+                       } ) );
                }
        };
 
@@ -46,36 +55,129 @@
        };
 
        /**
+        * Fetch all pages with unread notifications in them per wiki
+        *
+        * @param {string[]} [sources=all] Requested sources
+        * @return {jQuery.Promise} Promise that is resolved with an object
+        *      of pages with the number of unread notifications per wiki
+        */
+       mw.echo.api.EchoApi.prototype.fetchUnreadNotificationPages = function ( 
sources ) {
+               return this.network.getApiHandler( 'local' 
).fetchUnreadNotificationPages( sources )
+                       .then( function ( data ) {
+                               return OO.getProp( data, 'query', 
'unreadnotificationpages' );
+                       } );
+       };
+
+       /**
+        * Fetch notifications from a given source with given filters
+        *
+        * @param {string} type Notification type to fetch: 'alert', 'message', 
or 'all'
+        * @param {string} [source] The source from which to fetch the 
notifications.
+        *  If not given, the local notifications will be fetched.
+        * @param {Object} [filters] Filter values
+        * @return {jQuery.Promise} Promise that is resolved with all 
notifications for the
+        *  requested types.
+        */
+       mw.echo.api.EchoApi.prototype.fetchFilteredNotifications = function ( 
type, source, filters ) {
+               source = source || 'local';
+
+               if ( source === 'local' || source === mw.config.get( 'wgDBname' 
) ) {
+                       return this.fetchNotifications( type, source, true, 
filters );
+               } else {
+                       return this.fetchNotificationsFromRemoteSource( type, 
source, true, filters );
+               }
+       };
+
+       /**
+        * Convert the filter object to the relevant API parameters.
+        *
+        * @param {Object} [filterObject] The filter object
+        * @param {string} [filterObject.continue] A continue variable
+        *  defining the offset to fetch notifications
+        * @param {string} [filterObject.readState] Notification read
+        *  state, 'all', 'read' or 'unread'
+        * @param {string|string[]} [filterObject.titles] Requested titles
+        * @return {Object} API parameter definitions to override
+        */
+       mw.echo.api.EchoApi.prototype.convertFiltersToAPIParams = function ( 
filterObject ) {
+               var overrideParams = {};
+
+               filterObject = filterObject || {};
+
+               if ( filterObject.continue ) {
+                       overrideParams.notcontinue = filterObject.continue;
+               }
+
+               if ( filterObject.readState && filterObject.readState !== 'all' 
) {
+                       overrideParams.notfilter = filterObject.readState === 
'read' ?
+                               'read' :
+                               '!read';
+               }
+
+               if ( filterObject.titles ) {
+                       overrideParams.nottitles = Array.isArray( 
filterObject.titles ) ?
+                               filterObject.titles.join( '|' ) :
+                               filterObject.titles;
+               }
+
+               return overrideParams;
+       };
+
+       /**
+        * Fetch remote notifications from a given source. This skips the local 
fetching that is
+        * usually done and calls the remote wiki directly.
+        *
+        * @param {string} type Notification type to fetch: 'alert', 'message', 
or 'all'
+        * @param {string|string[]} [source] The source from which to fetch the 
notifications.
+        *  If not given, the local notifications will be fetched.
+        * @param {boolean} [isForced] Force a refresh on the fetch 
notifications promise
+        * @param {Object} [filters] Filter values
+        * @return {jQuery.Promise} Promise that is resolved with all 
notifications for the
+        *  requested types.
+        */
+       mw.echo.api.EchoApi.prototype.fetchNotificationsFromRemoteSource = 
function ( type, source, isForced, filters ) {
+               var handler = this.network.getApiHandler( source );
+
+               if ( !handler ) {
+                       return $.Deferred().reject().promise();
+               }
+
+               return handler.fetchNotifications(
+                       type,
+                       // For the remote source, we are fetching 'local' 
notifications
+                       'local',
+                       !!isForced,
+                       this.convertFiltersToAPIParams( filters )
+               )
+                       .then( function ( result ) {
+                               return OO.getProp( result.query, 
'notifications' );
+                       } );
+       };
+
+       /**
         * Fetch notifications from the server based on type
         *
         * @param {string} type Notification type to fetch: 'alert', 'message', 
or 'all'
         * @param {string|string[]} [sources] The source from which to fetch 
the notifications.
         *  If not given, the local notifications will be fetched.
         * @param {boolean} [isForced] Force a refresh on the fetch 
notifications promise
-        * @param {string} [continueValue] A value for the continue parameter, 
defining a page
-        * @param {string} [readStatus='all'] Read status of the notifications: 
'read', 'unread' or 'all'
+        * @param {Object} [filters] Filter values
         * @return {jQuery.Promise} Promise that is resolved with all 
notifications for the
         *  requested types.
         */
-       mw.echo.api.EchoApi.prototype.fetchNotifications = function ( type, 
sources, isForced, continueValue, readStatus ) {
-               var overrideParams = {};
+       mw.echo.api.EchoApi.prototype.fetchNotifications = function ( type, 
sources, isForced, filters ) {
                sources = Array.isArray( sources ) ?
                        sources :
                        sources ?
                                [ sources ] :
-                               null;
+                               'local';
 
-               if ( continueValue ) {
-                       overrideParams.notcontinue = continueValue;
-               }
-
-               if ( readStatus && readStatus !== 'all' ) {
-                       overrideParams.notfilter = readStatus === 'read' ?
-                               'read' :
-                               '!read';
-               }
-
-               return this.network.getApiHandler( 'local' 
).fetchNotifications( type, sources, isForced, overrideParams )
+               return this.network.getApiHandler( 'local' ).fetchNotifications(
+                       type,
+                       sources,
+                       isForced,
+                       this.convertFiltersToAPIParams( filters )
+               )
                        .then( function ( result ) {
                                return OO.getProp( result.query, 
'notifications' );
                        } );
@@ -90,7 +192,7 @@
         *  names to an array of their items' API data objects.
         */
        mw.echo.api.EchoApi.prototype.fetchNotificationGroups = function ( 
sourceArray, type ) {
-               return this.network.getApiHandler( 'local' 
).fetchNotifications( type, sourceArray )
+               return this.network.getApiHandler( 'local' 
).fetchNotifications( type, sourceArray, true )
                        .then( function ( result ) {
                                var i,
                                        items = OO.getProp( result, 'query', 
'notifications', 'list' ),
@@ -197,4 +299,4 @@
        mw.echo.api.EchoApi.prototype.getLimit = function () {
                return this.limit;
        };
-} )( mediaWiki );
+} )( mediaWiki, jQuery );
diff --git a/modules/api/mw.echo.api.ForeignAPIHandler.js 
b/modules/api/mw.echo.api.ForeignAPIHandler.js
index 48b31c4..3f8cfe6 100644
--- a/modules/api/mw.echo.api.ForeignAPIHandler.js
+++ b/modules/api/mw.echo.api.ForeignAPIHandler.js
@@ -9,6 +9,8 @@
         * @param {string} apiUrl A url for the access point of the
         *  foreign API.
         * @param {Object} [config] Configuration object
+        * @cfg {boolean} [unreadOnly] Whether this handler should request 
unread
+        *  notifications by default.
         */
        mw.echo.api.ForeignAPIHandler = function MwEchoApiForeignAPIHandler( 
apiUrl, config ) {
                config = config || {};
@@ -17,6 +19,7 @@
                mw.echo.api.ForeignAPIHandler.parent.call( this, config );
 
                this.api = new mw.ForeignApi( apiUrl );
+               this.unreadOnly = config.unreadOnly !== undefined ? 
!!config.unreadOnly : false;
        };
 
        /* Setup */
@@ -27,10 +30,15 @@
         * @inheritdoc
         */
        mw.echo.api.ForeignAPIHandler.prototype.getTypeParams = function ( type 
) {
-               return $.extend( {}, this.typeParams[ type ], {
-                       notfilter: '!read',
+               var params = {
                        // Backwards compatibility
-                       notnoforn: 1
-               } );
+                       notforn: 1
+               };
+
+               if ( this.unreadOnly ) {
+                       params = $.extend( {}, params, { notfilter: '!read' } );
+               }
+
+               return $.extend( {}, this.typeParams[ type ], params );
        };
 } )( mediaWiki, jQuery );
diff --git a/modules/api/mw.echo.api.LocalAPIHandler.js 
b/modules/api/mw.echo.api.LocalAPIHandler.js
index 3c562a4..eaa4260 100644
--- a/modules/api/mw.echo.api.LocalAPIHandler.js
+++ b/modules/api/mw.echo.api.LocalAPIHandler.js
@@ -28,9 +28,9 @@
        mw.echo.api.LocalAPIHandler.prototype.fetchNotifications = function ( 
type, source, isForced, overrideParams ) {
                if ( overrideParams ) {
                        return this.createNewFetchNotificationPromise( type, 
source, overrideParams );
-               } else if ( overrideParams || this.isFetchingErrorState( type, 
source ) ) {
+               } else if ( isForced || this.isFetchingErrorState( type, source 
) ) {
                        // Force new promise
-                       this.createNewFetchNotificationPromise( type, source, 
overrideParams );
+                       return this.createNewFetchNotificationPromise( type, 
source, overrideParams );
                }
 
                return this.getFetchNotificationPromise( type, source, 
overrideParams );
diff --git a/modules/controller/mw.echo.Controller.js 
b/modules/controller/mw.echo.Controller.js
index ea08418..3db7601 100644
--- a/modules/controller/mw.echo.Controller.js
+++ b/modules/controller/mw.echo.Controller.js
@@ -17,15 +17,26 @@
        OO.initClass( mw.echo.Controller );
 
        /**
-        * Update a filter value
+        * Update a filter value.
+        * The method accepts a filter name and as many arguments
+        * as needed.
         *
         * @param {string} filter Filter name
-        * @param {string} value Filter value
         */
-       mw.echo.Controller.prototype.setFilter = function ( filter, value ) {
+       mw.echo.Controller.prototype.setFilter = function ( filter ) {
+               var filtersModel = this.manager.getFiltersModel(),
+                       values = Array.prototype.slice.call( arguments );
+
+               values.shift();
+
                if ( filter === 'readState' ) {
-                       this.manager.getFiltersModel().setReadState( value );
+                       filtersModel.setReadState( values[ 0 ] );
+               } else if ( filter === 'sourcePage' ) {
+                       filtersModel.setCurrentSourcePage( values[ 0 ], values[ 
1 ] );
                }
+
+               // Reset pagination
+               this.manager.getPaginationModel().reset();
        };
 
        /**
@@ -62,6 +73,41 @@
        };
 
        /**
+        * 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 () {
+               var controller = this,
+                       filterModel = this.manager.getFiltersModel(),
+                       sourcePageModel = filterModel.getSourcePagesModel();
+
+               return this.api.fetchUnreadNotificationPages()
+                       .then( function ( data ) {
+                               var source,
+                                       foreignSources = {};
+
+                               for ( source in data ) {
+                                       if ( source !== mw.config.get( 
'wgDBname' ) ) {
+                                               // Collect sources for API
+                                               foreignSources[ source ] = 
data[ source ].source;
+                                       }
+                               }
+
+                               // Register the foreign sources in the API
+                               controller.api.registerForeignSources( 
foreignSources, false );
+
+                               // Register local source with the wiki name
+                               controller.api.registerLocalSources( [ 
mw.config.get( 'wgDBname' ) ] );
+
+                               // Register pages
+                               sourcePageModel.setAllSources( data );
+                       } );
+       };
+
+       /**
         * Fetch notifications from the local API and sort them by date.
         * This method ignores cross-wiki notifications and bundles.
         *
@@ -74,17 +120,19 @@
                var controller = this,
                        pagination = this.manager.getPaginationModel(),
                        filters = this.manager.getFiltersModel(),
-                       // When we have multiple possible sources, this will 
change
-                       currentSource = 'local',
+                       currentSource = 
filters.getSourcePagesModel().getCurrentSource(),
                        continueValue = pagination.getPageContinue( page || 
pagination.getCurrPageIndex() );
 
                pagination.setItemsPerPage( this.api.getLimit() );
-               return this.api.fetchNotifications(
+
+               return this.api.fetchFilteredNotifications(
                        this.manager.getTypeString(),
-                       'local',
-                       true,
-                       continueValue,
-                       filters.getReadState()
+                       currentSource,
+                       {
+                               continue: continueValue,
+                               readState: filters.getReadState(),
+                               titles: 
filters.getSourcePagesModel().getCurrentPageTitle()
+                       }
                )
                        .then( function ( data ) {
                                var i, notifData, newNotifData, date, 
itemModel, symbolicName, count,
@@ -203,7 +251,7 @@
                                                        
foreignListModel.setForeign( true );
 
                                                        // Register foreign 
sources
-                                                       
controller.api.registerForeignSources( notifData.sources );
+                                                       
controller.api.registerForeignSources( notifData.sources, true );
                                                        // Add the lists 
according to the sources
                                                        for ( source in 
notifData.sources ) {
                                                                
foreignListModel.getList().addGroup(
@@ -341,7 +389,7 @@
                                                        notifData = 
controller.createNotificationData( groupItems[ i ] );
                                                        items.push(
                                                                new 
mw.echo.dm.NotificationItem( groupItems[ i ].id, $.extend( notifData, {
-                                                                       
modelName: group,
+                                                                       
modelName: 'xwiki',
                                                                        source: 
group,
                                                                        
bundled: true,
                                                                        
foreign: true
@@ -369,16 +417,15 @@
         *
         * @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} [isCrossWiki=false] The item is 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 ) {
+       mw.echo.Controller.prototype.markSingleItemRead = function ( itemId, 
modelName, isCrossWiki, isRead ) {
+               if ( isCrossWiki ) {
                        return this.markCrossWikiItemsRead( [ itemId ], 
modelName, isRead );
                }
 
@@ -477,7 +524,7 @@
 
                                        // Mark items as read in the API
                                        promises.push(
-                                               
controller.markCrossWikiItemsRead( idArray, listModel.getSource() )
+                                               
controller.markCrossWikiItemsRead( idArray, listModel.getName() )
                                        );
                                }
 
diff --git a/modules/echo.variables.less b/modules/echo.variables.less
index db1d2da..dcb9551 100644
--- a/modules/echo.variables.less
+++ b/modules/echo.variables.less
@@ -16,3 +16,11 @@
 @opacity-mid: 0.8;
 
 @specialpage-separation-unit: 0.7em;
+@specialpage-sidebar-width: 20em;
+
+@grey-light: #777;
+@grey-medium: #555;
+@grey-dark: #333;
+@grey-darkest: #000;
+
+@border-color: #ccc;
diff --git a/modules/model/mw.echo.dm.FiltersModel.js 
b/modules/model/mw.echo.dm.FiltersModel.js
index 656d255..2e0cef9 100644
--- a/modules/model/mw.echo.dm.FiltersModel.js
+++ b/modules/model/mw.echo.dm.FiltersModel.js
@@ -9,6 +9,7 @@
         * @param {Object} config Configuration object
         * @cfg {string} [readState='all'] Notifications read state. Allowed
         *  values are 'all', 'read' or 'unread'.
+        * @cfg {string} [selectedSource] Currently selected source
         */
        mw.echo.dm.FiltersModel = function MwEchoDmFiltersModel( config ) {
                config = config || {};
@@ -16,10 +17,11 @@
                // Mixin constructor
                OO.EventEmitter.call( this );
 
-               this.readState = 'all';
-               if ( config.readState ) {
-                       this.setReadState( config.readState );
-               }
+               this.readState = config.readState || 'all';
+
+               this.sourcePagesModel = new mw.echo.dm.SourcePagesModel();
+               this.selectedSource = config.selectedSource || '';
+               this.selectedSourcePage = null;
        };
 
        /* Initialization */
@@ -61,4 +63,37 @@
        mw.echo.dm.FiltersModel.prototype.getReadState = function () {
                return this.readState;
        };
+
+       /**
+        * Set the currently selected source and page.
+        * If no page is given, or if page is null, the source title
+        * is assumed to be selected.
+        *
+        * @param {string} source Source name
+        * @param {string} [page] Page name
+        */
+       mw.echo.dm.FiltersModel.prototype.setCurrentSourcePage = function ( 
source, page ) {
+               this.sourcePagesModel.setCurrentSourcePage( source, page );
+       };
+
+       /**
+        * Get the total count of a source. This sums the count of all
+        * sub pages in that source.
+        *
+        * @param {string} source Symbolic name for source
+        * @return {number} Total count
+        */
+       mw.echo.dm.FiltersModel.prototype.getSourceTotalCount = function ( 
source ) {
+               return this.sourcePagesModel.getSourceTotalCount( source );
+       };
+
+       /**
+        * Get the source page model
+        *
+        * @return {mw.echo.dm.SourcePagesModel} Source pages model
+        */
+       mw.echo.dm.FiltersModel.prototype.getSourcePagesModel = function () {
+               return this.sourcePagesModel;
+       };
+
 } )( mediaWiki );
diff --git a/modules/model/mw.echo.dm.ModelManager.js 
b/modules/model/mw.echo.dm.ModelManager.js
index c538e48..15ae21a 100644
--- a/modules/model/mw.echo.dm.ModelManager.js
+++ b/modules/model/mw.echo.dm.ModelManager.js
@@ -40,7 +40,9 @@
 
                this.notificationModels = {};
                this.paginationModel = new mw.echo.dm.PaginationModel();
-               this.filtersModel = new mw.echo.dm.FiltersModel();
+               this.filtersModel = new mw.echo.dm.FiltersModel( {
+                       selectedSource: mw.config.get( 'wgDBname' )
+               } );
 
                // Properties
                this.seenTime = mw.config.get( 'wgEchoSeenTime' ) || {};
diff --git a/modules/model/mw.echo.dm.NotificationItem.js 
b/modules/model/mw.echo.dm.NotificationItem.js
index 145d61a..8dd1cd6 100644
--- a/modules/model/mw.echo.dm.NotificationItem.js
+++ b/modules/model/mw.echo.dm.NotificationItem.js
@@ -64,6 +64,7 @@
                this.foreign = !!config.foreign;
                this.bundled = !!config.bundled;
                this.source = config.source || '';
+               this.modelName = config.modelName || 'local';
                this.iconType = config.iconType;
                this.iconURL = config.iconURL;
 
diff --git a/modules/model/mw.echo.dm.NotificationsList.js 
b/modules/model/mw.echo.dm.NotificationsList.js
index c0da050..f98fab3 100644
--- a/modules/model/mw.echo.dm.NotificationsList.js
+++ b/modules/model/mw.echo.dm.NotificationsList.js
@@ -164,6 +164,15 @@
        };
 
        /**
+        * Get the name associated with this list.
+        *
+        * @return {string} List name
+        */
+       mw.echo.dm.NotificationsList.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
         * Get the source article url associated with this list.
         *
         * @return {string} List source article url
diff --git a/modules/model/mw.echo.dm.PaginationModel.js 
b/modules/model/mw.echo.dm.PaginationModel.js
index 4d516ff..28e5b9a 100644
--- a/modules/model/mw.echo.dm.PaginationModel.js
+++ b/modules/model/mw.echo.dm.PaginationModel.js
@@ -48,6 +48,18 @@
        /* Methods */
 
        /**
+        * Reset pagination data
+        *
+        * @fires update
+        */
+       mw.echo.dm.PaginationModel.prototype.reset = function () {
+               this.pagesContinue = [];
+               this.currPageIndex = 0;
+               this.lastPageItemCount = 0;
+
+               this.emit( 'update' );
+       };
+       /**
         * Set a page index with its 'continue' value, used for API fetching
         *
         * @param {number} page Page index
diff --git a/modules/model/mw.echo.dm.SourcePagesModel.js 
b/modules/model/mw.echo.dm.SourcePagesModel.js
new file mode 100644
index 0000000..c5f679f
--- /dev/null
+++ b/modules/model/mw.echo.dm.SourcePagesModel.js
@@ -0,0 +1,218 @@
+( function ( mw ) {
+       /**
+        * Source pages model for notification filtering
+        *
+        * @class
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {Object} config Configuration object
+        * @cfg {string} [currentSource] The selected source for the model.
+        *  Defaults to the current wiki.
+        */
+       mw.echo.dm.SourcePagesModel = function MwEchoDmSourcePagesModel( config 
) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.sources = {};
+
+               this.currentSource = config.currentSource || mw.config.get( 
'wgDBname' );
+               this.currentPage = null;
+       };
+
+       /* Initialization */
+       OO.initClass( mw.echo.dm.SourcePagesModel );
+       OO.mixinClass( mw.echo.dm.SourcePagesModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * The state of the source page model has changed
+        */
+
+       /* Methds */
+
+       /**
+        * Set the current source and page.
+        *
+        * @param {string} source New source
+        * @param {string} page New page
+        * @fires update
+        */
+       mw.echo.dm.SourcePagesModel.prototype.setCurrentSourcePage = function ( 
source, page ) {
+               if (
+                       this.currentSource !== source ||
+                       this.currentPage !== page
+               ) {
+                       this.currentSource = source;
+                       this.currentPage = page;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get the current source
+        *
+        * @return {string} Current source
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getCurrentSource = function () {
+               return this.currentSource;
+       };
+
+       /**
+        * Get the current page or pages' id.
+        * Returns null if no page is selected.
+        *
+        * @return {number|number[]} Current page id
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getCurrentPage = function () {
+               return this.currentPage;
+       };
+       /**
+        * Get the current source
+        *
+        * @return {string} Current source
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getCurrentSource = function () {
+               return this.currentSource;
+       };
+
+       /**
+        * Get the title of the currently selected page
+        *
+        * @return {string} Page title
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getCurrentPageTitle = function () 
{
+               return this.getPageTitle(
+                       this.getCurrentSource(),
+                       this.getCurrentPage()
+               );
+       };
+
+       /**
+        * Set all sources and pages. This will also reset and override any
+        * previously set information.
+        *
+        * @param {Object} sourceData A detailed object about sources and pages
+        */
+       mw.echo.dm.SourcePagesModel.prototype.setAllSources = function ( 
sourceData ) {
+               var source;
+
+               this.reset();
+               for ( source in sourceData ) {
+                       if ( sourceData.hasOwnProperty( source ) ) {
+                               this.setSourcePagesDetails( source, sourceData[ 
source ] );
+                       }
+               }
+               this.emit( 'update' );
+       };
+
+       /**
+        * Get an array of all source names
+        *
+        * @return {string[]} Array of source names
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getSourcesArray = function () {
+               return Object.keys( this.sources );
+       };
+
+       /**
+        * Get the title of a source
+        *
+        * @param {string} source Symbolic name of the source
+        * @return {string} Source title
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getSourceTitle = function ( 
source ) {
+               return this.sources[ source ] && this.sources[ source ].title;
+       };
+
+       /**
+        * Get the total count of a source
+        *
+        * @param {string} source Symbolic name of the source
+        * @return {number} Total count
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getSourceTotalCount = function ( 
source ) {
+               return ( this.sources[ source ] && this.sources[ source 
].totalCount ) || 0;
+       };
+
+       /**
+        * Get all pages in a source
+        *
+        * @param {string} source Symbolic name of the source
+        * @return {Object} Page definitions in this source
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getSourcePages = function ( 
source ) {
+               return this.sources[ source ] && this.sources[ source ].pages;
+       };
+
+       /**
+        * Get a specific page's title
+        *
+        * @param {string} source Symbolic name for source
+        * @param {number} pageId Page ID
+        * @return {string} Page title
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getPageTitle = function ( source, 
pageId ) {
+               return this.getPageTitleById( source, pageId );
+       };
+
+       /**
+        * Get page title by the source and page ID
+        *
+        * @param {string} source Symbolic name of the source
+        * @param {number} pageId Page ID
+        * @return {string} Page title
+        */
+       mw.echo.dm.SourcePagesModel.prototype.getPageTitleById = function ( 
source, pageId ) {
+               return this.sources[ source ] &&
+                       this.sources[ source ].pages[ pageId ] &&
+                       this.sources[ source ].pages[ pageId ].title;
+       };
+
+       /**
+        * Reset the data
+        */
+       mw.echo.dm.SourcePagesModel.prototype.reset = function () {
+               this.sources = {};
+       };
+
+       /**
+        * Set the details of a source and its page definitions
+        *
+        * @private
+        * @param {string} source Source symbolic name
+        * @param {Object} details Details object
+        */
+       mw.echo.dm.SourcePagesModel.prototype.setSourcePagesDetails = function 
( source, details ) {
+               var id, pageDetails, count;
+
+               // Source information
+               this.sources[ source ] = {
+                       title: details.source.title,
+                       base: details.source.base,
+                       totalCount: 0,
+                       pages: {}
+               };
+
+               // Fill in pages
+               count = 0;
+               for ( id in details.pages ) {
+                       pageDetails = details.pages[ id ];
+                       this.sources[ source ].pages[ id ] = {
+                               title: pageDetails.title,
+                               count: pageDetails.count,
+                               id: id
+                       };
+
+                       count += parseInt( pageDetails.count );
+               }
+
+               // Update total count
+               this.sources[ source ].totalCount = count;
+       };
+} )( mediaWiki );
diff --git a/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less 
b/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less
new file mode 100644
index 0000000..f6c6fdc
--- /dev/null
+++ b/modules/styles/mw.echo.ui.CrossWikiUnreadFilterWidget.less
@@ -0,0 +1,16 @@
+@import '../echo.variables';
+
+.mw-echo-ui-crossWikiUnreadFilterWidget {
+       border: 1px solid @border-color;
+       padding: @specialpage-separation-unit;
+       width: @specialpage-sidebar-width;
+
+       &-title {
+               font-size: 1.3em;
+               font-weight: bold;
+       }
+
+       &-subtitle {
+               color: @grey-light;
+       }
+}
diff --git a/modules/styles/mw.echo.ui.NotificationsInboxWidget.less 
b/modules/styles/mw.echo.ui.NotificationsInboxWidget.less
index da0683e..1784d5c 100644
--- a/modules/styles/mw.echo.ui.NotificationsInboxWidget.less
+++ b/modules/styles/mw.echo.ui.NotificationsInboxWidget.less
@@ -1,31 +1,43 @@
 @import '../echo.variables';
 .mw-echo-ui-notificationsInboxWidget {
-       &-toolbar {
-               &-row {
-                       display: table-row;
-               }
-               &-cell {
+       &-row {
+               display: table-row;
+               width: 100%;
+       }
+       &-cell {
+               display: table-cell;
+               vertical-align: middle;
+               &-placeholder {
                        display: table-cell;
-                       vertical-align: middle;
-               }
-
-               &-top {
-                       display: table;
-                       margin-bottom: 3 * @specialpage-separation-unit;
-
-
-                       &-placeholder {
-                               display: table-cell;
-                               width: 100%;
-                       }
-               }
-
-               &-bottom {
-                       display: table;
-                       width: inherit;
-                       margin-left: auto;
-                       margin-right: auto;
-                       margin-top: 3 * @specialpage-separation-unit;
+                       width: 100%;
                }
        }
+
+       &-sidebar {
+               width: @specialpage-sidebar-width;
+               padding-right: 1em;
+               vertical-align: top;
+       }
+
+       &-main {
+               vertical-align: top;
+               width: 100%;
+
+               &-toolbar {
+                       &-top {
+                               display: table;
+                               margin-bottom: 3 * @specialpage-separation-unit;
+                               width: 100%;
+                       }
+
+                       &-bottom {
+                               display: table;
+                               width: inherit;
+                               margin-left: auto;
+                               margin-right: auto;
+                               margin-top: 3 * @specialpage-separation-unit;
+                       }
+               }
+       }
+
 }
diff --git a/modules/styles/mw.echo.ui.PageFilterWidget.less 
b/modules/styles/mw.echo.ui.PageFilterWidget.less
new file mode 100644
index 0000000..8f22f27
--- /dev/null
+++ b/modules/styles/mw.echo.ui.PageFilterWidget.less
@@ -0,0 +1,8 @@
+@import '../echo.variables';
+.mw-echo-ui-pageFilterWidget {
+       margin-top: 2 * @specialpage-separation-unit;
+
+       &-title {
+               font-weight: bold;
+       }
+}
diff --git a/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less 
b/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less
new file mode 100644
index 0000000..15b7338
--- /dev/null
+++ b/modules/styles/mw.echo.ui.PageNotificationsOptionWidget.less
@@ -0,0 +1,31 @@
+@import '../echo.variables';
+
+.mw-echo-ui-pageNotificationsOptionWidget {
+       width: 100%;
+       box-sizing: border-box;
+
+       &-icon {
+               float: left;
+               .oo-ui-iconElement-icon {
+                       display: inline-block;
+               }
+       }
+
+       &-title {
+               padding: 0.2em 0;
+       }
+
+       &-count {
+               float: right;
+               padding: 0.2em 0.5em;
+               margin-left: 0.5em;
+               background-color: #eee;
+               border-radius: 2px;
+               color: @grey-medium;
+
+               .oo-ui-optionWidget-selected & {
+                       background-color: #bbb;
+               }
+       }
+
+}
diff --git a/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js 
b/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js
new file mode 100644
index 0000000..ed2cf78
--- /dev/null
+++ b/modules/ui/mw.echo.ui.CrossWikiUnreadFilterWidget.js
@@ -0,0 +1,182 @@
+( function ( $, mw ) {
+       /**
+        * A filter for cross-wiki unread notifications
+        *
+        * @class
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {mw.echo.Controller} controller Echo controller
+        * @param {mw.echo.dm.FiltersModel} filtersModel Filter model
+        * @param {Object} [config] Configuration object
+        */
+       mw.echo.ui.CrossWikiUnreadFilterWidget = function 
MwEchoUiCrossWikiUnreadFilterWidget( controller, filtersModel, config ) {
+               var titleWidget, subtitleWidget;
+
+               config = config || {};
+
+               // Parent
+               mw.echo.ui.CrossWikiUnreadFilterWidget.parent.call( this,
+                       // Sorting callback
+                       function ( a, b ) {
+                               var diff;
+
+                               // Local source is always first
+                               if ( a.getSource() === mw.config.get( 
'wgDBname' ) ) {
+                                       return -1;
+                               } else if ( b.getSource() === mw.config.get( 
'wgDBname' ) ) {
+                                       return 1;
+                               }
+
+                               diff = Number( b.getTotalCount() ) - Number( 
a.getTotalCount() );
+                               if ( diff !== 0 ) {
+                                       return diff;
+                               }
+
+                               // Fallback on Source
+                               return b.getSource() - a.getSource();
+                       },
+                       // Config
+                       config
+               );
+               // Mixin
+               OO.ui.mixin.PendingElement.call( this, config );
+
+               this.controller = controller;
+               this.model = filtersModel;
+               this.previousPageSelected = null;
+
+               titleWidget = new OO.ui.LabelWidget( {
+                       classes: [ 
'mw-echo-ui-crossWikiUnreadFilterWidget-title' ],
+                       label: mw.msg( 'echo-specialpage-pagefilters-title' )
+               } );
+               subtitleWidget = new OO.ui.LabelWidget( {
+                       classes: [ 
'mw-echo-ui-crossWikiUnreadFilterWidget-subtitle' ],
+                       label: mw.msg( 'echo-specialpage-pagefilters-subtitle' )
+               } );
+
+               // Events
+               this.aggregate( { choose: 'pageFilterChoose' } );
+               this.connect( this, { pageFilterChoose: 'onPageFilterChoose' } 
);
+
+               // Always have a local wiki
+               this.localSource = new mw.echo.ui.PageFilterWidget(
+                       this.model.getSourcePagesModel(),
+                       mw.config.get( 'wgDBname' )
+               );
+
+               this.$element
+                       .addClass( 'mw-echo-ui-crossWikiUnreadFilterWidget' )
+                       .append(
+                               titleWidget.$element,
+                               subtitleWidget.$element,
+                               this.$group
+                                       .addClass( 
'mw-echo-ui-crossWikiUnreadFilterWidget-group' )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.echo.ui.CrossWikiUnreadFilterWidget, 
mw.echo.ui.SortedListWidget );
+       OO.mixinClass( mw.echo.ui.CrossWikiUnreadFilterWidget, 
OO.ui.mixin.PendingElement );
+
+       /* Events */
+
+       /**
+        * @event filter
+        * @param {string} source Source symbolic name
+        * @param {number} [pageId] Chosen page ID
+        *
+        * A source page filter was chosen
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to choose event in one of the page filter widgets
+        *
+        * @param {mw.echo.ui.PageFilterWidget} widget The widget the event 
originated from
+        * @param {mw.echo.ui.PageNotificationsOptionWidget} item The chosen 
item
+        * @fires filter
+        */
+       mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.onPageFilterChoose = 
function ( widget, item ) {
+               var source = widget.getSource(),
+                       pageId = item && item.getData();
+
+               if ( item ) {
+                       this.setItemSelected( item );
+                       // Emit a choice
+                       this.emit( 'filter', source, pageId );
+               }
+       };
+
+       /**
+        * Set the selected item
+        *
+        * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item to select
+        */
+       mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.setItemSelected = 
function ( item ) {
+               // Unselect the previous item
+               if ( this.previousPageSelected ) {
+                       this.previousPageSelected.setSelected( false );
+               }
+               item.setSelected( true );
+               this.previousPageSelected = item;
+       };
+
+       /**
+        * Populate the sources
+        */
+       mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.populateSources = 
function () {
+               this.pushPending();
+               this.controller.fetchUnreadPagesByWiki()
+                       .then( this.populateDataFromModel.bind( this ) )
+                       .always( this.popPending.bind( this ) );
+       };
+
+       /**
+        * Populate the widget from the model data
+        */
+       mw.echo.ui.CrossWikiUnreadFilterWidget.prototype.populateDataFromModel 
= function () {
+               var i, source, widget, selectedWidget, item,
+                       widgets = [],
+                       sourcePageModel = this.model.getSourcePagesModel(),
+                       selectedSource = sourcePageModel.getCurrentSource(),
+                       selectedPage = sourcePageModel.getCurrentPage(),
+                       sources = sourcePageModel.getSourcesArray();
+
+               for ( i = 0; i < sources.length; i++ ) {
+                       source = sources[ i ];
+                       widget = new mw.echo.ui.PageFilterWidget(
+                               sourcePageModel,
+                               source,
+                               {
+                                       title: sourcePageModel.getSourceTitle( 
source ),
+                                       unreadCount: 
sourcePageModel.getSourceTotalCount( source ),
+                                       initialSelection: 
this.previousPageSelected && this.previousPageSelected.getData()
+                               }
+                       );
+
+                       widgets.push( widget );
+                       if ( source !== mw.config.get( 'wgDBname' ) ) {
+                               this.localSource = widget;
+                       }
+               }
+
+               this.clearItems();
+               this.addItems( widgets );
+
+               // Select the current source
+               selectedWidget = this.getItemFromData( selectedSource );
+               if ( selectedPage ) {
+                       // Select a specific page
+                       item = selectedWidget.getItemFromData( selectedPage );
+               } else {
+                       // The wiki title is selected
+                       item = selectedWidget.getTitleItem();
+               }
+               this.setItemSelected( item );
+       };
+
+} )( jQuery, mediaWiki );
diff --git a/modules/ui/mw.echo.ui.DatedNotificationsWidget.js 
b/modules/ui/mw.echo.ui.DatedNotificationsWidget.js
index b61efdd..0755cad 100644
--- a/modules/ui/mw.echo.ui.DatedNotificationsWidget.js
+++ b/modules/ui/mw.echo.ui.DatedNotificationsWidget.js
@@ -73,16 +73,10 @@
         * Respond to model manager update event.
         * This event means we are repopulating the entire list and the
         * associated models within it.
-        *
-        * @param {Object} [models] Object of new models to populate the
-        *  list. If not given, the method will request all models from the
-        *  manager.
         */
        mw.echo.ui.DatedNotificationsWidget.prototype.populateFromModel = 
function ( models ) {
                var modelId, model, subgroupWidget,
                        groupWidgets = [];
-
-               models = models || this.manager.getAllNotificationModels();
 
                // Detach all attached models
                for ( modelId in this.models ) {
diff --git a/modules/ui/mw.echo.ui.NotificationsInboxWidget.js 
b/modules/ui/mw.echo.ui.NotificationsInboxWidget.js
index 93bded3..a36d9d4 100644
--- a/modules/ui/mw.echo.ui.NotificationsInboxWidget.js
+++ b/modules/ui/mw.echo.ui.NotificationsInboxWidget.js
@@ -14,6 +14,8 @@
         * @cfg {jQuery} [$overlay] An overlay for the popup menus
         */
        mw.echo.ui.NotificationsInboxWidget = function 
MwEchoUiNotificationsInboxWidget( controller, manager, config ) {
+               var $main, $sidebar;
+
                config = config || {};
 
                // Parent
@@ -59,8 +61,15 @@
                // Filter by read state
                this.readStateSelectWidget = new 
mw.echo.ui.ReadStateButtonSelectWidget();
 
+               // Sidebar filters
+               this.xwikiUnreadWidget = new 
mw.echo.ui.CrossWikiUnreadFilterWidget(
+                       this.controller,
+                       this.manager.getFiltersModel()
+               );
+
                // Events
                this.readStateSelectWidget.connect( this, { filter: 
'onReadStateFilter' } );
+               this.xwikiUnreadWidget.connect( this, { filter: 
'onSourcePageFilter' } );
                this.manager.getFiltersModel().connect( this, { update: 
'updateReadStateSelectWidget' } );
                this.topPaginationWidget.connect( this, { change: 
'populateNotifications' } );
                this.bottomPaginationWidget.connect( this, { change: 
'populateNotifications' } );
@@ -69,37 +78,41 @@
                this.bottomPaginationWidget.setDisabled( true );
 
                // Initialization
-               this.$element
-                       .addClass( 'mw-echo-ui-notificationsInboxWidget' )
+               $sidebar = $( '<div>' )
+                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-sidebar' )
+                       .append( this.xwikiUnreadWidget.$element );
+
+               $main = $( '<div>' )
+                       .addClass( 'mw-echo-ui-notificationsInboxWidget-main' )
                        .append(
                                $( '<div>' )
-                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-toolbar-top' )
+                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-main-toolbar-top' )
                                        .append(
                                                $( '<div>' )
-                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-toolbar-row' )
+                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-row' )
                                                        .append(
                                                                $( '<div>' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-readState' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' )
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-readState' )
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-cell' )
                                                                        
.append( this.readStateSelectWidget.$element ),
                                                                $( '<div>' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-top-placeholder' ),
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-cell-placeholder' ),
                                                                $( '<div>' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-pagination' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' )
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-main-toolbar-pagination' )
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-cell' )
                                                                        
.append( this.topPaginationWidget.$element )
                                                        )
                                        ),
                                this.noticeMessageWidget.$element,
                                this.datedListWidget.$element,
                                $( '<div>' )
-                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-toolbar-bottom' )
+                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-main-toolbar-bottom' )
                                        .append(
                                                $( '<div>' )
-                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-toolbar-row' )
+                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-row' )
                                                        .append(
                                                                $( '<div>' )
-                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-toolbar-cell' )
+                                                                       
.addClass( 'mw-echo-ui-notificationsInboxWidget-cell' )
                                                                        .append(
                                                                                
this.bottomPaginationWidget.$element
                                                                        )
@@ -107,7 +120,21 @@
                                        )
                        );
 
+               this.$element
+                       .addClass( 'mw-echo-ui-notificationsInboxWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-row' )
+                                       .append(
+                                               $sidebar
+                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-cell' ),
+                                               $main
+                                                       .addClass( 
'mw-echo-ui-notificationsInboxWidget-cell' )
+                                       )
+                       );
+
                this.updateReadStateSelectWidget();
+               this.xwikiUnreadWidget.populateSources();
                this.populateNotifications();
        };
 
@@ -128,6 +155,17 @@
        };
 
        /**
+        * Respond to unread page filter
+        *
+        * @param {string} source Source symbolic name
+        * @param {number} pageId Page Id
+        */
+       mw.echo.ui.NotificationsInboxWidget.prototype.onSourcePageFilter = 
function ( source, pageId ) {
+               this.controller.setFilter( 'sourcePage', source, pageId );
+               this.populateNotifications();
+       };
+
+       /**
         * Respond to read state filter event
         *
         * @param {string} readState Read state 'all', 'read' or 'unread'
diff --git a/modules/ui/mw.echo.ui.PageFilterWidget.js 
b/modules/ui/mw.echo.ui.PageFilterWidget.js
new file mode 100644
index 0000000..c8a0379
--- /dev/null
+++ b/modules/ui/mw.echo.ui.PageFilterWidget.js
@@ -0,0 +1,170 @@
+( function ( $, mw ) {
+       /**
+        * A widget that displays wikis and their pages to choose a filter
+        *
+        * @class
+        * @extends OO.ui.SelectWidget
+        *
+        * @constructor
+        * @param {mw.echo.dm.FiltersModel} filterModel Filters model
+        * @param {string} source Symbolic name for the source
+        * @param {Object} [config] Configuration object
+        * @cfg {string} [title] The title of this page group, usually
+        *  the name of the wiki that the pages belong to
+        * @cfg {number} [unreadCount] Number of unread notifications
+        * @cfg {number} [initialSelection] The pageId of an initial selection
+        */
+       mw.echo.ui.PageFilterWidget = function MwEchoUiPageFilterWidget( 
filterModel, source, config ) {
+               config = config || {};
+
+               // Parent
+               mw.echo.ui.PageFilterWidget.parent.call( this, config );
+
+               this.model = filterModel;
+               this.source = source;
+               // This is to be able to fetch and recognize this widget
+               // according to its source. The source is, in this case,
+               // unique per filter widget.
+               this.data = this.source;
+               this.totalCount = config.unreadCount || 
this.model.getSourceTotalCount( this.source );
+
+               this.initialSelection = config.initialSelection;
+
+               // Title option
+               this.title = new mw.echo.ui.PageNotificationsOptionWidget( {
+                       label: config.title,
+                       unreadCount: this.totalCount,
+                       data: null,
+                       classes: [ 'mw-echo-ui-pageFilterWidget-title' ]
+               } );
+
+               // Initialization
+               this.populateDataFromModel();
+               this.$element
+                       .addClass( 'mw-echo-ui-pageFilterWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.echo.ui.PageFilterWidget, OO.ui.SelectWidget );
+
+       /**
+        * Set the total count of this group
+        *
+        * @param {number} count Total count
+        */
+       mw.echo.ui.PageFilterWidget.prototype.setTotalCount = function ( count 
) {
+               this.totalCount = count;
+               this.title.setCount( this.totalCount );
+       };
+
+       /**
+        * Set the total count of this group
+        *
+        * @return {number} Total count
+        */
+       mw.echo.ui.PageFilterWidget.prototype.getTotalCount = function () {
+               return this.totalCount;
+       };
+
+       /**
+        * Populate the widget from the model
+        */
+       mw.echo.ui.PageFilterWidget.prototype.populateDataFromModel = function 
() {
+               var id, title, widget,
+                       optionWidgets = [],
+                       sourcePages = this.model.getSourcePages( this.source );
+
+               for ( id in sourcePages ) {
+                       title = this.model.getPageTitle( this.source, id );
+                       if ( !title ) {
+                               continue;
+                       }
+                       widget = new mw.echo.ui.PageNotificationsOptionWidget( {
+                               label: title,
+                               // TODO: Pages that are a user page should
+                               // have a user icon
+                               icon: 'article',
+                               unreadCount: sourcePages[ id ].count,
+                               // TODO: When we group pages, this should be
+                               // an array of IDs
+                               data: id,
+                               classes: [ 'mw-echo-ui-pageFilterWidget-page' ]
+                       } );
+                       optionWidgets.push( widget );
+
+                       if ( this.initialSelection === title ) {
+                               widget.setSelected( true );
+                       }
+               }
+
+               this.setItems( optionWidgets );
+       };
+
+       /**
+        * Get the source associated with this filter
+        *
+        * @return {string} Source symbolic name
+        */
+       mw.echo.ui.PageFilterWidget.prototype.getSource = function () {
+               return this.source;
+       };
+
+       /**
+        * Get the title item
+        *
+        * @return {mw.echo.ui.PageNotificationsOptionWidget} Page title item
+        */
+       mw.echo.ui.PageFilterWidget.prototype.getTitleItem = function () {
+               return this.title;
+       };
+
+       /**
+        * Set the page items in this widget, in order
+        *
+        * @param {mw.echo.ui.PageNotificationsOptionWidget[]} items Item 
widgets to order and insert
+        */
+       mw.echo.ui.PageFilterWidget.prototype.setItems = function ( items ) {
+               var i, index;
+
+               this.clearItems();
+               for ( i = 0; i < items.length; i++ ) {
+                       index = this.findInsertionIndex( items[ i ] );
+                       this.addItems( [ items[ i ] ], index );
+               }
+
+               // Add the title on top
+               this.addItems( [ this.title ], 0 );
+       };
+
+       /**
+        * Find the proper insertion index for ordering when inserting items
+        *
+        * @private
+        * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item widget
+        * @return {number} Insertion index
+        */
+       mw.echo.ui.PageFilterWidget.prototype.findInsertionIndex = function ( 
item ) {
+               var widget = this;
+
+               return OO.binarySearch(
+                       this.items,
+                       function ( otherItem ) {
+                               return widget.sortingFunction( item, otherItem 
);
+                       },
+                       true
+               );
+       };
+
+       /**
+        * Sorting function for item insertion
+        *
+        * @private
+        * @param {mw.echo.ui.PageNotificationsOptionWidget} item Item widget
+        * @param {mw.echo.ui.PageNotificationsOptionWidget} otherItem Another 
item widget
+        * @return {number} Ordering value
+        */
+       mw.echo.ui.PageFilterWidget.prototype.sortingFunction = function ( 
item, otherItem ) {
+               return Number( otherItem.getCount() ) - Number( item.getCount() 
);
+       };
+} )( jQuery, mediaWiki );
diff --git a/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js 
b/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js
new file mode 100644
index 0000000..e3283f4
--- /dev/null
+++ b/modules/ui/mw.echo.ui.PageNotificationsOptionWidget.js
@@ -0,0 +1,76 @@
+( function ( $, mw ) {
+       /**
+        * An option widget for the page filter in PageFilterWidget
+        *
+        * @class
+        * @extends OO.ui.OptionWidget
+        * @mixins OO.ui.mixin.IconElement
+        *
+        * @constructor
+        * @param {Object} [config] Configuration object
+        * @cfg {number} [unreadCount] Number of unread notifications
+        */
+       mw.echo.ui.PageNotificationsOptionWidget = function 
MwEchoUiPageNotificationsOptionWidget( config ) {
+               config = config || {};
+
+               // Parent
+               mw.echo.ui.PageNotificationsOptionWidget.parent.call( this, 
config );
+               // Mixin constructors
+               OO.ui.mixin.IconElement.call( this, config );
+
+               this.count = config.unreadCount || 0;
+
+               this.$label
+                       .addClass( 
'mw-echo-ui-pageNotificationsOptionWidget-title-label' );
+
+               this.unreadCountLabel = new OO.ui.LabelWidget( {
+                       classes: [ 
'mw-echo-ui-pageNotificationsOptionWidget-label-count' ],
+                       label: String( this.count )
+               } );
+
+               // Initialization
+               this.$element
+                       .addClass( 'mw-echo-ui-pageNotificationsOptionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 
'mw-echo-ui-pageNotificationsOptionWidget-count' )
+                                       .append( this.unreadCountLabel.$element 
),
+                               $( '<div>' )
+                                       .addClass( 
'mw-echo-ui-pageNotificationsOptionWidget-title' )
+                                       .append( this.$label )
+                       );
+
+               if ( this.getIcon() ) {
+                       this.$element.prepend(
+                               $( '<div>' )
+                                       .addClass( 
'mw-echo-ui-pageNotificationsOptionWidget-icon' )
+                                       .append( this.$icon )
+                       );
+               }
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( mw.echo.ui.PageNotificationsOptionWidget, 
OO.ui.OptionWidget );
+       OO.mixinClass( mw.echo.ui.PageNotificationsOptionWidget, 
OO.ui.mixin.IconElement );
+
+       /**
+        * Set the page count
+        *
+        * @param {number} count Page count
+        */
+       mw.echo.ui.PageNotificationsOptionWidget.prototype.setCount = function 
( count ) {
+               this.count = count;
+               this.unreadCountLabel.setLabel( this.count );
+       };
+
+       /**
+        * Get the page count
+        *
+        * @return {number} Page count
+        */
+       mw.echo.ui.PageNotificationsOptionWidget.prototype.getCount = function 
() {
+               return this.count;
+       };
+
+} )( jQuery, mediaWiki );
diff --git a/modules/ui/mw.echo.ui.SortedListWidget.js 
b/modules/ui/mw.echo.ui.SortedListWidget.js
index 99fde36..9316d78 100644
--- a/modules/ui/mw.echo.ui.SortedListWidget.js
+++ b/modules/ui/mw.echo.ui.SortedListWidget.js
@@ -81,6 +81,26 @@
        };
 
        /**
+        * Get an item by its data.
+        *
+        * @param {string} data Item data to search for
+        * @return {OO.ui.Element|null} Item with equivalent data, `null` if 
none exists
+        */
+       mw.echo.ui.SortedListWidget.prototype.getItemFromData = function ( data 
) {
+               var i, len, item,
+                       hash = OO.getHash( data );
+
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       item = this.items[ i ];
+                       if ( hash === OO.getHash( item.getData() ) ) {
+                               return item;
+                       }
+               }
+
+               return null;
+       };
+
+       /**
         * Remove items.
         *
         * @param {OO.EventEmitter[]} items Items to remove
diff --git a/modules/ui/mw.echo.ui.SubGroupListWidget.js 
b/modules/ui/mw.echo.ui.SubGroupListWidget.js
index 1ccd13f..ec8de33 100644
--- a/modules/ui/mw.echo.ui.SubGroupListWidget.js
+++ b/modules/ui/mw.echo.ui.SubGroupListWidget.js
@@ -262,13 +262,13 @@
        };
 
        /**
-        * Get the group id, which is represented by its source.
+        * Get the group id, which is represented by its model symbolic name.
         * This is meant for sorting callbacks that fallback on
         * sorting by IDs.
         *
         * @return {string} Group source
         */
        mw.echo.ui.SubGroupListWidget.prototype.getId = function () {
-               return this.getSource();
+               return this.model.getName();
        };
 } )( mediaWiki, jQuery );

-- 
To view, visit https://gerrit.wikimedia.org/r/292600
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I57d827a47f80274d75364c2099a9624049a26834
Gerrit-PatchSet: 24
Gerrit-Project: mediawiki/extensions/Echo
Gerrit-Branch: master
Gerrit-Owner: Mooeypoo <mor...@gmail.com>
Gerrit-Reviewer: Catrope <roan.katt...@gmail.com>
Gerrit-Reviewer: Mooeypoo <mor...@gmail.com>
Gerrit-Reviewer: Sbisson <sbis...@wikimedia.org>
Gerrit-Reviewer: Siebrand <siebr...@kitano.nl>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to