Mooeypoo has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/234451

Change subject: [wip] Use viewmodel in Echo's front end
......................................................................

[wip] Use viewmodel in Echo's front end

Change-Id: Ia3b220b373b70b1419fecd6d0edb1d7df1af4417
---
M Resources.php
M modules/ext.echo.init.js
M modules/ooui/mw.echo.ui.NotificationBadgeWidget.js
M modules/ooui/mw.echo.ui.NotificationOptionWidget.js
M modules/ooui/mw.echo.ui.NotificationsWidget.js
A modules/viewmodel/mw.echo.dm.List.js
A modules/viewmodel/mw.echo.dm.NotificationItem.js
A modules/viewmodel/mw.echo.dm.NotificationList.js
A modules/viewmodel/mw.echo.dm.NotificationsModel.js
A modules/viewmodel/mw.echo.dm.js
10 files changed, 1,010 insertions(+), 383 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Echo 
refs/changes/51/234451/1

diff --git a/Resources.php b/Resources.php
index 8804322..6b73324 100644
--- a/Resources.php
+++ b/Resources.php
@@ -54,6 +54,7 @@
                ),
                'dependencies' => array(
                        'ext.echo.nojs',
+                       'ext.echo.dm',
                        'oojs-ui',
                        'ext.echo.logger',
                        'mediawiki.api',
@@ -72,6 +73,19 @@
                ),
                'targets' => array( 'desktop', 'mobile' ),
        ),
+       'ext.echo.dm' => $echoResourceTemplate + array(
+               'scripts' => array(
+                       'viewmodel/mw.echo.dm.js',
+                       'viewmodel/mw.echo.dm.NotificationItem.js',
+                       'viewmodel/mw.echo.dm.List.js',
+                       'viewmodel/mw.echo.dm.NotificationList.js',
+                       'viewmodel/mw.echo.dm.NotificationsModel.js',
+               ),
+               'dependencies' => array(
+                       'oojs'
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       ),
        'ext.echo.base' => array(
                // This is a dummy module for backwards compatibility.
                // Most extensions that require ext.echo.base actually need
diff --git a/modules/ext.echo.init.js b/modules/ext.echo.init.js
index d0d9c2e..af57e2c 100644
--- a/modules/ext.echo.init.js
+++ b/modules/ext.echo.init.js
@@ -28,7 +28,7 @@
                if ( $existingMessageLink.length ) {
                        mw.echo.ui.messageWidget = new 
mw.echo.ui.NotificationBadgeWidget( {
                                type: 'message',
-                               markReadWhenSeen: false,
+                               markReadWhenSeen: true,//false,
                                numItems: numMessages,
                                hasUnread: hasUnreadMessages,
                                badgeIcon: 'speechBubble',
diff --git a/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js 
b/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js
index 989b765..2aced3b 100644
--- a/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js
+++ b/modules/ooui/mw.echo.ui.NotificationBadgeWidget.js
@@ -39,10 +39,25 @@
                        buttonFlags.push( 'unseen' );
                }
 
-               this.notificationsWidget = new mw.echo.ui.NotificationsWidget( {
+               // View model
+               this.notificationsModel = new mw.echo.dm.NotificationsModel( {
                        type: this.type,
-                       markReadWhenSeen: this.markReadWhenSeen
+                       limit: 25,
+                       userLang: mw.config.get( 'wgUserLanguage' )
                } );
+
+               // Notifications widget
+               this.notificationsWidget = new mw.echo.ui.NotificationsWidget(
+                       this.notificationsModel,
+                       {
+                               type: this.type,
+                               markReadWhenSeen: this.markReadWhenSeen
+                       }
+               );
+
+               // Mixin constructors
+               OO.ui.mixin.PendingElement.call( this, config );
+               this.setPendingElement( this.notificationsWidget.$element );
 
                // Footer
                allNotificationsButton = new OO.ui.ButtonWidget( {
@@ -98,6 +113,7 @@
                        label: mw.msg( 'echo-mark-all-as-read' ),
                        classes: [ 
'mw-echo-ui-notificationsWidget-markAllReadButton' ]
                } );
+
                // Hide the close button
                this.popup.closeButton.toggle( false );
                // Add the 'mark all as read' button to the header
@@ -105,12 +121,12 @@
                this.markAllReadButton.toggle( !this.markReadWhenSeen && 
this.hasUnread );
 
                // Events
-               this.notificationsWidget.connect( this, {
-                       seen: 'onNotificationsSeen',
-                       update: 'onNotificationsUpdate',
-                       readChange: 'onNotificationsReadChange'
-               } );
                this.markAllReadButton.connect( this, { click: 
'onMarkAllReadButtonClick' } );
+               this.notificationsModel.connect( this, {
+                       updateSeenTime: 'onModelUpdateSeenTime',
+                       unseenChange: 'onModelUnseenChange',
+                       unreadChange: 'onModelUnreadChange'
+               } );
 
                this.$element
                        .addClass(
@@ -122,49 +138,35 @@
        /* Initialization */
 
        OO.inheritClass( mw.echo.ui.NotificationBadgeWidget, 
OO.ui.PopupButtonWidget );
+       OO.mixinClass( mw.echo.ui.NotificationBadgeWidget, 
OO.ui.mixin.PendingElement );
+
+       mw.echo.ui.NotificationBadgeWidget.prototype.onModelUpdateSeenTime = 
function () {
+               this.setFlags( { unseen: false } );
+       };
+
+       /**
+        * Respond to model unseen change
+        *
+        * @param {mw.echo.dm.NotificationItems[]} unseenNotifications Array of 
unseen notifications
+        */
+       mw.echo.ui.NotificationBadgeWidget.prototype.onModelUnseenChange = 
function ( unseenNotifications ) {
+               this.setFlags( { unseen: !!unseenNotifications.length } );
+       };
+
+       /**
+        * Respond to model unread change
+        *
+        * @param {mw.echo.dm.NotificationItems[]} unreadNotifications Array of 
unread notifications
+        */
+       mw.echo.ui.NotificationBadgeWidget.prototype.onModelUnreadChange = 
function ( unreadNotifications ) {
+               this.setLabel( String( unreadNotifications.length ) );
+       };
 
        /**
         * Respond to 'mark all as read' button click
         */
        mw.echo.ui.NotificationBadgeWidget.prototype.onMarkAllReadButtonClick = 
function () {
-               this.notificationsWidget.markAllRead()
-                       .then( this.updateUnreadCount.bind( this ) );
-       };
-
-       /**
-        * Respond to seen time change
-        */
-       mw.echo.ui.NotificationBadgeWidget.prototype.onNotificationsSeen = 
function () {
-               // Change the status of the button
-               this.setFlags( { unseen: false } );
-       };
-
-       /**
-        * Respond to a change in notification read state
-        */
-       mw.echo.ui.NotificationBadgeWidget.prototype.onNotificationsReadChange 
= function ( unreadCount ) {
-               this.updateUnreadCount( unreadCount );
-       };
-
-       /**
-        * Update unread count and state
-        *
-        * @param {number} unreadCount Unread notification count
-        */
-       mw.echo.ui.NotificationBadgeWidget.prototype.updateUnreadCount = 
function ( unreadCount ) {
-               var hasUnread = unreadCount > 0;
-
-               this.setLabel( String( unreadCount ) );
-               this.setFlags( { unseen: 
!!this.notificationsWidget.getUnseenCount() } );
-               this.hasUnread = hasUnread;
-               this.markAllReadButton.toggle( hasUnread );
-       };
-
-       /**
-        * Respond to update event of the notifications widget
-        */
-       mw.echo.ui.NotificationBadgeWidget.prototype.onNotificationsUpdate = 
function () {
-               this.updateUnreadCount( 
this.notificationsWidget.getUnreadCount() );
+               this.notificationsModel.markAllRead();
        };
 
        /**
@@ -184,50 +186,33 @@
                        this.type
                );
 
-               // Refresh the notifications
                if ( !this.updated ) {
-                       this.notificationsWidget.fetchNotifications()
-                               .then( function ( optionWidgets ) {
-                                       var i, len,
-                                               idArray = [];
-
+                       this.pushPending();
+                       this.notificationsModel.fetchNotifications()
+                               .then( function ( idArray ) {
                                        // Clip again
                                        widget.popup.clip();
-
-                                       // Gather ids
-                                       for ( i = 0, len = 
optionWidgets.length; i < len; i++ ) {
-                                               idArray.push( 
optionWidgets[i].getData() );
-                                       }
 
                                        // Log impressions
                                        
mw.echo.logger.logNotificationImpressions( this.type, idArray, 
mw.echo.Logger.static.context.popup );
 
-                                       // Mark notifications as 'read' if 
markReadWhenSeen is set to true
+                                       // // Mark notifications as 'read' if 
markReadWhenSeen is set to true
                                        if ( widget.markReadWhenSeen ) {
-                                               return 
widget.notificationsWidget.markAllRead()
-                                                       .then( 
widget.updateUnreadCount.bind( widget ) );
+                                               return 
widget.notificationsModel.markAllRead();
                                        }
                                } )
                                .then( function () {
                                        // Update seen time
-                                       
widget.notificationsWidget.updateSeenTime();
+                                       
widget.notificationsModel.updateSeenTime();
+                               } )
+                               .always( function () {
+                                       widget.popPending();
                                } );
                        this.updated = true;
                } else {
                        // Update seen time
-                       this.notificationsWidget.updateSeenTime();
+                       widget.notificationsModel.updateSeenTime();
                }
-       };
-
-       /**
-        * Update unread count of the notifications in this widget
-        *
-        * @return {jQuery.Promise} jQuery promise that's resolved when the 
unread count is fetched
-        *  and the badge label is updated.
-        */
-       mw.echo.ui.NotificationBadgeWidget.prototype.fetchUnreadCountFromApi = 
function () {
-               return this.notificationsWidget.fetchUnreadCountFromApi()
-                       .then( this.updateUnreadCount.bind( this ) );
        };
 
 } )( mediaWiki, jQuery );
diff --git a/modules/ooui/mw.echo.ui.NotificationOptionWidget.js 
b/modules/ooui/mw.echo.ui.NotificationOptionWidget.js
index d181229..a6d011e 100644
--- a/modules/ooui/mw.echo.ui.NotificationOptionWidget.js
+++ b/modules/ooui/mw.echo.ui.NotificationOptionWidget.js
@@ -1,4 +1,4 @@
-( function ( mw ) {
+( function ( mw, $ ) {
        /**
         * Notification option widget for echo popup.
         *
@@ -7,17 +7,15 @@
         *
         * @constructor
         * @param {Object} [config] Configuration object
-        * @cfg {boolean} [read=false] State the read state of the option
-        * @cfg {boolean} [seen=false] State the seen state of the option
         * @cfg {boolean} [markReadWhenSeen=false] This option is marked as 
read when it is viewed
-        * @cfg {string} [link] The link this option leads to
-        * @cfg {number} [timestamp=now] The timestamp of the notification
         */
-       mw.echo.ui.NotificationOptionWidget = function 
MwEchoUiNotificationOptionWidget( config ) {
+       mw.echo.ui.NotificationOptionWidget = function 
MwEchoUiNotificationOptionWidget( model, config ) {
                config = config || {};
 
+               this.model = model;
+
                // Parent constructor
-               mw.echo.ui.NotificationOptionWidget.parent.call( this, config );
+               mw.echo.ui.NotificationOptionWidget.parent.call( this, 
$.extend( { data: this.model.getId() }, config ) );
 
                this.markAsReadButton = new OO.ui.ButtonWidget( {
                        icon: 'close',
@@ -25,13 +23,12 @@
                        classes: [ 
'mw-echo-ui-notificationOptionWidget-markAsReadButton' ]
                } );
 
-               this.toggleRead( !!config.read );
-               this.toggleSeen( !!config.seen );
+               this.setLabel( this.model.getContent() );
+
+               this.toggleRead( this.model.isRead() );
+               this.toggleSeen( this.model.isSeen() );
 
                this.markReadWhenSeen = !!config.markReadWhenSeen;
-               this.link = config.link;
-
-               this.setTimestamp( config.timestamp || ( Date.now() / 1000 ) );
 
                // Events
                this.markAsReadButton.connect( this, { click: [ 'emit', 
'markAsRead' ] } );
@@ -47,6 +44,11 @@
                        this.$element.addClass( 
'mw-echo-ui-notificationOptionWidget-markReadWhenSeen' );
                }
 
+               // Events
+               this.model.connect( this, {
+                       seen: 'toggleSeen',
+                       read: 'toggleRead'
+               } );
        };
 
        /* Initialization */
@@ -87,37 +89,6 @@
 
                this.$element
                        .toggleClass( 
'mw-echo-ui-notificationOptionWidget-unseen', !this.seen );
-
-               if ( this.markReadWhenSeen && seen ) {
-                       this.toggleRead( true );
-               }
-       };
-
-       /**
-        * Set the notification timestamp
-        *
-        * @param {number} timestamp Notification timestamp
-        */
-       mw.echo.ui.NotificationOptionWidget.prototype.setTimestamp = function ( 
timestamp ) {
-               this.timestamp = timestamp;
-       };
-
-       /**
-        * Get the notification timestamp
-        *
-        * @return {number} Notification timestamp
-        */
-       mw.echo.ui.NotificationOptionWidget.prototype.getTimestamp = function 
() {
-               return this.timestamp;
-       };
-
-       /**
-        * Set the notification link
-        *
-        * @param {string} link Notification link
-        */
-       mw.echo.ui.NotificationOptionWidget.prototype.setLink = function ( link 
) {
-               this.link = link;
        };
 
        /**
@@ -125,7 +96,24 @@
         *
         * @return {string} Notification link
         */
-       mw.echo.ui.NotificationOptionWidget.prototype.getLink = function () {
-               return this.link;
+       mw.echo.ui.NotificationOptionWidget.prototype.getModel = function () {
+               return this.model;
        };
-} )( mediaWiki );
+
+       /**
+        * Get the notification link
+        *
+        * @return {string} Notification link
+        */
+       mw.echo.ui.NotificationOptionWidget.prototype.getPrimaryLink = function 
() {
+               return this.model.getPrimaryLink();
+       };
+
+       /**
+        * Disconnect events when widget is destroyed.
+        */
+       mw.echo.ui.NotificationOptionWidget.prototype.destroy = function () {
+               this.model.disconnect( this );
+       };
+
+} )( mediaWiki, jQuery );
diff --git a/modules/ooui/mw.echo.ui.NotificationsWidget.js 
b/modules/ooui/mw.echo.ui.NotificationsWidget.js
index a5b083f..f15306a 100644
--- a/modules/ooui/mw.echo.ui.NotificationsWidget.js
+++ b/modules/ooui/mw.echo.ui.NotificationsWidget.js
@@ -6,85 +6,123 @@
         * @extends OO.ui.Widget
         *
         * @constructor
+        * @param {mw.echo.dm.NotificationsModel} model Notifications view model
         * @param {Object} [config] Configuration object
-        * @cfg {string} [type='alert'] Notification type
         * @cfg {boolean} [markReadWhenSeen=false] State whether the 
notifications are all
         *  marked as read when they are seen.
-        * @cfg {number} [limit=15] Notification limit
         */
-       mw.echo.ui.NotificationsWidget = function MwEchoUiNotificationsWidget( 
config ) {
+       mw.echo.ui.NotificationsWidget = function MwEchoUiNotificationsWidget( 
model, config ) {
                config = config || {};
 
-               this.seenTime = mw.user.options.get( 'echo-seen-time' );
+               this.model = model;
+
                this.markReadWhenSeen = !!config.markReadWhenSeen;
-
-               this.type = config.type || 'alert';
-               this.limit = config.limit || 25;
-
-               this.howManyUnread = 0;
-               this.howManyUnseen = 0;
-               this.userLang = mw.config.get( 'wgUserLanguage' );
 
                // Parent constructor
                mw.echo.ui.NotificationsWidget.parent.call( this, config );
-
-               // Mixin constructors
-               OO.ui.mixin.PendingElement.call( this, config );
-
-               this.api = new mw.Api( { ajax: { cache: false } } );
-
-               // Notification list
-               this.notifications = new OO.ui.SelectWidget( {
-                       classes: [ 'mw-echo-ui-notificationsWidget-list' ]
-               } );
-               this.setPendingElement( this.notifications.$element );
 
                // Dummy 'loading' option widget
                this.loadingOptionWidget = new OO.ui.OptionWidget( {
                        data: null,
                        classes: [ 
'mw-echo-ui-notificationsWidget-loadingOption' ]
                } );
-               this.notifications.addItems( [ this.loadingOptionWidget ] );
+               this.addItems( [ this.loadingOptionWidget ] );
 
-               // Event
-               this.notifications.aggregate( { markAsRead: 
'notificationMarkAsRead' } );
-               this.notifications.connect( this, {
-                       choose: 'onNotificationChoose',
-                       notificationMarkAsRead: 'onNotificationMarkAsRead'
+               // Events
+               this.model.connect( this, {
+                       notificationSeen: 'onModelNotificationSeen',
+                       notificationRead: 'onModelNotificationRead',
+                       add: 'onModelNotificationAdd'
+               } );
+               this.connect( this, {
+                       choose: 'onNotificationChoose'
                } );
 
                this.$element
-                       .addClass( 'mw-echo-ui-notificationsWidget' )
-                       .append( this.notifications.$element );
+                       .addClass( 'mw-echo-ui-notificationsWidget' );
        };
 
        /* Initialization */
 
-       OO.inheritClass( mw.echo.ui.NotificationsWidget, OO.ui.Widget );
-       OO.mixinClass( mw.echo.ui.NotificationsWidget, 
OO.ui.mixin.PendingElement );
-
-       /* Events */
-
-       /**
-        * @event seen
-        * Change the seen timestamp for this user
-        *
-        * @param {number} seenTime Mediawiki timestamp for when the user has 
seen notifications
-        */
-
-       /**
-        * @event update
-        * Notifications were updated
-        */
-
-       /**
-        * @event readChange
-        * @param {number} howManyUnread How many unread notifications still 
exist
-        *
-        * Notification's read status has changed
-        */
+       OO.inheritClass( mw.echo.ui.NotificationsWidget, OO.ui.SelectWidget );
+       // OO.mixinClass( mw.echo.ui.NotificationsWidget, 
OO.ui.mixin.PendingElement );
 
        /* Methods */
+
+       mw.echo.ui.NotificationsWidget.prototype.onModelNotificationSeen = 
function () {};
+       mw.echo.ui.NotificationsWidget.prototype.onModelNotificationRead = 
function () {};
+
+       /**
+        * Respond to model add event
+        *
+        * @param {mw.echo.dm.NotificationItem[]} Added notification items
+        */
+       mw.echo.ui.NotificationsWidget.prototype.onModelNotificationAdd = 
function ( notificationItems, index ) {
+               var i, len, itemCount,
+                       optionWidgets = [];
+
+               // Add option widgets (in reverse order)
+               for ( i = 0, len = notificationItems.length; i < len; i++ ) {
+                       optionWidgets.push(
+                               new mw.echo.ui.NotificationOptionWidget(
+                                       notificationItems[i],
+                                       {
+                                               markReadWhenSeen: 
this.markReadWhenSeen
+                                       }
+                               )
+                       );
+               }
+
+               // Remove dummy option
+               this.removeItems( [ this.loadingOptionWidget ] );
+
+               this.addItems( optionWidgets, index );
+       };
+
+       /**
+        * Respond to model add event
+        *
+        * @param {mw.echo.dm.NotificationItem[]} Removed notification items
+        */
+       mw.echo.ui.NotificationsWidget.prototype.onModelNotificationClear = 
function () {
+               var i, len,
+                       items = this.getItems();
+
+               // Destroy all the widgets and their events
+               for ( i = 0, len = items.length; i < len; i++ ) {
+                       items[i].destroy();
+               }
+
+               this.clearItems();
+
+               // Add dummy option
+               this.addItems( [ this.loadingOptionWidget ] );
+       };
+
+       /**
+        * Respond to model add event
+        *
+        * @param {mw.echo.dm.NotificationItem[]} Removed notification items
+        */
+       mw.echo.ui.NotificationsWidget.prototype.onModelNotificationRemove = 
function ( notificationItems ) {
+               var i, len, widget,
+                       removalWidgets = [];
+
+               for ( i = 0, len = notificationItems.length; i < len; i++ ) {
+                       widget = this.getItemById( notificationItems[i].getId() 
);
+                       if ( widget ) {
+                               widget.destroy();
+                               removalWidgets.push( widget );
+                       }
+               }
+
+               this.removeItems( removalWidgets );
+
+               if ( !this.getItemCount() ) {
+                       // Add dummy option
+                       this.addItems( [ this.loadingOptionWidget ] );
+               }
+       };
 
        /**
         * Respond to notification 'mark as read' event
@@ -94,67 +132,24 @@
         * @fires readChange
         */
        mw.echo.ui.NotificationsWidget.prototype.onNotificationMarkAsRead = 
function ( notification ) {
-               var widget = this,
-                       id = notification ? notification.getData() : null,
-                       data = {
-                               action: 'echomarkread',
-                               uselang: this.userLang,
-                               list: id
-                       };
+               // var widget = this,
+               //      id = notification ? notification.getData() : null,
+               //      data = {
+               //              action: 'echomarkread',
+               //              uselang: this.userLang,
+               //              list: id
+               //      };
 
-               if ( !id ) {
-                       return $.Deferred().reject().promise();
-               }
+               // if ( !id ) {
+               //      return $.Deferred().reject().promise();
+               // }
 
-               return this.api.postWithToken( 'edit', data )
-                       .then( function () {
-                               notification.toggleRead( true );
-                               widget.howManyUnread--;
-                               widget.emit( 'readChange', widget.howManyUnread 
);
-                       } );
-       };
-
-       /**
-        * Fetch the unread count from the API
-        *
-        * @return {jQuery.Promise} Promise that is resolved with the unread 
notification count
-        */
-       mw.echo.ui.NotificationsWidget.prototype.fetchUnreadCountFromApi = 
function () {
-               var widget = this,
-                       apiData = {
-                               action: 'query',
-                               meta: 'notifications',
-                               notsections: this.type,
-                               notmessageunreadfirst: 1,
-                               notlimit: this.limit,
-                               notprop: 'index|count',
-                               uselang: this.userLang
-                       };
-
-               return this.api.get( apiData )
-                       .then( function ( result ) {
-                               var unread = OO.getProp( result.query, 
'notifications', 'rawcount' ) || 0;
-                               widget.howManyUnread = unread;
-                               return widget.howManyUnread;
-                       } );
-       };
-
-       /**
-        * Get the stored number of unread notifications.
-        *
-        * @return {number} Number of unread notifications
-        */
-       mw.echo.ui.NotificationsWidget.prototype.getUnreadCount = function () {
-               return this.howManyUnread;
-       };
-
-       /**
-        * Get the stored number of unseen notifications.
-        *
-        * @return {number} Number of unseen notifications
-        */
-       mw.echo.ui.NotificationsWidget.prototype.getUnseenCount = function () {
-               return this.howManyUnseen;
+               // return this.api.postWithToken( 'edit', data )
+               //      .then( function () {
+               //              notification.toggleRead( true );
+               //              widget.howManyUnread--;
+               //              widget.emit( 'readChange', widget.howManyUnread 
);
+               //      } );
        };
 
        /**
@@ -180,102 +175,13 @@
        };
 
        /**
-        * Fetch notifications from the API and update the notifications list.
-        *
-        * @return {jQuery.Promise} A promise that resolves after the 
notifications
-        *  have been populated, or rejects when there is a failed api request.
-        */
-       mw.echo.ui.NotificationsWidget.prototype.fetchNotifications = function 
() {
-               var widget = this,
-                       apiData = {
-                               action: 'query',
-                               meta: 'notifications',
-                               notsections: this.type,
-                               notmessageunreadfirst: 1,
-                               notformat: 'flyout',
-                               notlimit: this.limit,
-                               notprop: 'index|list|count',
-                               uselang: this.userLang
-                       };
-
-               this.pushPending();
-               this.clearNotifications();
-
-               return this.api.get( apiData )
-                       .then( function ( result ) {
-                               var notifData, i, len, $content, wasSeen, 
wasRead,
-                                       optionWidgets = [],
-                                       data = result.query.notifications;
-
-                               for ( i = 0, len = data.index.length; i < len; 
i++ ) {
-                                       notifData = data.list[ data.index[i] ];
-                                       // TODO: This should really be 
formatted better, and the OptionWidget
-                                       // should be the one that displays 
whatever icon relates to this notification
-                                       // according to its type.
-                                       $content = $( $.parseHTML( 
notifData['*'] ) );
-
-                                       wasRead = !!notifData.read;
-                                       wasSeen = notifData.timestamp.mw <= 
widget.getSeenTime();
-
-                                       if ( !wasRead ) {
-                                               widget.howManyUnread++;
-                                       }
-                                       if ( !wasSeen ) {
-                                               widget.howManyUnseen++;
-                                       }
-                                       optionWidgets.push( new 
mw.echo.ui.NotificationOptionWidget( {
-                                               data: notifData.id,
-                                               label: $content,
-                                               read: wasRead,
-                                               seen: wasSeen,
-                                               markReadWhenSeen: 
!!widget.markReadWhenSeen,
-                                               timestamp: 
notifData.timestamp.mw,
-                                               category: notifData.category,
-                                               // Hack: Get the primary link 
from the $content
-                                               link: $content.find( 
'.mw-echo-notification-primary-link' ).attr( 'href' )
-                                       } ) );
-                               }
-
-                               widget.notifications.removeItems( [ 
widget.loadingOptionWidget ] );
-                               widget.notifications.addItems( optionWidgets );
-                               widget.popPending();
-                               widget.emit( 'update' );
-
-                               return optionWidgets;
-                       },
-                       // Failure
-                       function () {
-                               // TODO: Handle failures better
-                               widget.popPending();
-                       } );
-       };
-
-       /**
         * Clear the notifications list. Leave only the dummy 'pending' option.
         */
        mw.echo.ui.NotificationsWidget.prototype.clearNotifications = function 
() {
-               this.notifications.clearItems();
+               // this.notifications.clearItems();
 
                // Add the dummy 'pending' option
-               this.notifications.addItems( [ this.loadingOptionWidget ] );
-       };
-
-       /**
-        * Get the notification widget type
-        *
-        * @return {string} Notification type; 'alert' or 'message'
-        */
-       mw.echo.ui.NotificationsWidget.prototype.getType = function () {
-               return this.type;
-       };
-
-       /**
-        * Set the notification widget type
-        *
-        * @param {string} type Notification type; 'alert' or 'message'
-        */
-       mw.echo.ui.NotificationsWidget.prototype.setType = function ( type ) {
-               this.type = type;
+               // this.notifications.addItems( [ this.loadingOptionWidget ] );
        };
 
        /**
@@ -285,70 +191,34 @@
         * remaining unread notifications when all notifications are marked 
unread.
         */
        mw.echo.ui.NotificationsWidget.prototype.markAllRead = function () {
-               var widget = this,
-                       data = {
-                               action: 'echomarkread',
-                               uselang: this.userLang,
-                               sections: this.type
-                       };
+               // var widget = this,
+               //      data = {
+               //              action: 'echomarkread',
+               //              uselang: this.userLang,
+               //              sections: this.type
+               //      };
 
-               if ( !this.howManyUnread ) {
-                       return $.Deferred().resolve( 0 ).promise();
-               }
+               // if ( !this.howManyUnread ) {
+               //      return $.Deferred().resolve( 0 ).promise();
+               // }
 
-               return this.api.postWithToken( 'edit', data )
-                       .then( function ( result ) {
-                               return 
result.query.echomarkread[widget.type].rawcount || 0;
-                       } )
-                       .then( function ( readNotificationCount ) {
-                               var i, len,
-                                       optionWidgets = 
widget.notifications.getItems();
+               // return this.api.postWithToken( 'edit', data )
+               //      .then( function ( result ) {
+               //              return 
result.query.echomarkread[widget.type].rawcount || 0;
+               //      } )
+               //      .then( function ( readNotificationCount ) {
+               //              var i, len,
+               //                      optionWidgets = 
widget.notifications.getItems();
 
-                               for ( i = 0, len = optionWidgets.length; i < 
len; i++ ) {
-                                       if ( optionWidgets[i].getData() ) {
-                                               optionWidgets[i].toggleRead( 
true );
-                                               optionWidgets[i].toggleSeen( 
true );
-                                       }
-                               }
-                               widget.howManyUnread = readNotificationCount;
-                               return widget.howManyUnread;
-                       } );
-       };
-
-       /**
-        * Get the seen timestamp in MW format.
-        *
-        * @return {number} Seen timestamp
-        */
-       mw.echo.ui.NotificationsWidget.prototype.getSeenTime = function () {
-               return this.seenTime;
-       };
-
-       /**
-        * Update the seen timestamp
-        *
-        * @return {jQuery.Promise} A promise that resolves with the seen 
timestamp
-        * @fires seen
-        */
-       mw.echo.ui.NotificationsWidget.prototype.updateSeenTime = function () {
-               var widget = this;
-
-               return this.api.post( {
-                       action: 'echomarkseen',
-                       token: mw.user.tokens.get( 'editToken' )
-               } ).then( function ( data ) {
-                       var time = data.query.echomarkseen.timestamp;
-
-                       // update echo-seen-time value in JS (where it wouldn't
-                       // otherwise propagate until page reload)
-                       mw.user.options.set( 'echo-seen-time', time );
-                       widget.seenTime = time;
-                       widget.howManyUnseen = 0;
-
-                       widget.emit( 'seen', widget.seenTime );
-
-                       return widget.seenTime;
-               } );
+               //              for ( i = 0, len = optionWidgets.length; i < 
len; i++ ) {
+               //                      if ( optionWidgets[i].getData() ) {
+               //                              optionWidgets[i].toggleRead( 
true );
+               //                              optionWidgets[i].toggleSeen( 
true );
+               //                      }
+               //              }
+               //              widget.howManyUnread = readNotificationCount;
+               //              return widget.howManyUnread;
+               //      } );
        };
 
 } )( mediaWiki, jQuery );
diff --git a/modules/viewmodel/mw.echo.dm.List.js 
b/modules/viewmodel/mw.echo.dm.List.js
new file mode 100644
index 0000000..381ec5a
--- /dev/null
+++ b/modules/viewmodel/mw.echo.dm.List.js
@@ -0,0 +1,272 @@
+( function ( $, mw ) {
+       /**
+        * Echo List mixin
+        *
+        * @mixin
+        * @abstract
+        * @constructor
+        * @param {Object} config Configuration options
+        */
+       mw.echo.dm.List = function mwFlowDmList( config ) {
+               // Configuration initialization
+               config = config || {};
+
+               this.items = [];
+
+               // Store references to items by their ids
+               this.itemsById = {};
+
+               this.aggregateItemEvents = {};
+       };
+
+       /* Events */
+
+       /**
+        * @event add Items have been added
+        * @param {mw.echo.dm.NotificationItem[]} items Added items
+        * @param {number} index Index items were added at
+        */
+
+       /**
+        * @event remove Items have been removed
+        * @param {mw.echo.dm.NotificationItem[]} items Removed items
+        */
+
+       /**
+        * @event clear All items have been removed
+        */
+
+       /* Methods */
+
+       /**
+        * Get all items
+        *
+        * @return {mw.echo.dm.NotificationItem[]} Items in the list
+        */
+       mw.echo.dm.List.prototype.getItems = function () {
+               return this.items.slice( 0 );
+       };
+
+       /**
+        * Get an item by its id
+        * @param {string} id Item id
+        * @return {mw.echo.dm.NotificationItem} Item
+        */
+       mw.echo.dm.List.prototype.getItemById = function ( id ) {
+               return this.itemsById[ id ];
+       };
+
+       /**
+        * Get the index of a specific item
+        *
+        * @param {mw.echo.dm.NotificationItem} item Requested item
+        * @return {number} Index of the item
+        */
+       mw.echo.dm.List.prototype.getItemIndex = function ( item ) {
+               return this.items.indexOf( item );
+       };
+
+       /**
+        * Get number of items
+        *
+        * @return {number} Number of items in the list
+        */
+       mw.echo.dm.List.prototype.getItemCount = function () {
+               return this.items.length;
+       };
+
+       /**
+        * Check if a list contains no items.
+        *
+        * @return {boolean} Group is empty
+        */
+       mw.echo.dm.List.prototype.isEmpty = function () {
+               return !this.items.length;
+       };
+
+       /**
+        * Aggregate the events emitted by the group.
+        * Taken from oojs-ui's OO.ui.GroupElement#aggregate
+        *
+        * When events are aggregated, the group will listen to all contained 
items for the event,
+        * and then emit the event under a new name. The new event will contain 
an additional leading
+        * parameter containing the item that emitted the original event. Other 
arguments emitted from
+        * the original event are passed through.
+        *
+        * @param {Object.<string,string|null>} events An object keyed by the 
name of the event that should be
+        *  aggregated  (e.g., ‘click’) and the value of the new name to use 
(e.g., ‘groupClick’).
+        *  A `null` value will remove aggregated events.
+
+        * @throws {Error} An error is thrown if aggregation already exists.
+        */
+       mw.echo.dm.List.prototype.aggregate = function ( events ) {
+               var i, len, item, add, remove, itemEvent, groupEvent;
+
+               for ( itemEvent in events ) {
+                       groupEvent = events[ itemEvent ];
+
+                       // Remove existing aggregated event
+                       if ( Object.prototype.hasOwnProperty.call( 
this.aggregateItemEvents, itemEvent ) ) {
+                               // Don't allow duplicate aggregations
+                               if ( groupEvent ) {
+                                       throw new Error( 'Duplicate item event 
aggregation for ' + itemEvent );
+                               }
+                               // Remove event aggregation from existing items
+                               for ( i = 0, len = this.items.length; i < len; 
i++ ) {
+                                       item = this.items[ i ];
+                                       if ( item.connect && item.disconnect ) {
+                                               remove = {};
+                                               remove[ itemEvent ] = [ 'emit', 
this.aggregateItemEvents[ itemEvent ], item ];
+                                               item.disconnect( this, remove );
+                                       }
+                               }
+                               // Prevent future items from aggregating event
+                               delete this.aggregateItemEvents[ itemEvent ];
+                       }
+
+                       // Add new aggregate event
+                       if ( groupEvent ) {
+                               // Make future items aggregate event
+                               this.aggregateItemEvents[ itemEvent ] = 
groupEvent;
+                               // Add event aggregation to existing items
+                               for ( i = 0, len = this.items.length; i < len; 
i++ ) {
+                                       item = this.items[ i ];
+                                       if ( item.connect && item.disconnect ) {
+                                               add = {};
+                                               add[ itemEvent ] = [ 'emit', 
groupEvent, item ];
+                                               item.connect( this, add );
+                                       }
+                               }
+                       }
+               }
+       };
+
+       /**
+        * Add items
+        *
+        * @param {mw.echo.dm.NotificationItem[]} items Items to add
+        * @param {number} index Index to add items at
+        * @chainable
+        * @fires add
+        */
+       mw.echo.dm.List.prototype.addItems = function ( items, index ) {
+               var i, len, item, event, events, currentIndex, existingItem, at;
+
+               if ( items.length === 0 ) {
+                       return this;
+               }
+
+               // Support adding existing items at new locations
+               for ( i = 0, len = items.length; i < len; i++ ) {
+                       item = items[i];
+                       existingItem = this.getItemById( item.getId() );
+
+                       // Check if item exists then remove it first, 
effectively "moving" it
+                       currentIndex = this.items.indexOf( existingItem );
+                       if ( currentIndex >= 0 ) {
+                               this.removeItems( [ existingItem ] );
+                               // Adjust index to compensate for removal
+                               if ( currentIndex < index ) {
+                                       index--;
+                               }
+                       }
+
+                       // Add the item
+                       if ( item.connect && item.disconnect && 
!$.isEmptyObject( this.aggregateItemEvents ) ) {
+                               events = {};
+                               for ( event in this.aggregateItemEvents ) {
+                                       events[ event ] = [ 'emit', 
this.aggregateItemEvents[ event ], item ];
+                               }
+                               item.connect( this, events );
+                       }
+
+                       // Add by reference
+                       this.itemsById[ item.getId() ] = items[i];
+               }
+
+               if ( index === undefined || index < 0 || index >= 
this.items.length ) {
+                       at = this.items.length;
+                       this.items.push.apply( this.items, items );
+               } else if ( index === 0 ) {
+                       at = 0;
+                       this.items.unshift.apply( this.items, items );
+               } else {
+                       at = index;
+                       this.items.splice.apply( this.items, [ index, 0 
].concat( items ) );
+               }
+               this.emit( 'add', items, at );
+
+               return this;
+       };
+
+       /**
+        * Remove items
+        *
+        * @param {mw.echo.dm.NotificationItem[]} items Items to remove
+        * @chainable
+        * @fires remove
+        */
+       mw.echo.dm.List.prototype.removeItems = function ( items ) {
+               var i, len, item, index, remove, itemEvent,
+                       removed = [];
+
+               if ( items.length === 0 ) {
+                       return this;
+               }
+
+               // Remove specific items
+               for ( i = 0, len = items.length; i < len; i++ ) {
+                       item = items[ i ];
+                       index = this.items.indexOf( item );
+                       if ( index !== -1 ) {
+                               if (
+                                       item.connect && item.disconnect &&
+                                       !$.isEmptyObject( 
this.aggregateItemEvents )
+                               ) {
+                                       remove = {};
+                                       if ( 
Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
+                                               remove[ itemEvent ] = [ 'emit', 
this.aggregateItemEvents[ itemEvent ], item ];
+                                       }
+                                       item.disconnect( this, remove );
+                               }
+                               this.items.splice( index, 1 );
+                               // Remove reference by Id
+                               delete this.itemsById[ item.getId() ];
+                       }
+               }
+               this.emit( 'remove', removed );
+
+               return this;
+       };
+
+       /**
+        * Clear all items
+        *
+        * @fires clear
+        */
+       mw.echo.dm.List.prototype.clearItems = function () {
+               var i, len, item, remove, itemEvent;
+
+               // Remove all items
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       item = this.items[ i ];
+                       if (
+                               item.connect && item.disconnect &&
+                               !$.isEmptyObject( this.aggregateItemEvents )
+                       ) {
+                               remove = {};
+                               if ( Object.prototype.hasOwnProperty.call( 
this.aggregateItemEvents, itemEvent ) ) {
+                                       remove[ itemEvent ] = [ 'emit', 
this.aggregateItemEvents[ itemEvent ], item ];
+                               }
+                               item.disconnect( this, remove );
+                       }
+               }
+
+               this.items = [];
+               this.itemsById = {};
+
+               this.emit( 'clear' );
+
+               return this;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/modules/viewmodel/mw.echo.dm.NotificationItem.js 
b/modules/viewmodel/mw.echo.dm.NotificationItem.js
new file mode 100644
index 0000000..b48089c
--- /dev/null
+++ b/modules/viewmodel/mw.echo.dm.NotificationItem.js
@@ -0,0 +1,172 @@
+( function ( mw, $ ) {
+       /**
+        * Echo notification NotificationItem model
+        *
+        * @abstract
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {number} id Notification id,
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery|string} [content] The html content of this notification
+        * @cfg {string} [category] The category of this notification. The 
category identifies
+        *  where the notification originates from.
+        * @cfg {boolean} [read=false] State the read state of the option
+        * @cfg {boolean} [seen=false] State the seen state of the option
+        * @cfg {string} [timstamp] Notification timestamp in Mediawiki 
timestamp format
+        */
+       mw.echo.dm.NotificationItem = function mwFlowDmNotificationItem( id, 
config ) {
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.id = id || null;
+
+               // TODO: We should work on the API to release and work with 
actual
+               // data here, rather than getting a pre-made html content of the
+               // notification.
+               this.content = config.content || $();
+
+               this.category = config.category || '';
+
+               this.toggleRead( !!config.read );
+               this.toggleSeen( !!config.seen );
+
+               this.setTimestamp( Number( config.timestamp ) || ( Date.now() / 
1000 ) );
+               this.setPrimaryLink( config.primaryLink );
+       };
+
+       /* Inheritance */
+
+       OO.mixinClass( mw.echo.dm.NotificationItem, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event seen
+        * @param {boolean} [seen] Notification is seen
+        *
+        * Seen status of the notification has changed
+        */
+
+       /**
+        * @event read
+        * @param {boolean} [read] Notification is read
+        *
+        * Read status of the notification has changed
+        */
+
+       /* Methods */
+
+       /**
+        * Get NotificationItem id
+        * @return {string} NotificationItem Id
+        */
+       mw.echo.dm.NotificationItem.prototype.getId = function () {
+               return this.id;
+       };
+
+       /**
+        * Set NotificationItem id
+        * @param {string} id NotificationItem Id
+        */
+       mw.echo.dm.NotificationItem.prototype.setId = function ( id ) {
+               this.id = id;
+       };
+
+       /**
+        * Get NotificationItem content
+        * @return {jQuery|string} NotificationItem content
+        */
+       mw.echo.dm.NotificationItem.prototype.getContent = function () {
+               return this.content;
+       };
+
+       /**
+        * Get NotificationItem category
+        * @return {string} NotificationItem category
+        */
+       mw.echo.dm.NotificationItem.prototype.getCategory = function () {
+               return this.category;
+       };
+
+       /**
+        * Check whether this notification item is read
+        * @return {boolean} Notification item is read
+        */
+       mw.echo.dm.NotificationItem.prototype.isRead = function () {
+               return this.read;
+       };
+
+       /**
+        * Check whether this notification item is seen
+        * @return {boolean} Notification item is seen
+        */
+       mw.echo.dm.NotificationItem.prototype.isSeen = function () {
+               return this.seen;
+       };
+
+       /**
+        * Toggle the read state of the widget
+        *
+        * @param {boolean} [read] The current read state. If not given, the 
state will
+        *  become the opposite of its current state.
+        */
+       mw.echo.dm.NotificationItem.prototype.toggleRead = function ( read ) {
+               read = read !== undefined ? read : !this.read;
+               if ( this.read !== read ) {
+                       this.read = read;
+                       this.emit( 'read', this.read );
+               }
+       };
+
+       /**
+        * Toggle the seen state of the widget
+        *
+        * @param {boolean} [seen] The current seen state. If not given, the 
state will
+        *  become the opposite of its current state.
+        */
+       mw.echo.dm.NotificationItem.prototype.toggleSeen = function ( seen ) {
+               seen = seen !== undefined ? seen : !this.seen;
+               if ( this.seen !== seen ) {
+                       this.seen = seen;
+                       this.emit( 'seen', this.seen );
+               }
+       };
+
+       /**
+        * Set the notification timestamp
+        *
+        * @param {number} timestamp Notification timestamp
+        */
+       mw.echo.dm.NotificationItem.prototype.setTimestamp = function ( 
timestamp ) {
+               this.timestamp = Number( timestamp );
+       };
+
+       /**
+        * Get the notification timestamp
+        *
+        * @return {number} Notification timestamp
+        */
+       mw.echo.dm.NotificationItem.prototype.getTimestamp = function () {
+               return this.timestamp;
+       };
+
+       /**
+        * Set the notification link
+        *
+        * @param {string} link Notification link
+        */
+       mw.echo.dm.NotificationItem.prototype.setPrimaryLink = function ( link 
) {
+               this.primaryLink = link;
+       };
+
+       /**
+        * Get the notification link
+        *
+        * @return {string} Notification link
+        */
+       mw.echo.dm.NotificationItem.prototype.getPrimaryLink = function () {
+               return this.primaryLink;
+       };
+
+}( mediaWiki, jQuery ) );
diff --git a/modules/viewmodel/mw.echo.dm.NotificationList.js 
b/modules/viewmodel/mw.echo.dm.NotificationList.js
new file mode 100644
index 0000000..7d4fbea
--- /dev/null
+++ b/modules/viewmodel/mw.echo.dm.NotificationList.js
@@ -0,0 +1,26 @@
+( function ( mw ) {
+       /**
+        * Notification list
+        *
+        * @class
+        * @mixins OO.EventEmitter
+        * @mixins mw.echo.dm.List
+        *
+        * @constructor
+        * @param {Object} [config] Configuration object
+        */
+       mw.echo.dm.NotificationList = function MwEchoDmNotificationList() {
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               // Mixin constructor
+               mw.echo.dm.List.call( this );
+       };
+
+       /* Initialization */
+
+       OO.initClass( mw.echo.dm.NotificationList );
+       OO.mixinClass( mw.echo.dm.NotificationList, OO.EventEmitter );
+       OO.mixinClass( mw.echo.dm.NotificationList, mw.echo.dm.List );
+} )( mediaWiki );
diff --git a/modules/viewmodel/mw.echo.dm.NotificationsModel.js 
b/modules/viewmodel/mw.echo.dm.NotificationsModel.js
new file mode 100644
index 0000000..9401fd2
--- /dev/null
+++ b/modules/viewmodel/mw.echo.dm.NotificationsModel.js
@@ -0,0 +1,296 @@
+( function ( mw, $ ) {
+       /**
+        * Notification view model
+        *
+        * @class
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {Object} [config] Configuration object
+        * @cfg {string} [type='alert'] Notification type 'alert', 'message' or 
'all'
+        * @cfg {number} [limit=25] Notification limit
+        * @cfg {string} [userLang] User language
+        */
+       mw.echo.dm.NotificationsModel = function MwEchoDmNotificationsModel( 
config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               // Mixin constructor
+               mw.echo.dm.List.call( this );
+
+               this.type = config.type || 'alert';
+               this.limit = config.limit || 25;
+               this.userLang = config.userLang || 'en';
+
+               this.api = new mw.Api( { ajax: { cache: false } } );
+
+               this.seenTime = mw.user.options.get( 'echo-seen-time' );
+
+               // Store references to unseen and unread notifications
+               this.unseenNotifications = new mw.echo.dm.NotificationList();
+               this.unreadNotifications = new mw.echo.dm.NotificationList();
+
+               // Events
+               this.aggregate( {
+                       seen: 'itemSeen',
+                       read: 'itemRead'
+               } );
+
+               this.connect( this, {
+                       itemSeen: 'onItemSeen',
+                       itemRead: 'onItemRead'
+               } );
+       };
+
+       /* Initialization */
+
+       OO.initClass( mw.echo.dm.NotificationsModel );
+       OO.mixinClass( mw.echo.dm.NotificationsModel, OO.EventEmitter );
+       OO.mixinClass( mw.echo.dm.NotificationsModel, mw.echo.dm.List );
+
+       /* Events */
+
+       /**
+        * @event updateSeenTime
+        * @param {number} seenTime Seen time
+        *
+        * Seen time has been updated
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to item seen state change
+        *
+        * @param {mw.echo.dm.NotificationItem} item Notification item
+        * @param {boolean} isSeen Notification is seen
+        * @fires unseenChange
+        */
+       mw.echo.dm.NotificationsModel.prototype.onItemSeen = function ( item, 
isSeen ) {
+               var id = item && item.getId(),
+                       unseenItem = id && 
this.unseenNotifications.getItemById( id );
+
+               if ( unseenItem ) {
+                       if ( isSeen ) {
+                               this.unseenNotifications.removeItems( [ 
unseenItem ] );
+                       } else {
+                               this.unseenNotifications.addItems( [ unseenItem 
] );
+                       }
+                       this.emit( 'unseenChange', 
this.unseenNotifications.getItems() );
+               }
+       };
+
+       /**
+        * Respond to item read state change
+        *
+        * @param {mw.echo.dm.NotificationItem} item Notification item
+        * @param {boolean} isRead Notification is read
+        * @fires notificationRead
+        */
+       mw.echo.dm.NotificationsModel.prototype.onItemRead = function ( item, 
isRead ) {
+               var id = item && item.getId(),
+                       unreadItem = id && 
this.unreadNotifications.getItemById( id );
+
+               if ( unreadItem ) {
+                       if ( isRead ) {
+                               this.unseenNotifications.removeItems( [ 
unreadItem ] );
+                       } else {
+                               this.unseenNotifications.addItems( [ unreadItem 
] );
+                       }
+                       this.emit( 'unreadChange', 
this.unreadNotifications.getItems() );
+               }
+       };
+
+       /**
+        * Get the type of the notifications that this model deals with.
+        * Notifications type are given from the API: 'alert', 'message', 'all'
+        *
+        * @return {string} Notifications type
+        */
+       mw.echo.dm.NotificationsModel.prototype.getType = function () {
+               return this.type;
+       };
+
+       /**
+        * Get the counter of how many notifications are unseen
+        *
+        * @return {number} Number of unseen notifications
+        */
+       mw.echo.dm.NotificationsModel.prototype.getUnseenCount = function () {
+               return this.unseenNotifications.getItemCount();
+       };
+
+       /**
+        * Get the counter of how many notifications are unread
+        *
+        * @return {number} Number of unread notifications
+        */
+       mw.echo.dm.NotificationsModel.prototype.getUnreadCount = function () {
+               return this.unreadNotifications.getItemCount();
+       };
+
+       /**
+        * Set the system seen time - the last time we've marked notification 
as seen
+        *
+        * @param {string|number} Mediawiki seen timestamp
+        */
+       mw.echo.dm.NotificationsModel.prototype.setSeenTime = function ( time ) 
{
+               this.seenTime = Number( time );
+       };
+
+       /**
+        * Get the system seen time
+        *
+        * @return {number} Mediawiki seen timestamp
+        */
+       mw.echo.dm.NotificationsModel.prototype.getSeenTime = function () {
+               return this.seenTime;
+       };
+
+       /**
+        * Update the seen timestamp
+        *
+        * @return {jQuery.Promise} A promise that resolves with the seen 
timestamp
+        * @fires seen
+        */
+       mw.echo.dm.NotificationsModel.prototype.updateSeenTime = function () {
+               var model = this;
+
+               return this.api.post( {
+                       action: 'echomarkseen',
+                       token: mw.user.tokens.get( 'editToken' )
+               } ).then( function ( data ) {
+                       var time = data.query.echomarkseen.timestamp;
+
+                       // update echo-seen-time value in JS (where it wouldn't
+                       // otherwise propagate until page reload)
+                       mw.user.options.set( 'echo-seen-time', time );
+                       model.setSeenTime( time );
+                       // model.unseenCounter = 0;
+                       model.emit( 'updateSeenTime' );
+               } );
+       };
+
+       /**
+        * Mark all notifications as read
+        *
+        * @return {jQuery.Promise} A promise that resolves when all 
notifications
+        * were marked as read.
+        */
+       mw.echo.dm.NotificationsModel.prototype.markAllRead = function () {
+               var model = this,
+                       data = {
+                               action: 'echomarkread',
+                               uselang: this.userLang,
+                               sections: this.type
+                       };
+
+               if ( !this.unreadNotifications.getItemCount() ) {
+                       return $.Deferred().resolve( 0 ).promise();
+               }
+
+               return this.api.postWithToken( 'edit', data )
+                       .then( function ( result ) {
+                               return 
result.query.echomarkread[model.type].rawcount || 0;
+                       } )
+                       .then( function () {
+                               var i, len,
+                                       items = 
model.unreadNotifications.getItems();
+
+                               for ( i = 0, len = items.length; i < len; i++ ) 
{
+                                       items[i].toggleRead( true );
+                                       items[i].toggleSeen( true );
+                               }
+                               model.unreadNotifications.clearItems();
+                       } );
+       };
+
+       /**
+        * Fetch notifications from the API and update the notifications list.
+        *
+        * @return {jQuery.Promise} A promise that resolves with an array of 
notification
+        *  id's.
+        */
+       mw.echo.dm.NotificationsModel.prototype.fetchNotifications = function 
() {
+               var model = this,
+                       apiData = {
+                               action: 'query',
+                               meta: 'notifications',
+                               notsections: this.type,
+                               notmessageunreadfirst: 1,
+                               notformat: 'flyout',
+                               notlimit: this.limit,
+                               notprop: 'index|list|count',
+                               uselang: this.userLang
+                       };
+
+               return this.api.get( apiData )
+                       .then( function ( result ) {
+                               var notifData, i, len, $content, wasSeen, 
wasRead, notificationModel,
+                                       optionItems = [],
+                                       idArray = [],
+                                       data = result.query.notifications;
+
+                               for ( i = 0, len = data.index.length; i < len; 
i++ ) {
+                                       notifData = data.list[ data.index[i] ];
+                                       // TODO: This should really be 
formatted better, and the OptionWidget
+                                       // should be the one that displays 
whatever icon relates to this notification
+                                       // according to its type.
+                                       $content = $( $.parseHTML( 
notifData['*'] ) );
+
+                                       wasRead = !!notifData.read;
+                                       wasSeen = notifData.timestamp.mw <= 
model.getSeenTime();
+                                       notificationModel = new 
mw.echo.dm.NotificationItem(
+                                               notifData.id,
+                                               {
+                                                       read: wasRead,
+                                                       seen: wasSeen,
+                                                       timestamp: 
notifData.timestamp.mw,
+                                                       category: 
notifData.category,
+                                                       content: $content,
+                                                       // Hack: Get the 
primary link from the $content
+                                                       primaryLink: 
$content.find( '.mw-echo-notification-primary-link' ).attr( 'href' )
+                                               }
+                                       );
+
+                                       idArray.push( notifData.id );
+                                       optionItems.push( notificationModel );
+
+                                       if ( !wasRead ) {
+                                               
model.unreadNotifications.addItems( [ notificationModel ] );
+                                       }
+                                       if ( !wasSeen ) {
+                                               
model.unseenNotifications.addItems( [ notificationModel ] );
+                                       }
+                               }
+                               model.addItems( optionItems );
+
+                               return idArray;
+                       } );
+       };
+
+       /**
+        * Query the API for unread count of the notifications in this model
+        *
+        * @return {jQuery.Promise} jQuery promise that's resolved when the 
unread count is fetched
+        *  and the badge label is updated.
+        */
+       mw.echo.dm.NotificationsModel.prototype.fetchUnreadCountFromApi = 
function () {
+               var apiData = {
+                               action: 'query',
+                               meta: 'notifications',
+                               notsections: this.getType(),
+                               notmessageunreadfirst: 1,
+                               notlimit: this.limit,
+                               notprop: 'index|count',
+                               uselang: this.userLang
+                       };
+
+               return this.api.get( apiData )
+                       .then( function ( result ) {
+                               return OO.getProp( result.query, 
'notifications', 'rawcount' ) || 0;
+                       } );
+       };
+} )( mediaWiki, jQuery );
diff --git a/modules/viewmodel/mw.echo.dm.js b/modules/viewmodel/mw.echo.dm.js
new file mode 100644
index 0000000..e7d7c70
--- /dev/null
+++ b/modules/viewmodel/mw.echo.dm.js
@@ -0,0 +1,4 @@
+( function ( mw ) {
+       mw.echo = mw.echo || {};
+       mw.echo.dm = {};
+} )( mediaWiki );

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ia3b220b373b70b1419fecd6d0edb1d7df1af4417
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/Echo
Gerrit-Branch: master
Gerrit-Owner: Mooeypoo <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to