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 @@