From af331bfb30bc094432fe2d8b4cab4aa00b9fd23a Mon Sep 17 00:00:00 2001
From: Augier <contact@c-henry.fr>
Date: Mon, 8 Aug 2016 16:56:02 +0200
Subject: [PATCH] Add collection to app.views.NotificationDropdown and
 app.views.Notifications

closes #6952
---
 Changelog.md                                  |   2 +
 app/assets/javascripts/app/app.js             |   1 +
 .../app/collections/notifications.js          | 118 +++++++++
 .../javascripts/app/models/notification.js    |  69 +++++
 app/assets/javascripts/app/router.js          |   2 +-
 .../javascripts/app/views/header_view.js      |  10 +-
 .../app/views/notification_dropdown_view.js   |  71 ++---
 .../app/views/notifications_view.js           | 117 ++++----
 .../helpers/browser_notification.js           |  22 ++
 app/assets/templates/header_tpl.jst.hbs       |   9 +-
 app/controllers/notifications_controller.rb   |  13 +-
 app/views/notifications/index.html.haml       |   3 +-
 config/locales/javascript/javascript.en.yml   |   3 +
 .../jasmine_fixtures/notifications_spec.rb    |   2 +
 .../notifications_controller_spec.rb          |  17 +-
 .../notifications_collection_spec.js          | 249 ++++++++++++++++++
 .../app/models/notification_spec.js           |  85 ++++++
 .../javascripts/app/views/header_view_spec.js |   1 +
 .../views/notification_dropdown_view_spec.js  | 143 ++++------
 .../app/views/notifications_view_spec.js      | 220 +++++++++++-----
 20 files changed, 864 insertions(+), 293 deletions(-)
 create mode 100644 app/assets/javascripts/app/collections/notifications.js
 create mode 100644 app/assets/javascripts/app/models/notification.js
 create mode 100644 app/assets/javascripts/helpers/browser_notification.js
 create mode 100644 spec/javascripts/app/collections/notifications_collection_spec.js
 create mode 100644 spec/javascripts/app/models/notification_spec.js

diff --git a/Changelog.md b/Changelog.md
index 1bbd260aa5..ac93a6c507 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -19,6 +19,8 @@
 * Add a dark color theme [#7152](https://github.com/diaspora/diaspora/pull/7152)
 * Added setting for custom changelog URL [#7166](https://github.com/diaspora/diaspora/pull/7166)
 * Show more information of recipients on conversation creation [#7129](https://github.com/diaspora/diaspora/pull/7129)
+* Update notifications every 5 minutes and when opening the notification dropdown [#6952](https://github.com/diaspora/diaspora/pull/6952)
+* Show browser notifications when receiving new unread notifications [#6952](https://github.com/diaspora/diaspora/pull/6952)
 
 # 0.6.1.0
 
diff --git a/app/assets/javascripts/app/app.js b/app/assets/javascripts/app/app.js
index 1046bd66ab..d300be7e6d 100644
--- a/app/assets/javascripts/app/app.js
+++ b/app/assets/javascripts/app/app.js
@@ -90,6 +90,7 @@ var app = {
 
   setupHeader: function() {
     if(app.currentUser.authenticated()) {
+      app.notificationsCollection = new app.collections.Notifications();
       app.header = new app.views.Header();
       $("header").prepend(app.header.el);
       app.header.render();
diff --git a/app/assets/javascripts/app/collections/notifications.js b/app/assets/javascripts/app/collections/notifications.js
new file mode 100644
index 0000000000..0bb4a2f977
--- /dev/null
+++ b/app/assets/javascripts/app/collections/notifications.js
@@ -0,0 +1,118 @@
+app.collections.Notifications = Backbone.Collection.extend({
+  model: app.models.Notification,
+  // URL parameter
+  /* eslint-disable camelcase */
+  url: Routes.notifications({per_page: 10, page: 1}),
+  /* eslint-enable camelcase */
+  page: 2,
+  perPage: 5,
+  unreadCount: 0,
+  unreadCountByType: {},
+  timeout: 300000, // 5 minutes
+
+  initialize: function() {
+    this.pollNotifications();
+
+    setTimeout(function() {
+      setInterval(this.pollNotifications.bind(this), this.timeout);
+    }.bind(this), this.timeout);
+
+    Diaspora.BrowserNotification.requestPermission();
+  },
+
+  pollNotifications: function() {
+    var unreadCountBefore = this.unreadCount;
+    this.fetch();
+
+    this.once("finishedLoading", function() {
+      if (unreadCountBefore < this.unreadCount) {
+        Diaspora.BrowserNotification.spawnNotification(
+          Diaspora.I18n.t("notifications.new_notifications", {count: this.unreadCount}));
+      }
+    }, this);
+  },
+
+  fetch: function(options) {
+    options = options || {};
+    options.remove = false;
+    options.merge = true;
+    options.parse = true;
+    Backbone.Collection.prototype.fetch.apply(this, [options]);
+  },
+
+  fetchMore: function() {
+    var hasMoreNotifications = (this.page * this.perPage) <= this.length;
+    // There are more notifications to load on the current page
+    if (hasMoreNotifications) {
+      this.page++;
+      // URL parameter
+      /* eslint-disable camelcase */
+      var route = Routes.notifications({per_page: this.perPage, page: this.page});
+      /* eslint-enable camelcase */
+      this.fetch({url: route, pushBack: true});
+    }
+  },
+
+  /**
+   * Adds new models to the collection at the end or at the beginning of the collection and
+   * then fires an event for each model of the collection. It will fire a different event
+   * based on whether the models were added at the end (typically when the scroll triggers to load more
+   * notifications) or at the beginning (new notifications have been added to the front of the list).
+   */
+  set: function(items, options) {
+    options = options || {};
+    options.at = options.pushBack ? this.length : 0;
+
+    // Retreive back the new created models
+    var models = [];
+    var accu = function(model) { models.push(model); };
+    this.on("add", accu);
+    Backbone.Collection.prototype.set.apply(this, [items, options]);
+    this.off("add", accu);
+
+    if (options.pushBack) {
+      models.forEach(function(model) { this.trigger("pushBack", model); }.bind(this));
+    } else {
+      // Fires events in the reverse order so that the first event is prepended in first position
+      models.reverse();
+      models.forEach(function(model) { this.trigger("pushFront", model); }.bind(this));
+    }
+    this.trigger("finishedLoading");
+  },
+
+  parse: function(response) {
+    this.unreadCount = response.unread_count;
+    this.unreadCountByType = response.unread_count_by_type;
+
+    return _.map(response.notification_list, function(item) {
+      /* eslint-disable new-cap */
+      var model = new this.model(item);
+      /* eslint-enable new-cap */
+      model.on("change:unread", this.onChangedUnreadStatus.bind(this));
+      return model;
+    }.bind(this));
+  },
+
+  setAllRead: function() {
+    this.forEach(function(model) { model.setRead(); });
+  },
+
+  setRead: function(guid) {
+    this.find(function(model) { return model.guid === guid; }).setRead();
+  },
+
+  setUnread: function(guid) {
+    this.find(function(model) { return model.guid === guid; }).setUnread();
+  },
+
+  onChangedUnreadStatus: function(model) {
+    if (model.get("unread") === true) {
+      this.unreadCount++;
+      this.unreadCountByType[model.get("type")]++;
+    } else {
+      this.unreadCount = Math.max(this.unreadCount - 1, 0);
+      this.unreadCountByType[model.get("type")] = Math.max(this.unreadCountByType[model.get("type")] - 1, 0);
+    }
+    this.trigger("update");
+  }
+});
diff --git a/app/assets/javascripts/app/models/notification.js b/app/assets/javascripts/app/models/notification.js
new file mode 100644
index 0000000000..b2e342116a
--- /dev/null
+++ b/app/assets/javascripts/app/models/notification.js
@@ -0,0 +1,69 @@
+app.models.Notification = Backbone.Model.extend({
+  constructor: function(attributes, options) {
+    options = options || {};
+    options.parse = true;
+    Backbone.Model.apply(this, [attributes, options]);
+    this.guid = this.get("id");
+  },
+
+  /**
+   * Flattens the notification object returned by the server.
+   *
+   * The server returns an object that looks like:
+   *
+   * {
+   *   "reshared": {
+   *     "id": 45,
+   *     "target_type": "Post",
+   *     "target_id": 11,
+   *     "recipient_id": 1,
+   *     "unread": true,
+   *     "created_at": "2015-10-27T19:56:30.000Z",
+   *     "updated_at": "2015-10-27T19:56:30.000Z",
+   *     "note_html": <html/>
+   *   },
+   *  "type": "reshared"
+   * }
+   *
+   * The returned object looks like:
+   *
+   * {
+   *   "type": "reshared",
+   *   "id": 45,
+   *   "target_type": "Post",
+   *   "target_id": 11,
+   *   "recipient_id": 1,
+   *   "unread": true,
+   *   "created_at": "2015-10-27T19:56:30.000Z",
+   *   "updated_at": "2015-10-27T19:56:30.000Z",
+   *   "note_html": <html/>,
+   * }
+   */
+  parse: function(response) {
+    var result = {type: response.type};
+    result = $.extend(result, response[result.type]);
+    return result;
+  },
+
+  setRead: function() {
+    this.setUnreadStatus(false);
+  },
+
+  setUnread: function() {
+    this.setUnreadStatus(true);
+  },
+
+  setUnreadStatus: function(state) {
+    if (this.get("unread") !== state) {
+      $.ajax({
+        url: Routes.notification(this.guid),
+        /* eslint-disable camelcase */
+        data: {set_unread: state},
+        /* eslint-enable camelcase */
+        type: "PUT",
+        context: this,
+        success: function() { this.set("unread", state); }
+      });
+    }
+  }
+});
diff --git a/app/assets/javascripts/app/router.js b/app/assets/javascripts/app/router.js
index 1884bb880f..ed8ec9d9a0 100644
--- a/app/assets/javascripts/app/router.js
+++ b/app/assets/javascripts/app/router.js
@@ -139,7 +139,7 @@ app.Router = Backbone.Router.extend({
   notifications: function() {
     this._loadContacts();
     this.renderAspectMembershipDropdowns($(document));
-    new app.views.Notifications({el: "#notifications_container"});
+    new app.views.Notifications({el: "#notifications_container", collection: app.notificationsCollection});
   },
 
   peopleSearch: function() {
diff --git a/app/assets/javascripts/app/views/header_view.js b/app/assets/javascripts/app/views/header_view.js
index 5b682c3b34..496cd83d34 100644
--- a/app/assets/javascripts/app/views/header_view.js
+++ b/app/assets/javascripts/app/views/header_view.js
@@ -12,12 +12,12 @@ app.views.Header = app.views.Base.extend({
     });
   },
 
-  postRenderTemplate: function(){
-    new app.views.Notifications({ el: "#notification-dropdown" });
-    this.notificationDropdown = new app.views.NotificationDropdown({ el: "#notification-dropdown" });
-    new app.views.Search({ el: "#header-search-form" });
+  postRenderTemplate: function() {
+    new app.views.Notifications({el: "#notification-dropdown", collection: app.notificationsCollection});
+    new app.views.NotificationDropdown({el: "#notification-dropdown", collection: app.notificationsCollection});
+    new app.views.Search({el: "#header-search-form"});
   },
 
-  menuElement: function(){ return this.$("ul.dropdown"); },
+  menuElement: function() { return this.$("ul.dropdown"); }
 });
 // @license-end
diff --git a/app/assets/javascripts/app/views/notification_dropdown_view.js b/app/assets/javascripts/app/views/notification_dropdown_view.js
index a44556c9ad..a72f1e8f1d 100644
--- a/app/assets/javascripts/app/views/notification_dropdown_view.js
+++ b/app/assets/javascripts/app/views/notification_dropdown_view.js
@@ -6,16 +6,21 @@ app.views.NotificationDropdown = app.views.Base.extend({
   },
 
   initialize: function(){
-    $(document.body).click($.proxy(this.hideDropdown, this));
+    $(document.body).click(this.hideDropdown.bind(this));
 
-    this.notifications = [];
-    this.perPage = 5;
-    this.hasMoreNotifs = true;
     this.badge = this.$el;
     this.dropdown = $("#notification-dropdown");
     this.dropdownNotifications = this.dropdown.find(".notifications");
     this.ajaxLoader = this.dropdown.find(".ajax-loader");
     this.perfectScrollbarInitialized = false;
+    this.dropdownNotifications.scroll(this.dropdownScroll.bind(this));
+    this.bindCollectionEvents();
+  },
+
+  bindCollectionEvents: function() {
+    this.collection.on("pushFront", this.onPushFront.bind(this));
+    this.collection.on("pushBack", this.onPushBack.bind(this));
+    this.collection.on("finishedLoading", this.finishLoading.bind(this));
   },
 
   toggleDropdown: function(evt){
@@ -31,12 +36,11 @@ app.views.NotificationDropdown = app.views.Base.extend({
   },
 
   showDropdown: function(){
-    this.resetParams();
     this.ajaxLoader.show();
     this.dropdown.addClass("dropdown-open");
     this.updateScrollbar();
     this.dropdownNotifications.addClass("loading");
-    this.getNotifications();
+    this.collection.fetch();
   },
 
   hideDropdown: function(evt){
@@ -50,40 +54,18 @@ app.views.NotificationDropdown = app.views.Base.extend({
 
   dropdownScroll: function(){
     var isLoading = ($(".loading").length === 1);
-    if (this.isBottom() && this.hasMoreNotifs && !isLoading){
+    if (this.isBottom() && !isLoading) {
       this.dropdownNotifications.addClass("loading");
-      this.getNotifications();
+      this.collection.fetchMore();
     }
   },
 
-  getParams: function(){
-    if(this.notifications.length === 0){ return{ per_page: 10, page: 1 }; }
-    else{ return{ per_page: this.perPage, page: this.nextPage }; }
-  },
-
-  resetParams: function(){
-    this.notifications.length = 0;
-    this.hasMoreNotifs = true;
-    delete this.nextPage;
-  },
-
   isBottom: function(){
     var bottom = this.dropdownNotifications.prop("scrollHeight") - this.dropdownNotifications.height();
     var currentPosition = this.dropdownNotifications.scrollTop();
     return currentPosition + 50 >= bottom;
   },
 
-  getNotifications: function(){
-    var self = this;
-    $.getJSON(Routes.notifications(this.getParams()), function(notifications){
-      $.each(notifications, function(){ self.notifications.push(this); });
-      self.hasMoreNotifs = notifications.length >= self.perPage;
-      if(self.nextPage){ self.nextPage++; }
-      else { self.nextPage = 3; }
-      self.renderNotifications();
-    });
-  },
-
   hideAjaxLoader: function(){
     var self = this;
     this.ajaxLoader.find(".spinner").fadeTo(200, 0, function(){
@@ -93,28 +75,23 @@ app.views.NotificationDropdown = app.views.Base.extend({
     });
   },
 
-  renderNotifications: function(){
-    var self = this;
-    this.dropdownNotifications.find(".media.stream-element").remove();
-    $.each(self.notifications, function(index, notifications){
-      $.each(notifications, function(index, notification){
-        if($.inArray(notification, notifications) === -1){
-          var node = self.dropdownNotifications.append(notification.note_html);
-          $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip();
-          $(node).find(self.avatars.selector).error(self.avatars.fallback);
-        }
-      });
-    });
+  onPushBack: function(notification) {
+    var node = this.dropdownNotifications.append(notification.get("note_html"));
+    $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip();
+    $(node).find(this.avatars.selector).error(this.avatars.fallback);
+  },
 
-    this.hideAjaxLoader();
+  onPushFront: function(notification) {
+    var node = this.dropdownNotifications.prepend(notification.get("note_html"));
+    $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip();
+    $(node).find(this.avatars.selector).error(this.avatars.fallback);
+  },
 
+  finishLoading: function() {
     app.helpers.timeago(this.dropdownNotifications);
-
     this.updateScrollbar();
+    this.hideAjaxLoader();
     this.dropdownNotifications.removeClass("loading");
-    this.dropdownNotifications.scroll(function(){
-      self.dropdownScroll();
-    });
   },
 
   updateScrollbar: function() {
diff --git a/app/assets/javascripts/app/views/notifications_view.js b/app/assets/javascripts/app/views/notifications_view.js
index 08400742ec..3ae156348b 100644
--- a/app/assets/javascripts/app/views/notifications_view.js
+++ b/app/assets/javascripts/app/views/notifications_view.js
@@ -1,96 +1,85 @@
 // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
 
 app.views.Notifications = Backbone.View.extend({
-
   events: {
-    "click .unread-toggle" : "toggleUnread",
-    "click #mark_all_read_link": "markAllRead"
+    "click .unread-toggle": "toggleUnread",
+    "click #mark-all-read-link": "markAllRead"
   },
 
   initialize: function() {
     $(".unread-toggle .entypo-eye").tooltip();
     app.helpers.timeago($(document));
+    this.bindCollectionEvents();
+  },
+
+  bindCollectionEvents: function() {
+    this.collection.on("change", this.onChangedUnreadStatus.bind(this));
+    this.collection.on("update", this.updateView.bind(this));
   },
 
   toggleUnread: function(evt) {
     var note = $(evt.target).closest(".stream-element");
     var unread = note.hasClass("unread");
     var guid = note.data("guid");
-    if (unread){ this.setRead(guid); }
-    else { this.setUnread(guid); }
-  },
-
-  getAllUnread: function() { return $(".media.stream-element.unread"); },
-
-  setRead: function(guid) { this.setUnreadStatus(guid, false); },
-
-  setUnread: function(guid){ this.setUnreadStatus(guid, true); },
-
-  setUnreadStatus: function(guid, state){
-    $.ajax({
-      url: "/notifications/" + guid,
-      data: { set_unread: state },
-      type: "PUT",
-      context: this,
-      success: this.clickSuccess
-    });
+    if (unread) {
+      this.collection.setRead(guid);
+    } else {
+      this.collection.setUnread(guid);
+    }
   },
 
-  clickSuccess: function(data) {
-    var guid = data.guid;
-    var type = $(".stream-element[data-guid=" + guid + "]").data("type");
-    this.updateView(guid, type, data.unread);
+  markAllRead: function() {
+    this.collection.setAllRead();
   },
 
-  markAllRead: function(evt){
-    if(evt) { evt.preventDefault(); }
-    var self = this;
-    this.getAllUnread().each(function(i, el){
-      self.setRead($(el).data("guid"));
-    });
+  onChangedUnreadStatus: function(model) {
+    var unread = model.get("unread");
+    var translationKey = unread ? "notifications.mark_read" : "notifications.mark_unread";
+    var note = $(".stream-element[data-guid=" + model.guid + "]");
+
+    note.find(".entypo-eye")
+      .tooltip("destroy")
+      .removeAttr("data-original-title")
+      .attr("title", Diaspora.I18n.t(translationKey))
+      .tooltip();
+
+    if (unread) {
+      note.removeClass("read").addClass("unread");
+    } else {
+      note.removeClass("unread").addClass("read");
+    }
   },
 
-  updateView: function(guid, type, unread) {
-    var change = unread ? 1 : -1,
-        allNotes = $("#notifications_container .list-group > a:eq(0) .badge"),
-        typeNotes = $("#notifications_container .list-group > a[data-type=" + type + "] .badge"),
-        headerBadge = $(".notifications-link .badge"),
-        note = $(".notifications .stream-element[data-guid=" + guid + "]"),
-        markAllReadLink = $("a#mark_all_read_link"),
-        translationKey = unread ? "notifications.mark_read" : "notifications.mark_unread";
+  updateView: function() {
+    var notificationsContainer = $("#notifications_container");
 
-    if(unread){ note.removeClass("read").addClass("unread"); }
-    else { note.removeClass("unread").addClass("read"); }
+    // update notification counts in the sidebar
+    Object.keys(this.collection.unreadCountByType).forEach(function(notificationType) {
+      var count = this.collection.unreadCountByType[notificationType];
+      this.updateBadge(notificationsContainer.find("a[data-type=" + notificationType + "] .badge"), count);
+    }.bind(this));
 
-    $(".unread-toggle .entypo-eye", note)
-        .tooltip("destroy")
-        .removeAttr("data-original-title")
-        .attr("title",Diaspora.I18n.t(translationKey))
-        .tooltip();
+    this.updateBadge(notificationsContainer.find("a[data-type=all] .badge"), this.collection.unreadCount);
 
-    [allNotes, typeNotes, headerBadge].forEach(function(element){
-      element.text(function(i, text){
-        return parseInt(text) + change;
-      });
-    });
+    // update notification count in the header
+    this.updateBadge($(".notifications-link .badge"), this.collection.unreadCount);
 
-    [allNotes, typeNotes].forEach(function(badge) {
-      if(badge.text() > 0) {
-        badge.removeClass("hidden");
-      }
-      else {
-        badge.addClass("hidden");
-      }
-    });
+    var markAllReadLink = $("a#mark-all-read-link");
 
-    if(headerBadge.text() > 0){
-      headerBadge.removeClass("hidden");
+    if (this.collection.unreadCount > 0) {
       markAllReadLink.removeClass("disabled");
-    }
-    else{
-      headerBadge.addClass("hidden");
+    } else {
       markAllReadLink.addClass("disabled");
     }
+  },
+
+  updateBadge: function(badge, count) {
+    badge.text(count);
+    if (count > 0) {
+      badge.removeClass("hidden");
+    } else {
+      badge.addClass("hidden");
+    }
   }
 });
 // @license-end
diff --git a/app/assets/javascripts/helpers/browser_notification.js b/app/assets/javascripts/helpers/browser_notification.js
new file mode 100644
index 0000000000..e8c8b7d8fd
--- /dev/null
+++ b/app/assets/javascripts/helpers/browser_notification.js
@@ -0,0 +1,22 @@
+Diaspora.BrowserNotification = {
+  requestPermission: function() {
+    if ("Notification" in window && Notification.permission !== "granted" && Notification.permission !== "denied") {
+      Notification.requestPermission();
+    }
+  },
+
+  spawnNotification: function(title, summary) {
+    if ("Notification" in window && Notification.permission === "granted") {
+      if (!_.isString(title)) {
+        throw new Error("No notification title given.");
+      }
+
+      summary = summary || "";
+
+      new Notification(title, {
+        body: summary,
+        icon: ImagePaths.get("branding/logos/asterisk_white_mobile.png")
+      });
+    }
+  }
+};
diff --git a/app/assets/templates/header_tpl.jst.hbs b/app/assets/templates/header_tpl.jst.hbs
index a895d99c92..ab8a27a959 100644
--- a/app/assets/templates/header_tpl.jst.hbs
+++ b/app/assets/templates/header_tpl.jst.hbs
@@ -53,7 +53,7 @@
               <ul class="dropdown-menu" role="menu">
                 <div class="header">
                   <div class="pull-right">
-                    <a href="#" id="mark_all_read_link" class="btn btn-default btn-sm {{#unless current_user.notifications_count}}disabled{{/unless}}">
+                    <a href="#" id="mark-all-read-link" class="btn btn-default btn-sm {{#unless current_user.notifications_count}}disabled{{/unless}}">
                       {{t "header.mark_all_as_read"}}
                     </a>
                   </div>
@@ -61,11 +61,10 @@
                     {{t "header.recent_notifications"}}
                   </h4>
                 </div>
-                <div class="notifications">
-                  <div class="ajax-loader">
-                    <div class="spinner"></div>
-                  </div>
+                <div class="ajax-loader">
+                  <div class="spinner"></div>
                 </div>
+                <div class="notifications"></div>
                 <div class="view_all">
                   <a href="/notifications" id="view_all_notifications">
                     {{t "header.view_all"}}
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 7ddb9b8d6b..aae218b69f 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -52,7 +52,7 @@ class NotificationsController < ApplicationController
       format.html
       format.xml { render :xml => @notifications.to_xml }
       format.json {
-        render json: @notifications, each_serializer: NotificationSerializer
+        render json: render_as_json(@unread_notification_count, @grouped_unread_notification_counts, @notifications)
       }
     end
   end
@@ -82,4 +82,15 @@ class NotificationsController < ApplicationController
     end
   end
 
+  private
+
+  def render_as_json(unread_count, unread_count_by_type, notification_list)
+    {
+      unread_count:         unread_count,
+      unread_count_by_type: unread_count_by_type,
+      notification_list:    notification_list.map {|note|
+        NotificationSerializer.new(note, default_serializer_options).as_json
+      }
+    }.as_json
+  end
 end
diff --git a/app/views/notifications/index.html.haml b/app/views/notifications/index.html.haml
index fde543ffcc..22e647c055 100644
--- a/app/views/notifications/index.html.haml
+++ b/app/views/notifications/index.html.haml
@@ -7,7 +7,8 @@
             = t(".notifications")
         .list-group
           %a.list-group-item{href: "/notifications" + (params[:show] == "unread" ? "?show=unread" : ""),
-              class: ("active" unless params[:type] && @grouped_unread_notification_counts.has_key?(params[:type]))}
+              class: ("active" unless params[:type] && @grouped_unread_notification_counts.has_key?(params[:type])),
+              data: {type: "all"}}
             %span.pull-right.badge{class: ("hidden" unless @unread_notification_count > 0)}
               = @unread_notification_count
             = t(".all_notifications")
diff --git a/config/locales/javascript/javascript.en.yml b/config/locales/javascript/javascript.en.yml
index 2f1f7362c0..b7e71a955b 100644
--- a/config/locales/javascript/javascript.en.yml
+++ b/config/locales/javascript/javascript.en.yml
@@ -248,6 +248,9 @@ en:
     notifications:
       mark_read: "Mark read"
       mark_unread: "Mark unread"
+      new_notifications:
+        one: "You have <%= count %> unread notification"
+        other: "You have <%= count %> unread notifications"
 
     stream:
       hide: "Hide"
diff --git a/spec/controllers/jasmine_fixtures/notifications_spec.rb b/spec/controllers/jasmine_fixtures/notifications_spec.rb
index 75f95bc801..0b1400add6 100644
--- a/spec/controllers/jasmine_fixtures/notifications_spec.rb
+++ b/spec/controllers/jasmine_fixtures/notifications_spec.rb
@@ -14,6 +14,8 @@ describe NotificationsController, :type => :controller do
     it "generates a jasmine fixture", :fixture => true do
       get :index
       save_fixture(html_for("body"), "notifications")
+      get :index, format: :json
+      save_fixture(response.body, "notifications_collection")
     end
   end
 end
diff --git a/spec/controllers/notifications_controller_spec.rb b/spec/controllers/notifications_controller_spec.rb
index b5e32bb9ef..4c4a160779 100644
--- a/spec/controllers/notifications_controller_spec.rb
+++ b/spec/controllers/notifications_controller_spec.rb
@@ -68,15 +68,24 @@ describe NotificationsController, :type => :controller do
       expect(assigns[:notifications].count).to eq(1)
     end
 
-    it 'succeeds for notification dropdown' do
+    it "succeeds for notification dropdown" do
       Timecop.travel(6.seconds.ago) do
         @notification.touch
       end
-      get :index, :format => :json
+      get :index, format: :json
       expect(response).to be_success
-      note_html = JSON.parse(response.body)[0]["also_commented"]["note_html"]
-      note_html = Nokogiri::HTML(note_html)
+      response_json = JSON.parse(response.body)
+      note_html = Nokogiri::HTML(response_json["notification_list"][0]["also_commented"]["note_html"])
       timeago_content = note_html.css("time")[0]["data-time-ago"]
+      expect(response_json["unread_count"]).to be(1)
+      expect(response_json["unread_count_by_type"]).to eq(
+        "also_commented"  => 1,
+        "comment_on_post" => 0,
+        "liked"           => 0,
+        "mentioned"       => 0,
+        "reshared"        => 0,
+        "started_sharing" => 0
+      )
       expect(timeago_content).to include(@notification.updated_at.iso8601)
       expect(response.body).to match(/note_html/)
     end
diff --git a/spec/javascripts/app/collections/notifications_collection_spec.js b/spec/javascripts/app/collections/notifications_collection_spec.js
new file mode 100644
index 0000000000..d5f8cd5223
--- /dev/null
+++ b/spec/javascripts/app/collections/notifications_collection_spec.js
@@ -0,0 +1,249 @@
+describe("app.collections.Notifications", function() {
+  describe("initialize", function() {
+    it("calls pollNotifications", function() {
+      spyOn(app.collections.Notifications.prototype, "pollNotifications");
+      new app.collections.Notifications();
+      expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalled();
+    });
+
+    it("calls Diaspora.BrowserNotification.requestPermission", function() {
+      spyOn(Diaspora.BrowserNotification, "requestPermission");
+      new app.collections.Notifications();
+      expect(Diaspora.BrowserNotification.requestPermission).toHaveBeenCalled();
+    });
+
+    it("initializes attributes", function() {
+      var target = new app.collections.Notifications();
+      expect(target.model).toBe(app.models.Notification);
+      /* eslint-disable camelcase */
+      expect(target.url).toBe(Routes.notifications({per_page: 10, page: 1}));
+      /* eslint-enable camelcase */
+      expect(target.page).toBe(2);
+      expect(target.perPage).toBe(5);
+      expect(target.unreadCount).toBe(0);
+      expect(target.unreadCountByType).toEqual({});
+    });
+  });
+
+  describe("pollNotifications", function() {
+    beforeEach(function() {
+      this.target = new app.collections.Notifications();
+    });
+
+    it("calls fetch", function() {
+      spyOn(app.collections.Notifications.prototype, "fetch");
+      this.target.pollNotifications();
+      expect(app.collections.Notifications.prototype.fetch).toHaveBeenCalled();
+    });
+
+    it("doesn't call Diaspora.BrowserNotification.spawnNotification when there are no new notifications", function() {
+      spyOn(Diaspora.BrowserNotification, "spawnNotification");
+      this.target.pollNotifications();
+      this.target.trigger("finishedLoading");
+      expect(Diaspora.BrowserNotification.spawnNotification).not.toHaveBeenCalled();
+    });
+
+    it("calls Diaspora.BrowserNotification.spawnNotification when there are new notifications", function() {
+      spyOn(Diaspora.BrowserNotification, "spawnNotification");
+      spyOn(app.collections.Notifications.prototype, "fetch").and.callFake(function() {
+        this.target.unreadCount++;
+      }.bind(this));
+      this.target.pollNotifications();
+      this.target.trigger("finishedLoading");
+      expect(Diaspora.BrowserNotification.spawnNotification).toHaveBeenCalled();
+    });
+
+    it("refreshes after timeout", function() {
+      spyOn(app.collections.Notifications.prototype, "pollNotifications").and.callThrough();
+      this.target.pollNotifications();
+      expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalledTimes(1);
+      jasmine.clock().tick(2 * this.target.timeout);
+      expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  describe("fetch", function() {
+    it("calls Backbone.Collection.prototype.fetch with correct parameters", function() {
+      var target = new app.collections.Notifications();
+      spyOn(Backbone.Collection.prototype, "fetch");
+      target.fetch({foo: "bar", remove: "bar", merge: "bar", parse: "bar"});
+      expect(Backbone.Collection.prototype.fetch.calls.mostRecent().args).toEqual([{
+        foo: "bar",
+        remove: false,
+        merge: true,
+        parse: true
+      }]);
+    });
+  });
+
+  describe("fetchMore", function() {
+    beforeEach(function() {
+      this.target = new app.collections.Notifications();
+      spyOn(app.collections.Notifications.prototype, "fetch");
+    });
+
+    it("fetches notifications when there are more notifications to be fetched", function() {
+      this.target.length = 15;
+      this.target.fetchMore();
+      /* eslint-disable camelcase */
+      var route = Routes.notifications({per_page: 5, page: 3});
+      /* eslint-enable camelcase */
+      expect(app.collections.Notifications.prototype.fetch).toHaveBeenCalledWith({url: route, pushBack: true});
+      expect(this.target.page).toBe(3);
+    });
+
+    it("doesn't fetch notifications when there are no more notifications to be fetched", function() {
+      this.target.length = 0;
+      this.target.fetchMore();
+      expect(app.collections.Notifications.prototype.fetch).not.toHaveBeenCalled();
+      expect(this.target.page).toBe(2);
+    });
+  });
+
+  describe("set", function() {
+    beforeEach(function() {
+      this.target = new app.collections.Notifications();
+    });
+
+    context("calls to Backbone.Collection.prototype.set", function() {
+      beforeEach(function() {
+        spyOn(Backbone.Collection.prototype, "set");
+      });
+
+      it("calls app.collections.Notifications.prototype.set", function() {
+        this.target.set([]);
+        expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {at: 0});
+      });
+
+      it("inserts the items at the beginning of the collection if option 'pushBack' is false", function() {
+        this.target.length = 15;
+        this.target.set([], {pushBack: false});
+        expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {pushBack: false, at: 0});
+      });
+
+      it("inserts the items at the end of the collection if option 'pushBack' is true", function() {
+        this.target.length = 15;
+        this.target.set([], {pushBack: true});
+        expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {pushBack: true, at: 15});
+      });
+    });
+
+    context("events", function() {
+      beforeEach(function() {
+        spyOn(Backbone.Collection.prototype, "set").and.callThrough();
+        spyOn(app.collections.Notifications.prototype, "trigger").and.callThrough();
+        this.model1 = new app.models.Notification({"reshared": {id: 1}, "type": "reshared"});
+        this.model2 = new app.models.Notification({"reshared": {id: 2}, "type": "reshared"});
+        this.model3 = new app.models.Notification({"reshared": {id: 3}, "type": "reshared"});
+        this.model4 = new app.models.Notification({"reshared": {id: 4}, "type": "reshared"});
+      });
+
+      it("triggers a 'pushFront' event for each model in reverse order when option 'pushBack' is false", function() {
+        this.target.set([this.model1, this.model2, this.model3, this.model4], {pushBack: false});
+
+        var calls = app.collections.Notifications.prototype.trigger.calls;
+
+        var index = calls.count() - 5;
+        expect(calls.argsFor(index)).toEqual(["pushFront", this.model4]);
+        expect(calls.argsFor(index + 1)).toEqual(["pushFront", this.model3]);
+        expect(calls.argsFor(index + 2)).toEqual(["pushFront", this.model2]);
+        expect(calls.argsFor(index + 3)).toEqual(["pushFront", this.model1]);
+      });
+
+      it("triggers a 'pushBack' event for each model in normal order when option 'pushBack' is true", function() {
+        this.target.set([this.model1, this.model2, this.model3, this.model4], {pushBack: true});
+
+        var calls = app.collections.Notifications.prototype.trigger.calls;
+
+        var index = calls.count() - 5;
+        expect(calls.argsFor(index)).toEqual(["pushBack", this.model1]);
+        expect(calls.argsFor(index + 1)).toEqual(["pushBack", this.model2]);
+        expect(calls.argsFor(index + 2)).toEqual(["pushBack", this.model3]);
+        expect(calls.argsFor(index + 3)).toEqual(["pushBack", this.model4]);
+      });
+
+      it("triggers a 'finishedLoading' event at the end of the process", function() {
+        this.target.set([]);
+        expect(app.collections.Notifications.prototype.trigger).toHaveBeenCalledWith("finishedLoading");
+      });
+    });
+  });
+
+  describe("parse", function() {
+    beforeEach(function() {
+      this.target = new app.collections.Notifications();
+    });
+
+    it("sets the unreadCount and unreadCountByType attributes", function() {
+      expect(this.target.unreadCount).toBe(0);
+      expect(this.target.unreadCountByType).toEqual({});
+
+      /* eslint-disable camelcase */
+      this.target.parse({
+        unread_count: 15,
+        unread_count_by_type: {reshared: 6},
+        notification_list: []
+      });
+      /* eslint-enable camelcase */
+
+      expect(this.target.unreadCount).toBe(15);
+      expect(this.target.unreadCountByType).toEqual({reshared: 6});
+    });
+
+    it("correctly parses the result", function() {
+      /* eslint-disable camelcase */
+      var parsed = this.target.parse({
+        unread_count: 15,
+        unread_count_by_type: {reshared: 6},
+        notification_list: [{"reshared": {id: 1}, "type": "reshared"}]
+      });
+      /* eslint-enable camelcase */
+
+      expect(parsed.length).toEqual(1);
+    });
+
+    it("correctly binds the change:unread event", function() {
+      spyOn(app.collections.Notifications.prototype, "onChangedUnreadStatus");
+
+      /* eslint-disable camelcase */
+      var parsed = this.target.parse({
+        unread_count: 15,
+        unread_count_by_type: {reshared: 6},
+        notification_list: [{"reshared": {id: 1}, "type": "reshared"}]
+      });
+      /* eslint-enable camelcase */
+
+      parsed[0].set("unread", true);
+
+      expect(app.collections.Notifications.prototype.onChangedUnreadStatus).toHaveBeenCalled();
+    });
+  });
+
+  describe("onChangedUnreadStatus", function() {
+    it("increases the unread counts when model's unread attribute is true", function() {
+      var target = new app.collections.Notifications();
+      var model = new app.models.Notification({"reshared": {id: 1, unread: true}, "type": "reshared"});
+
+      target.unreadCount = 15;
+      target.unreadCountByType.reshared = 6;
+
+      target.onChangedUnreadStatus(model);
+
+      expect(target.unreadCount).toBe(16);
+      expect(target.unreadCountByType.reshared).toBe(7);
+    });
+
+    it("decreases the unread counts when model's unread attribute is false", function() {
+      var target = new app.collections.Notifications();
+      var model = new app.models.Notification({"reshared": {id: 1, unread: false}, "type": "reshared"});
+
+      target.unreadCount = 15;
+      target.unreadCountByType.reshared = 6;
+
+      target.onChangedUnreadStatus(model);
+
+      expect(target.unreadCount).toBe(14);
+      expect(target.unreadCountByType.reshared).toBe(5);
+    });
+  });
+});
diff --git a/spec/javascripts/app/models/notification_spec.js b/spec/javascripts/app/models/notification_spec.js
new file mode 100644
index 0000000000..d405c34ec3
--- /dev/null
+++ b/spec/javascripts/app/models/notification_spec.js
@@ -0,0 +1,85 @@
+describe("app.models.Notification", function() {
+  beforeEach(function() {
+    this.model = new app.models.Notification({
+      "reshared": {},
+      "type": "reshared"
+    });
+  });
+
+  describe("constructor", function() {
+    it("calls parent constructor with the correct parameters", function() {
+      spyOn(Backbone, "Model").and.callThrough();
+      new app.models.Notification({attribute: "attribute"}, {option: "option"});
+      expect(Backbone.Model).toHaveBeenCalledWith(
+        {attribute: "attribute"},
+        {option: "option", parse: true}
+      );
+    });
+  });
+
+  describe("parse", function() {
+    it("correctly parses the object", function() {
+      var parsed = this.model.parse({
+        "reshared": {
+          "id": 45,
+          "target_type": "Post",
+          "target_id": 11,
+          "recipient_id": 1,
+          "unread": true,
+          "created_at": "2015-10-27T19:56:30.000Z",
+          "updated_at": "2015-10-27T19:56:30.000Z",
+          "note_html": "<html/>"
+        },
+        "type": "reshared"
+      });
+
+      expect(parsed).toEqual({
+        "type": "reshared",
+        "id": 45,
+        "target_type": "Post",
+        "target_id": 11,
+        "recipient_id": 1,
+        "unread": true,
+        "created_at": "2015-10-27T19:56:30.000Z",
+        "updated_at": "2015-10-27T19:56:30.000Z",
+        "note_html": "<html/>"
+      });
+    });
+  });
+
+  describe("setRead", function() {
+    it("calls setUnreadStatus with 'false'", function() {
+      spyOn(app.models.Notification.prototype, "setUnreadStatus");
+      new app.models.Notification({"reshared": {}, "type": "reshared"}).setRead();
+      expect(app.models.Notification.prototype.setUnreadStatus).toHaveBeenCalledWith(false);
+    });
+  });
+
+  describe("setUnread", function() {
+    it("calls setUnreadStatus with 'true'", function() {
+      spyOn(app.models.Notification.prototype, "setUnreadStatus");
+      new app.models.Notification({"reshared": {}, "type": "reshared"}).setUnread();
+      expect(app.models.Notification.prototype.setUnreadStatus).toHaveBeenCalledWith(true);
+    });
+  });
+
+  describe("setUnreadStatus", function() {
+    beforeEach(function() {
+      this.target = new app.models.Notification({"reshared": {id: 16}, "type": "reshared"});
+      spyOn(app.models.Notification.prototype, "set").and.callThrough();
+    });
+
+    it("calls calls ajax with correct parameters and sets 'unread' attribute", function() {
+      this.target.setUnreadStatus(true);
+      jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: '{"guid": 16, "unread": true}'});
+      var call = jasmine.Ajax.requests.mostRecent();
+
+      expect(call.url).toBe("/notifications/16");
+      /* eslint-disable camelcase */
+      expect(call.params).toEqual("set_unread=true");
+      /* eslint-enable camelcase */
+      expect(call.method).toEqual("PUT");
+      expect(app.models.Notification.prototype.set).toHaveBeenCalledWith("unread", true);
+    });
+  });
+});
diff --git a/spec/javascripts/app/views/header_view_spec.js b/spec/javascripts/app/views/header_view_spec.js
index 0850935e8b..3bf13978f9 100644
--- a/spec/javascripts/app/views/header_view_spec.js
+++ b/spec/javascripts/app/views/header_view_spec.js
@@ -6,6 +6,7 @@ describe("app.views.Header", function() {
 
     spec.loadFixture("aspects_index");
     gon.appConfig = {settings: {podname: "MyPod"}};
+    app.notificationsCollection = new app.collections.Notifications();
     this.view = new app.views.Header().render();
   });
 
diff --git a/spec/javascripts/app/views/notification_dropdown_view_spec.js b/spec/javascripts/app/views/notification_dropdown_view_spec.js
index 891023032e..69dcb4a567 100644
--- a/spec/javascripts/app/views/notification_dropdown_view_spec.js
+++ b/spec/javascripts/app/views/notification_dropdown_view_spec.js
@@ -1,125 +1,74 @@
 describe("app.views.NotificationDropdown", function() {
-  beforeEach(function (){
+  beforeEach(function() {
     spec.loadFixture("notifications");
     gon.appConfig = {settings: {podname: "MyPod"}};
     this.header = new app.views.Header();
     $("header").prepend(this.header.el);
     loginAs({guid: "foo"});
     this.header.render();
-    this.view = new app.views.NotificationDropdown({el: "#notification-dropdown"});
+    this.collection = new app.collections.Notifications();
+    this.view = new app.views.NotificationDropdown({el: "#notification-dropdown", collection: this.collection});
   });
 
-  context("showDropdown", function(){
-    it("Calls resetParam()", function(){
-      spyOn(this.view, "resetParams");
-      this.view.showDropdown();
-      expect(this.view.resetParams).toHaveBeenCalled();
+  describe("bindCollectionEvents", function() {
+    beforeEach(function() {
+      this.view.collection.off("pushFront");
+      this.view.collection.off("pushBack");
+      this.view.collection.off("finishedLoading");
+      spyOn(this.view, "onPushFront");
+      spyOn(this.view, "onPushBack");
+      spyOn(this.view, "finishLoading");
     });
-    it("Calls updateScrollbar()", function(){
+
+    it("binds collection events", function() {
+      this.view.bindCollectionEvents();
+
+      this.collection.trigger("pushFront");
+      this.collection.trigger("pushBack");
+      this.collection.trigger("finishedLoading");
+
+      expect(this.view.onPushFront).toHaveBeenCalled();
+      expect(this.view.onPushBack).toHaveBeenCalled();
+      expect(this.view.finishLoading).toHaveBeenCalled();
+    });
+  });
+
+  describe("showDropdown", function() {
+    it("Calls updateScrollbar", function() {
       spyOn(this.view, "updateScrollbar");
       this.view.showDropdown();
       expect(this.view.updateScrollbar).toHaveBeenCalled();
     });
-    it("Changes CSS", function(){
+    it("Changes CSS", function() {
       expect($("#notification-dropdown")).not.toHaveClass("dropdown-open");
       this.view.showDropdown();
       expect($("#notification-dropdown")).toHaveClass("dropdown-open");
     });
-    it("Calls getNotifications()", function(){
-      spyOn(this.view, "getNotifications");
+    it("Calls collection#fetch", function() {
+      spyOn(this.collection, "fetch");
       this.view.showDropdown();
-      expect(this.view.getNotifications).toHaveBeenCalled();
+      expect(this.collection.fetch).toHaveBeenCalled();
     });
   });
 
-  context("dropdownScroll", function(){
-    it("Calls getNotifications if is at the bottom and has more notifications to load", function(){
-      this.view.isBottom = function(){ return true; };
-      this.view.hasMoreNotifs = true;
-      spyOn(this.view, "getNotifications");
+  describe("dropdownScroll", function() {
+    it("Calls collection#fetchMore if it is at the bottom", function() {
+      this.view.isBottom = function() { return true; };
+      spyOn(this.collection, "fetchMore");
       this.view.dropdownScroll();
-      expect(this.view.getNotifications).toHaveBeenCalled();
+      expect(this.collection.fetchMore).toHaveBeenCalled();
     });
 
-    it("Doesn't call getNotifications if is not at the bottom", function(){
-      this.view.isBottom = function(){ return false; };
-      this.view.hasMoreNotifs = true;
-      spyOn(this.view, "getNotifications");
+    it("Doesn't call collection#fetchMore if it is not at the bottom", function() {
+      this.view.isBottom = function() { return false; };
+      spyOn(this.collection, "fetchMore");
       this.view.dropdownScroll();
-      expect(this.view.getNotifications).not.toHaveBeenCalled();
-    });
-
-    it("Doesn't call getNotifications if is not at the bottom", function(){
-      this.view.isBottom = function(){ return true; };
-      this.view.hasMoreNotifs = false;
-      spyOn(this.view, "getNotifications");
-      this.view.dropdownScroll();
-      expect(this.view.getNotifications).not.toHaveBeenCalled();
-    });
-  });
-
-  context("getNotifications", function(){
-    it("Has more notifications", function(){
-      var response = ["", "", "", "", ""];
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); });
-      this.view.getNotifications();
-      expect(this.view.hasMoreNotifs).toBe(true);
-    });
-    it("Has no more notifications", function(){
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); });
-      this.view.getNotifications();
-      expect(this.view.hasMoreNotifs).toBe(false);
-    });
-    it("Correctly sets the next page", function(){
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); });
-      expect(typeof this.view.nextPage).toBe("undefined");
-      this.view.getNotifications();
-      expect(this.view.nextPage).toBe(3);
-    });
-    it("Increase the page count", function(){
-      var response = ["", "", "", "", ""];
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); });
-      this.view.getNotifications();
-      expect(this.view.nextPage).toBe(3);
-      this.view.getNotifications();
-      expect(this.view.nextPage).toBe(4);
-    });
-    it("Calls renderNotifications()", function(){
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); });
-      spyOn(this.view, "renderNotifications");
-      this.view.getNotifications();
-      expect(this.view.renderNotifications).toHaveBeenCalled();
-    });
-    it("Adds the notifications to this.notifications", function(){
-      var response = ["", "", "", "", ""];
-      this.view.notifications.length = 0;
-      spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); });
-      this.view.getNotifications();
-      expect(this.view.notifications).toEqual(response);
-    });
-  });
-
-  context("renderNotifications", function(){
-    it("Removes the previous notifications", function(){
-      this.view.dropdownNotifications.append("<div class=\"media stream-element\">Notification</div>");
-      expect(this.view.dropdownNotifications.find(".media.stream-element").length).toBe(1);
-      this.view.renderNotifications();
-      expect(this.view.dropdownNotifications.find(".media.stream-element").length).toBe(0);
-    });
-    it("Calls hideAjaxLoader()", function(){
-      spyOn(this.view, "hideAjaxLoader");
-      this.view.renderNotifications();
-      expect(this.view.hideAjaxLoader).toHaveBeenCalled();
-    });
-    it("Calls updateScrollbar()", function(){
-      spyOn(this.view, "updateScrollbar");
-      this.view.renderNotifications();
-      expect(this.view.updateScrollbar).toHaveBeenCalled();
+      expect(this.collection.fetchMore).not.toHaveBeenCalled();
     });
   });
 
-  context("updateScrollbar", function() {
-    it("Initializes perfectScrollbar", function(){
+  describe("updateScrollbar", function() {
+    it("Initializes perfectScrollbar", function() {
       this.view.perfectScrollbarInitialized = false;
       spyOn($.fn, "perfectScrollbar");
       this.view.updateScrollbar();
@@ -128,7 +77,7 @@ describe("app.views.NotificationDropdown", function() {
       expect(this.view.perfectScrollbarInitialized).toBeTruthy();
     });
 
-    it("Updates perfectScrollbar", function(){
+    it("Updates perfectScrollbar", function() {
       this.view.perfectScrollbarInitialized = true;
       this.view.dropdownNotifications.perfectScrollbar();
       spyOn($.fn, "perfectScrollbar");
@@ -139,8 +88,8 @@ describe("app.views.NotificationDropdown", function() {
     });
   });
 
-  context("destroyScrollbar", function() {
-    it("destroys perfectScrollbar", function(){
+  describe("destroyScrollbar", function() {
+    it("destroys perfectScrollbar", function() {
       this.view.perfectScrollbarInitialized = true;
       this.view.dropdownNotifications.perfectScrollbar();
       spyOn($.fn, "perfectScrollbar");
@@ -150,7 +99,7 @@ describe("app.views.NotificationDropdown", function() {
       expect(this.view.perfectScrollbarInitialized).toBeFalsy();
     });
 
-    it("doesn't destroy perfectScrollbar if it isn't initialized", function(){
+    it("doesn't destroy perfectScrollbar if it isn't initialized", function() {
       this.view.perfectScrollbarInitialized = false;
       spyOn($.fn, "perfectScrollbar");
       this.view.destroyScrollbar();
diff --git a/spec/javascripts/app/views/notifications_view_spec.js b/spec/javascripts/app/views/notifications_view_spec.js
index 94f8a83946..d837703007 100644
--- a/spec/javascripts/app/views/notifications_view_spec.js
+++ b/spec/javascripts/app/views/notifications_view_spec.js
@@ -1,8 +1,36 @@
-describe("app.views.Notifications", function(){
+describe("app.views.Notifications", function() {
+  beforeEach(function() {
+    this.collection = new app.collections.Notifications();
+    this.collection.fetch();
+    jasmine.Ajax.requests.mostRecent().respondWith({
+      status: 200,
+      responseText: spec.readFixture("notifications_collection")
+    });
+  });
+
   context("on the notifications page", function() {
     beforeEach(function() {
       spec.loadFixture("notifications");
-      this.view = new app.views.Notifications({el: "#notifications_container"});
+      this.view = new app.views.Notifications({el: "#notifications_container", collection: this.collection});
+    });
+
+    describe("bindCollectionEvents", function() {
+      beforeEach(function() {
+        this.view.collection.off("change");
+        this.view.collection.off("update");
+        spyOn(this.view, "onChangedUnreadStatus");
+        spyOn(this.view, "updateView");
+      });
+
+      it("binds collection events", function() {
+        this.view.bindCollectionEvents();
+
+        this.collection.trigger("change");
+        this.collection.trigger("update");
+
+        expect(this.view.onChangedUnreadStatus).toHaveBeenCalled();
+        expect(this.view.updateView).toHaveBeenCalled();
+      });
     });
 
     describe("mark read", function() {
@@ -11,11 +39,11 @@ describe("app.views.Notifications", function(){
         this.guid = this.unreadN.data("guid");
       });
 
-      it("calls 'setRead'", function() {
-        spyOn(this.view, "setRead");
+      it("calls collection's 'setRead'", function() {
+        spyOn(this.collection, "setRead");
         this.unreadN.find(".unread-toggle").trigger("click");
 
-        expect(this.view.setRead).toHaveBeenCalledWith(this.guid);
+        expect(this.collection.setRead).toHaveBeenCalledWith(this.guid);
       });
     });
 
@@ -25,11 +53,11 @@ describe("app.views.Notifications", function(){
         this.guid = this.readN.data("guid");
       });
 
-      it("calls 'setUnread'", function() {
-        spyOn(this.view, "setUnread");
+      it("calls collection's 'setUnread'", function() {
+        spyOn(this.collection, "setUnread");
         this.readN.find(".unread-toggle").trigger("click");
 
-        expect(this.view.setUnread).toHaveBeenCalledWith(this.guid);
+        expect(this.collection.setUnread).toHaveBeenCalledWith(this.guid);
       });
     });
 
@@ -40,42 +68,65 @@ describe("app.views.Notifications", function(){
         this.type = this.readN.data("type");
       });
 
-      it("changes the 'all notifications' count", function() {
+      it("increases the 'all notifications' count", function() {
         var badge = $(".list-group > a:eq(0) .badge");
-        var count = parseInt(badge.text());
+        expect(parseInt(badge.text(), 10)).toBe(2);
 
-        this.view.updateView(this.guid, this.type, true);
-        expect(parseInt(badge.text())).toBe(count + 1);
+        this.collection.unreadCount++;
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(3);
 
-        this.view.updateView(this.guid, this.type, false);
-        expect(parseInt(badge.text())).toBe(count);
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(3);
       });
 
-      it("changes the notification type count", function() {
+      it("decreases the 'all notifications' count", function() {
+        var badge = $(".list-group > a:eq(0) .badge");
+        expect(parseInt(badge.text(), 10)).toBe(2);
+
+        this.collection.unreadCount--;
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(1);
+
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(1);
+      });
+
+      it("increases the notification type count", function() {
         var badge = $(".list-group > a[data-type=" + this.type + "] .badge");
-        var count = parseInt(badge.text());
 
-        this.view.updateView(this.guid, this.type, true);
-        expect(parseInt(badge.text())).toBe(count + 1);
+        expect(parseInt(badge.text(), 10)).toBe(1);
+
+        this.collection.unreadCountByType[this.type]++;
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(2);
 
-        this.view.updateView(this.guid, this.type, false);
-        expect(parseInt(badge.text())).toBe(count);
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(2);
       });
 
-      it("toggles the unread class and changes the title", function() {
-        this.view.updateView(this.readN.data("guid"), this.readN.data("type"), true);
-        expect(this.readN.hasClass("unread")).toBeTruthy();
-        expect(this.readN.hasClass("read")).toBeFalsy();
-        expect(this.readN.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
-          Diaspora.I18n.t("notifications.mark_read")
-        );
+      it("decreases the notification type count", function() {
+        var badge = $(".list-group > a[data-type=" + this.type + "] .badge");
 
-        this.view.updateView(this.readN.data("guid"), this.readN.data("type"), false);
-        expect(this.readN.hasClass("read")).toBeTruthy();
-        expect(this.readN.hasClass("unread")).toBeFalsy();
-        expect(this.readN.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
-          Diaspora.I18n.t("notifications.mark_unread")
-        );
+        expect(parseInt(badge.text(), 10)).toBe(1);
+
+        this.collection.unreadCountByType[this.type]--;
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(0);
+
+        this.view.updateView();
+        expect(parseInt(badge.text(), 10)).toBe(0);
+      });
+
+      it("hides badge count when notification count is zero", function() {
+        Object.keys(this.collection.unreadCountByType).forEach(function(notificationType) {
+          this.collection.unreadCountByType[notificationType] = 0;
+        }.bind(this));
+        this.collection.unreadCount = 0;
+
+        this.view.updateView();
+
+        expect($("a .badge")).toHaveClass("hidden");
       });
 
       context("with a header", function() {
@@ -84,6 +135,7 @@ describe("app.views.Notifications", function(){
           loginAs({name: "alice", avatar: {small: "http://avatar.com/photo.jpg"}, notifications_count: 2, guid: "foo"});
           /* jshint camelcase: true */
           gon.appConfig = {settings: {podname: "MyPod"}};
+          app.notificationsCollection = this.collection;
           this.header = new app.views.Header();
           $("header").prepend(this.header.el);
           this.header.render();
@@ -92,30 +144,77 @@ describe("app.views.Notifications", function(){
         it("changes the header notifications count", function() {
           var badge1 = $(".notifications-link:eq(0) .badge");
           var badge2 = $(".notifications-link:eq(1) .badge");
-          var count = parseInt(badge1.text(), 10);
 
-          this.view.updateView(this.guid, this.type, true);
-          expect(parseInt(badge1.text(), 10)).toBe(count + 1);
+          expect(parseInt(badge1.text(), 10)).toBe(this.collection.unreadCount);
+          expect(parseInt(badge2.text(), 10)).toBe(this.collection.unreadCount);
 
-          this.view.updateView(this.guid, this.type, false);
-          expect(parseInt(badge1.text(), 10)).toBe(count);
+          this.collection.unreadCount++;
+          this.view.updateView();
+          expect(parseInt(badge1.text(), 10)).toBe(this.collection.unreadCount);
 
-          this.view.updateView(this.guid, this.type, true);
-          expect(parseInt(badge2.text(), 10)).toBe(count + 1);
+          this.view.updateView();
+          expect(parseInt(badge2.text(), 10)).toBe(this.collection.unreadCount);
+        });
 
-          this.view.updateView(this.guid, this.type, false);
-          expect(parseInt(badge2.text(), 10)).toBe(count);
+        it("disables the mark-all-read-link button", function() {
+          expect($("a#mark-all-read-link")).not.toHaveClass("disabled");
+          this.collection.unreadCount = 0;
+          this.view.updateView();
+          expect($("a#mark-all-read-link")).toHaveClass("disabled");
         });
       });
     });
 
     describe("markAllRead", function() {
-      it("calls setRead for each unread notification", function(){
-        spyOn(this.view, "setRead");
+      it("calls collection#setAllRead", function() {
+        spyOn(this.collection, "setAllRead");
         this.view.markAllRead();
-        expect(this.view.setRead).toHaveBeenCalledWith(this.view.$(".stream-element.unread").eq(0).data("guid"));
-        this.view.markAllRead();
-        expect(this.view.setRead).toHaveBeenCalledWith(this.view.$(".stream-element.unread").eq(1).data("guid"));
+        expect(this.collection.setAllRead).toHaveBeenCalled();
+      });
+    });
+
+    describe("onChangedUnreadStatus", function() {
+      beforeEach(function() {
+        this.modelRead = new app.models.Notification({});
+        this.modelRead.set("unread", false);
+        this.modelRead.guid = $(".stream-element.unread").first().data("guid");
+        this.modelUnread = new app.models.Notification({});
+        this.modelUnread.set("unread", true);
+        this.modelUnread.guid = $(".stream-element.read").first().data("guid");
+      });
+
+      it("Adds the unread class and changes the title", function() {
+        var unreadEl = $(".stream-element[data-guid=" + this.modelUnread.guid + "]");
+
+        expect(unreadEl.hasClass("read")).toBeTruthy();
+        expect(unreadEl.hasClass("unread")).toBeFalsy();
+        expect(unreadEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
+          Diaspora.I18n.t("notifications.mark_unread")
+        );
+
+        this.view.onChangedUnreadStatus(this.modelUnread);
+        expect(unreadEl.hasClass("unread")).toBeTruthy();
+        expect(unreadEl.hasClass("read")).toBeFalsy();
+        expect(unreadEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
+          Diaspora.I18n.t("notifications.mark_read")
+        );
+      });
+
+      it("Removes the unread class and changes the title", function() {
+        var readEl = $(".stream-element[data-guid=" + this.modelRead.guid + "]");
+
+        expect(readEl.hasClass("unread")).toBeTruthy();
+        expect(readEl.hasClass("read")).toBeFalsy();
+        expect(readEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
+          Diaspora.I18n.t("notifications.mark_read")
+        );
+
+        this.view.onChangedUnreadStatus(this.modelRead);
+        expect(readEl.hasClass("read")).toBeTruthy();
+        expect(readEl.hasClass("unread")).toBeFalsy();
+        expect(readEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe(
+          Diaspora.I18n.t("notifications.mark_unread")
+        );
       });
     });
   });
@@ -123,47 +222,32 @@ describe("app.views.Notifications", function(){
   context("on the contacts page", function() {
     beforeEach(function() {
       spec.loadFixture("aspects_manage");
-      this.view = new app.views.Notifications({el: "#notifications_container"});
+      this.view = new app.views.Notifications({el: "#notifications_container", collection: this.collection});
       /* jshint camelcase: false */
       loginAs({name: "alice", avatar: {small: "http://avatar.com/photo.jpg"}, notifications_count: 2, guid: "foo"});
       /* jshint camelcase: true */
       gon.appConfig = {settings: {podname: "MyPod"}};
+      app.notificationsCollection = this.collection;
       this.header = new app.views.Header();
       $("header").prepend(this.header.el);
       this.header.render();
     });
 
     describe("updateView", function() {
-      it("changes the header notifications count", function() {
-        var badge1 = $(".notifications-link:eq(0) .badge");
-        var badge2 = $(".notifications-link:eq(1) .badge");
-        var count = parseInt(badge1.text(), 10);
-
-        this.view.updateView(this.guid, this.type, true);
-        expect(parseInt(badge1.text(), 10)).toBe(count + 1);
-
-        this.view.updateView(this.guid, this.type, false);
-        expect(parseInt(badge1.text(), 10)).toBe(count);
-
-        this.view.updateView(this.guid, this.type, true);
-        expect(parseInt(badge2.text(), 10)).toBe(count + 1);
-
-        this.view.updateView(this.guid, this.type, false);
-        expect(parseInt(badge2.text(), 10)).toBe(count);
-      });
-
       it("doesn't change the contacts count", function() {
         expect($("#aspect_nav .badge").length).toBeGreaterThan(0);
         $("#aspect_nav .badge").each(function(index, el) {
           $(el).text(index + 1337);
         });
 
-        this.view.updateView(this.guid, this.type, true);
+        this.view.updateView();
         $("#aspect_nav .badge").each(function(index, el) {
           expect(parseInt($(el).text(), 10)).toBe(index + 1337);
         });
 
-        this.view.updateView(this.guid, this.type, false);
+        this.collection.unreadCount++;
+
+        this.view.updateView();
         $("#aspect_nav .badge").each(function(index, el) {
           expect(parseInt($(el).text(), 10)).toBe(index + 1337);
         });
-- 
GitLab