From af331bfb30bc094432fe2d8b4cab4aa00b9fd23a Mon Sep 17 00:00:00 2001
From: Augier
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 | 139 +++++-----
.../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, 875 insertions(+), 304 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 1bbd260aa..ac93a6c50 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 1046bd66a..d300be7e6 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 000000000..0bb4a2f97
--- /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 000000000..b2e342116
--- /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":
+ * },
+ * "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":
,
+ * }
+ */
+ 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 1884bb880..ed8ec9d9a 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 5b682c3b3..496cd83d3 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 a44556c9a..a72f1e8f1 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 08400742e..3ae156348 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
- });
- },
-
- clickSuccess: function(data) {
- var guid = data.guid;
- var type = $(".stream-element[data-guid=" + guid + "]").data("type");
- this.updateView(guid, type, data.unread);
- },
-
- markAllRead: function(evt){
- if(evt) { evt.preventDefault(); }
- var self = this;
- this.getAllUnread().each(function(i, el){
- self.setRead($(el).data("guid"));
- });
- },
-
- 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";
-
- if(unread){ note.removeClass("read").addClass("unread"); }
- else { note.removeClass("unread").addClass("read"); }
-
- $(".unread-toggle .entypo-eye", note)
- .tooltip("destroy")
- .removeAttr("data-original-title")
- .attr("title",Diaspora.I18n.t(translationKey))
- .tooltip();
-
- [allNotes, typeNotes, headerBadge].forEach(function(element){
- element.text(function(i, text){
- return parseInt(text) + change;
- });
- });
-
- [allNotes, typeNotes].forEach(function(badge) {
- if(badge.text() > 0) {
- badge.removeClass("hidden");
- }
- else {
- badge.addClass("hidden");
- }
- });
-
- if(headerBadge.text() > 0){
- headerBadge.removeClass("hidden");
- markAllReadLink.removeClass("disabled");
+ if (unread) {
+ this.collection.setRead(guid);
+ } else {
+ this.collection.setUnread(guid);
}
- else{
- headerBadge.addClass("hidden");
+ },
+
+ markAllRead: function() {
+ this.collection.setAllRead();
+ },
+
+ 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() {
+ var notificationsContainer = $("#notifications_container");
+
+ // 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));
+
+ this.updateBadge(notificationsContainer.find("a[data-type=all] .badge"), this.collection.unreadCount);
+
+ // update notification count in the header
+ this.updateBadge($(".notifications-link .badge"), this.collection.unreadCount);
+
+ var markAllReadLink = $("a#mark-all-read-link");
+
+ if (this.collection.unreadCount > 0) {
+ markAllReadLink.removeClass("disabled");
+ } 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 000000000..e8c8b7d8f
--- /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 a895d99c9..ab8a27a95 100644
--- a/app/assets/templates/header_tpl.jst.hbs
+++ b/app/assets/templates/header_tpl.jst.hbs
@@ -53,7 +53,7 @@