Unverified Commit af331bfb authored by Augier's avatar Augier Committed by Steffen van Bergerem

Add collection to app.views.NotificationDropdown and app.views.Notifications

closes #6952
parent 9b72527f
......@@ -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
......
......@@ -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();
......
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");
}
});
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); }
});
}
}
});
......@@ -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() {
......
......@@ -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
......@@ -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() {
......
// @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
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")
});
}
}
};
......@@ -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"}}
......
......@@ -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
......@@ -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")
......
......@@ -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"
......
......@@ -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
......@@ -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
......
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":