diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 0838ca17eb..e04482c633 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -12,6 +12,17 @@ "new_notification": "New Notification", "you_have_unread_notifications": "You have unread notifications.", + "all": "All", + "topics": "Topics", + "replies": "Replies", + "chat": "Chats", + "follows": "Follows", + "upvote": "Upvotes", + "new-flags": "New Flags", + "my-flags": "Flags assigned to me", + "bans": "Bans", + + "new_message_from": "New message from %1", "upvoted_your_post_in": "%1 has upvoted your post in %2.", "upvoted_your_post_in_dual": "%1 and %2 have upvoted your post in %3.", diff --git a/public/src/client/notifications.js b/public/src/client/notifications.js index fb61a53063..04213499e3 100644 --- a/public/src/client/notifications.js +++ b/public/src/client/notifications.js @@ -1,7 +1,7 @@ 'use strict'; -define('forum/notifications', ['components', 'notifications', 'forum/infinitescroll'], function (components, notifs, infinitescroll) { +define('forum/notifications', ['components', 'notifications'], function (components, notifs) { var Notifications = {}; Notifications.init = function () { @@ -35,32 +35,7 @@ define('forum/notifications', ['components', 'notifications', 'forum/infinitescr notifs.updateNotifCount(0); }); }); - - infinitescroll.init(loadMoreNotifications); }; - function loadMoreNotifications(direction) { - if (direction < 0) { - return; - } - var notifList = $('.notifications-list'); - infinitescroll.loadMore('notifications.loadMore', { - after: notifList.attr('data-nextstart'), - }, function (data, done) { - if (!data) { - return done(); - } - notifList.attr('data-nextstart', data.nextStart); - if (!data.notifications || !data.notifications.length) { - return done(); - } - app.parseAndTranslate('notifications', 'notifications', { notifications: data.notifications }, function (html) { - notifList.append(html); - html.find('.timeago').timeago(); - done(); - }); - }); - } - return Notifications; }); diff --git a/src/controllers/accounts/notifications.js b/src/controllers/accounts/notifications.js index 6184ec056d..9e1839b42b 100644 --- a/src/controllers/accounts/notifications.js +++ b/src/controllers/accounts/notifications.js @@ -1,23 +1,91 @@ 'use strict'; +var async = require('async'); + var user = require('../../user'); var helpers = require('../helpers'); +var plugins = require('../../plugins'); +var pagination = require('../../pagination'); -var notificationsController = {}; +var notificationsController = module.exports; notificationsController.get = function (req, res, next) { - user.notifications.getAll(req.uid, 0, 39, function (err, notifications) { - if (err) { - return next(err); - } - res.render('notifications', { - notifications: notifications, - nextStart: 40, - title: '[[pages:notifications]]', - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:notifications]]' }]), - }); - }); + var regularFilters = [ + { name: '[[notifications:all]]', filter: '' }, + { name: '[[notifications:topics]]', filter: 'new-topic' }, + { name: '[[notifications:replies]]', filter: 'new-reply' }, + { name: '[[notifications:chat]]', filter: 'new-chat' }, + { name: '[[notifications:follows]]', filter: 'follow' }, + { name: '[[notifications:upvote]]', filter: 'upvote' }, + ]; + + var moderatorFilters = [ + { name: '[[notifications:new-flags]]', filter: 'new-post-flag' }, + { name: '[[notifications:my-flags]]', filter: 'my-flags' }, + { name: '[[notifications:bans]]', filter: 'ban' }, + ]; + + var filter = req.query.filter || ''; + var page = Math.max(1, req.query.page || 1); + var itemsPerPage = 20; + var start = (page - 1) * itemsPerPage; + var stop = start + itemsPerPage - 1; + var selectedFilter; + var pageCount = 1; + var allFilters = []; + + async.waterfall([ + function (next) { + async.parallel({ + filters: function (next) { + plugins.fireHook('filter:notifications.addFilters', { + regularFilters: regularFilters, + moderatorFilters: moderatorFilters, + uid: req.uid, + }, next); + }, + isPrivileged: function (next) { + user.isPrivileged(req.uid, next); + }, + }, next); + }, + function (data, _next) { + allFilters = data.filters.regularFilters; + + if (data.isPrivileged) { + allFilters = allFilters.concat([ + { separator: true }, + ]).concat(data.filters.moderatorFilters); + } + + selectedFilter = allFilters.find(function (filterData) { + filterData.selected = filterData.filter === filter; + return filterData.selected; + }); + + if (!selectedFilter) { + return next(); + } + + user.notifications.getAll(req.uid, selectedFilter.filter, _next); + }, + function (nids, next) { + pageCount = nids.length / itemsPerPage; + nids = nids.slice(start, stop + 1); + + user.notifications.getNotifications(nids, req.uid, next); + }, + function (notifications) { + res.render('notifications', { + notifications: notifications, + pagination: pagination.create(page, pageCount, req.query), + filters: allFilters, + regularFilters: regularFilters, + moderatorFilters: moderatorFilters, + selectedFilter: selectedFilter, + title: '[[pages:notifications]]', + breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:notifications]]' }]), + }); + }, + ], next); }; - - -module.exports = notificationsController; diff --git a/src/flags.js b/src/flags.js index 33175d933f..185ef6f6ad 100644 --- a/src/flags.js +++ b/src/flags.js @@ -60,9 +60,6 @@ Flags.list = function (filters, uid, callback) { value.forEach(function (x) { orSets.push(setPrefix + x); }); - } else { - // Empty array, do nothing - } }; @@ -604,6 +601,7 @@ Flags.notify = function (flagObj, uid, callback) { var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); notifications.create({ + type: 'new-post-flag', bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]', bodyLong: flagObj.description, pid: flagObj.targetId, diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index eb7a1a1a74..a3f168f49d 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -68,6 +68,7 @@ module.exports = function (Messaging) { } notifications.create({ + type: 'new-chat', bodyShort: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', bodyLong: messageObj.content, nid: 'chat_' + fromuid + '_' + roomId, diff --git a/src/notifications.js b/src/notifications.js index 752915d636..1e476f619e 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -28,64 +28,66 @@ var utils = require('../public/src/utils'); }; Notifications.getMultiple = function (nids, callback) { + if (!nids.length) { + return setImmediate(callback, null, []); + } var keys = nids.map(function (nid) { return 'notifications:' + nid; }); - db.getObjects(keys, function (err, notifications) { - if (err) { - return callback(err); - } + var notifications; - notifications = notifications.filter(Boolean); - if (!notifications.length) { - return callback(null, []); - } + async.waterfall([ + function (next) { + db.getObjects(keys, next); + }, + function (_notifications, next) { + notifications = _notifications; + var userKeys = notifications.map(function (notification) { + return notification && notification.from; + }); - var userKeys = notifications.map(function (notification) { - return notification.from; - }); - - User.getUsersFields(userKeys, ['username', 'userslug', 'picture'], function (err, usersData) { - if (err) { - return callback(err); - } + User.getUsersFields(userKeys, ['username', 'userslug', 'picture'], next); + }, + function (usersData, next) { notifications.forEach(function (notification, index) { - notification.datetimeISO = utils.toISOString(notification.datetime); + if (notification) { + notification.datetimeISO = utils.toISOString(notification.datetime); - if (notification.bodyLong) { - notification.bodyLong = S(notification.bodyLong).escapeHTML().s; - } - - notification.user = usersData[index]; - if (notification.user) { - notification.image = notification.user.picture || null; - if (notification.user.username === '[[global:guest]]') { - notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2'); + if (notification.bodyLong) { + notification.bodyLong = S(notification.bodyLong).escapeHTML().s; + } + + notification.user = usersData[index]; + if (notification.user) { + notification.image = notification.user.picture || null; + if (notification.user.username === '[[global:guest]]') { + notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2'); + } + } else if (notification.image === 'brand:logo' || !notification.image) { + notification.image = meta.config['brand:logo'] || nconf.get('relative_path') + '/logo.png'; } - } else if (notification.image === 'brand:logo' || !notification.image) { - notification.image = meta.config['brand:logo'] || nconf.get('relative_path') + '/logo.png'; } }); - callback(null, notifications); - }); - }); + next(null, notifications); + }, + ], callback); }; Notifications.filterExists = function (nids, callback) { - // Removes nids that have been pruned - db.isSortedSetMembers('notifications', nids, function (err, exists) { - if (err) { - return callback(err); - } + async.waterfall([ + function (next) { + db.isSortedSetMembers('notifications', nids, next); + }, + function (exists, next) { + nids = nids.filter(function (notifId, idx) { + return exists[idx]; + }); - nids = nids.filter(function (notifId, idx) { - return exists[idx]; - }); - - callback(null, nids); - }); + next(null, nids); + }, + ], callback); }; Notifications.findRelated = function (mergeIds, set, callback) { diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index ce2ee7b30c..241937d5fb 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -106,6 +106,7 @@ SocketHelpers.sendNotificationToPostOwner = function (pid, fromuid, command, not var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); notifications.create({ + type: command, bodyShort: '[[' + notification + ', ' + results.username + ', ' + titleEscaped + ']]', bodyLong: results.postObj.content, pid: pid, diff --git a/src/socket.io/notifications.js b/src/socket.io/notifications.js index 66e5135ed6..8a4568810e 100644 --- a/src/socket.io/notifications.js +++ b/src/socket.io/notifications.js @@ -1,11 +1,9 @@ 'use strict'; -var async = require('async'); var user = require('../user'); var notifications = require('../notifications'); -var utils = require('../../public/src/utils'); -var SocketNotifs = {}; +var SocketNotifs = module.exports; SocketNotifs.get = function (socket, data, callback) { if (data && Array.isArray(data.nids) && socket.uid) { @@ -15,25 +13,6 @@ SocketNotifs.get = function (socket, data, callback) { } }; -SocketNotifs.loadMore = function (socket, data, callback) { - if (!data || !utils.isNumber(data.after) || parseInt(data.after, 10) < 0) { - return callback(new Error('[[error:invalid-data]]')); - } - if (!socket.uid) { - return callback(new Error('[[error:no-privileges]]')); - } - var start = parseInt(data.after, 10); - var stop = start + 20; - async.waterfall([ - function (next) { - user.notifications.getAll(socket.uid, start, stop, next); - }, - function (notifications, next) { - next(null, { notifications: notifications, nextStart: stop }); - }, - ], callback); -}; - SocketNotifs.getCount = function (socket, data, callback) { user.notifications.getUnreadCount(socket.uid, callback); }; @@ -57,5 +36,3 @@ SocketNotifs.markUnread = function (socket, nid, callback) { SocketNotifs.markAllRead = function (socket, data, callback) { notifications.markAllRead(socket.uid, callback); }; - -module.exports = SocketNotifs; diff --git a/src/socket.io/user.js b/src/socket.io/user.js index 78f696a19b..c161d13f6f 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -171,6 +171,7 @@ SocketUser.follow = function (socket, data, callback) { function (_userData, next) { userData = _userData; notifications.create({ + type: 'follow', bodyShort: '[[notifications:user_started_following_you, ' + userData.username + ']]', nid: 'follow:' + data.uid + ':uid:' + socket.uid, from: socket.uid, diff --git a/src/topics/follow.js b/src/topics/follow.js index a3b1041b13..5b5f368f6a 100644 --- a/src/topics/follow.js +++ b/src/topics/follow.js @@ -224,6 +224,7 @@ module.exports = function (Topics) { postData.content = posts.relativeToAbsolute(postData.content); notifications.create({ + type: 'new-reply', bodyShort: '[[notifications:user_posted_to, ' + postData.user.username + ', ' + titleEscaped + ']]', bodyLong: postData.content, pid: postData.pid, diff --git a/src/user/notifications.js b/src/user/notifications.js index f830092b07..eebb31de7b 100644 --- a/src/user/notifications.js +++ b/src/user/notifications.js @@ -32,20 +32,80 @@ var privileges = require('../privileges'); }); }; - UserNotifications.getAll = function (uid, start, stop, callback) { - getNotifications(uid, start, stop, function (err, notifs) { - if (err) { - return callback(err); - } - notifs = notifs.unread.concat(notifs.read); - notifs = notifs.filter(Boolean).sort(function (a, b) { - return b.datetime - a.datetime; - }); + function filterNotifications(nids, filter, callback) { + if (!filter) { + return setImmediate(callback, null, nids); + } + async.waterfall([ + function (next) { + var keys = nids.map(function (nid) { + return 'notifications:' + nid; + }); + db.getObjectsFields(keys, ['nid', 'type'], next); + }, + function (notifications, next) { + nids = notifications.filter(function (notification) { + return notification && notification.nid && notification.type === filter; + }).map(function (notification) { + return notification.nid; + }); + next(null, nids); + }, + ], callback); + } - callback(null, notifs); - }); + UserNotifications.getAll = function (uid, filter, callback) { + var nids; + async.waterfall([ + function (next) { + async.parallel({ + unread: function (next) { + db.getSortedSetRevRange('uid:' + uid + ':notifications:unread', 0, -1, next); + }, + read: function (next) { + db.getSortedSetRevRange('uid:' + uid + ':notifications:read', 0, -1, next); + }, + }, next); + }, + function (results, next) { + nids = results.unread.concat(results.read); + db.isSortedSetMembers('notifications', nids, next); + }, + function (exists, next) { + var deleteNids = []; + + nids = nids.filter(function (nid, index) { + if (!nid || !exists[index]) { + deleteNids.push(nid); + } + return nid && exists[index]; + }); + + deleteUserNids(deleteNids, uid, next); + }, + function (next) { + filterNotifications(nids, filter, next); + }, + ], callback); }; + function deleteUserNids(nids, uid, callback) { + callback = callback || function () {}; + if (!nids.length) { + return setImmediate(callback); + } + async.parallel([ + function (next) { + db.sortedSetRemove('uid:' + uid + ':notifications:read', nids, next); + }, + function (next) { + db.sortedSetRemove('uid:' + uid + ':notifications:unread', nids, next); + }, + ], function (err) { + callback(err); + }); + } + function getNotifications(uid, start, stop, callback) { async.parallel({ unread: function (next) { @@ -58,52 +118,54 @@ var privileges = require('../privileges'); } function getNotificationsFromSet(set, read, uid, start, stop, callback) { - var setNids; - async.waterfall([ - async.apply(db.getSortedSetRevRange, set, start, stop), + function (next) { + db.getSortedSetRevRange(set, start, stop, next); + }, function (nids, next) { if (!Array.isArray(nids) || !nids.length) { return callback(null, []); } - setNids = nids; UserNotifications.getNotifications(nids, uid, next); }, - function (notifs, next) { - var deletedNids = []; - - notifs.forEach(function (notification, index) { - if (!notification) { - winston.verbose('[notifications.get] nid ' + setNids[index] + ' not found. Removing.'); - deletedNids.push(setNids[index]); - } else { - notification.read = read; - notification.readClass = !notification.read ? 'unread' : ''; - } - }); - - if (deletedNids.length) { - db.sortedSetRemove(set, deletedNids); - } - - notifications.merge(notifs, next); - }, ], callback); } UserNotifications.getNotifications = function (nids, uid, callback) { - notifications.getMultiple(nids, function (err, notifications) { - if (err) { - return callback(err); - } - notifications = notifications.filter(function (notification) { - return notification && notification.path; - }); - callback(null, notifications); - }); - }; + var notificationData = []; + async.waterfall([ + function (next) { + async.parallel({ + notifications: function (next) { + notifications.getMultiple(nids, next); + }, + hasRead: function (next) { + db.isSortedSetMember('uid:' + uid + ':notifications:read', nids, next); + }, + }, next); + }, + function (results, next) { + var deletedNids = []; + notificationData = results.notifications.filter(function (notification, index) { + if (!notification || !notification.nid) { + deletedNids.push(nids[index]); + } + if (notification) { + notification.read = results.hasRead[index]; + notification.readClass = !notification.read ? 'unread' : ''; + } + return notification && notification.path; + }); + + deleteUserNids(deletedNids, uid, next); + }, + function (next) { + notifications.merge(notificationData, next); + }, + ], callback); + }; UserNotifications.getDailyUnread = function (uid, callback) { var yesterday = Date.now() - (1000 * 60 * 60 * 24); // Approximate, can be more or less depending on time changes, makes no difference really. @@ -222,6 +284,7 @@ var privileges = require('../privileges'); } notifications.create({ + type: 'new-topic', bodyShort: '[[notifications:user_posted_topic, ' + postData.user.username + ', ' + title + ']]', bodyLong: postData.content, pid: postData.pid, diff --git a/test/notifications.js b/test/notifications.js index f21d46cf32..d65b4c0bb5 100644 --- a/test/notifications.js +++ b/test/notifications.js @@ -232,31 +232,6 @@ describe('Notifications', function () { }); }); - it('should error with invalid data', function (done) { - socketNotifications.loadMore({ uid: uid }, { after: 'test' }, function (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error if not logged in', function (done) { - socketNotifications.loadMore({ uid: 0 }, { after: 10 }, function (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should load more notifications', function (done) { - socketNotifications.loadMore({ uid: uid }, { after: 0 }, function (err, data) { - assert.ifError(err); - assert.equal(data.notifications[0].bodyShort, 'bodyShort'); - assert.equal(data.notifications[0].nid, 'notification_id'); - assert.equal(data.notifications[0].path, '/notification/path'); - done(); - }); - }); - - it('should error if not logged in', function (done) { socketNotifications.deleteAll({ uid: 0 }, null, function (err) { assert.equal(err.message, '[[error:no-privileges]]');