diff --git a/public/language/en_GB/topic.json b/public/language/en_GB/topic.json index 6299b8aac1..a64bd15ca7 100644 --- a/public/language/en_GB/topic.json +++ b/public/language/en_GB/topic.json @@ -38,6 +38,7 @@ "following_topic.message": "You will now be receiving notifications when somebody posts to this topic.", "not_following_topic.message": "You will no longer receive notifications from this topic.", + "ignoring_topic.message": "You will no longer see this topic in the unread topics list. You will be notified when you are mentioned or your post is up voted.", "login_to_subscribe": "Please register or log in in order to subscribe to this topic.", @@ -50,6 +51,12 @@ "watch.title": "Be notified of new replies in this topic", "unwatch.title": "Stop watching this topic", "share_this_post": "Share this Post", + "following": "Following", + "reading": "Reading", + "ignoring": "Ignoring", + "following.description": "Notify me of new replies", + "reading.description": "Show topic in unread list", + "ignoring.description": "Do not show topic in unread list", "thread_tools.title": "Topic Tools", "thread_tools.markAsUnreadForAll": "Mark Unread", @@ -125,7 +132,7 @@ "stale.warning": "The topic you are replying to is quite old. Would you like to create a new topic instead, and reference this one in your reply?", "stale.create": "Create a new topic", "stale.reply_anyway": "Reply to this topic anyway", - + "link_back": "Re: [%1](%2)\n\n", "spam": "Spam", diff --git a/public/less/generics.less b/public/less/generics.less index 265007af55..33af3bfa11 100644 --- a/public/less/generics.less +++ b/public/less/generics.less @@ -21,6 +21,12 @@ overflow-x: hidden; } +.topic-watch-dropdown { + .help-text { + margin-left: 20px; + } +} + .category-list { padding: 0; diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index 433c12e575..adc110e8a6 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -83,12 +83,18 @@ define('forum/topic/threadTools', [ deletePosts.init(); fork.init(); - components.get('topic').on('click', '[component="topic/follow"], [component="topic/unfollow"]', follow); - components.get('topic/follow').off('click').on('click', follow); - components.get('topic/unfollow').off('click').on('click', follow); + $('.topic').on('click', '[component="topic/following"]', function() { + setFollow('follow'); + }); + $('.topic').on('click', '[component="topic/reading"]', function() { + setFollow('unfollow'); + }); + $('.topic').on('click', '[component="topic/ignoring"]', function() { + setFollow('ignore'); + }); - function follow() { - socket.emit('topics.toggleFollow', tid, function(err, state) { + function setFollow(type) { + socket.emit('topics.changeWatching', {tid: tid, type: type}, function(err) { if (err) { return app.alert({ type: 'danger', @@ -98,12 +104,19 @@ define('forum/topic/threadTools', [ timeout: 5000 }); } - - setFollowState(state); + var message = ''; + if (type === 'follow') { + message = '[[topic:following_topic.message]]'; + } else if (type === 'unfollow') { + message = '[[topic:not_following_topic.message]]'; + } else if (type === 'ignore') { + message = '[[topic:ignoring_topic.message]]'; + } + setFollowState(type); app.alert({ alert_id: 'follow_thread', - message: state ? '[[topic:following_topic.message]]' : '[[topic:not_following_topic.message]]', + message: message, type: 'success', timeout: 5000 }); @@ -195,8 +208,17 @@ define('forum/topic/threadTools', [ }; function setFollowState(state) { - components.get('topic/follow').toggleClass('hidden', state); - components.get('topic/unfollow').toggleClass('hidden', !state); + var menu = components.get('topic/following/menu'); + menu.toggleClass('hidden', state !== 'follow'); + components.get('topic/following/check').toggleClass('fa-check', state === 'follow'); + + menu = components.get('topic/reading/menu'); + menu.toggleClass('hidden', state !== 'unfollow'); + components.get('topic/reading/check').toggleClass('fa-check', state === 'unfollow'); + + menu = components.get('topic/ignoring/menu'); + menu.toggleClass('hidden', state !== 'ignore' ); + components.get('topic/ignoring/check').toggleClass('fa-check', state === 'ignore'); } diff --git a/src/categories.js b/src/categories.js index cd2b18a787..a000eff251 100644 --- a/src/categories.js +++ b/src/categories.js @@ -305,4 +305,28 @@ var privileges = require('./privileges'); return tree; }; + Categories.getIgnorers = function(cid, start, stop, callback) { + db.getSortedSetRevRange('cid:' + cid + ':ignorers', start, stop, callback); + }; + + Categories.filterIgnoringUids = function(cid, uids, callback) { + async.waterfall([ + function (next){ + Categories.getIgnorers(cid, 0, -1, next); + }, + function (ignorerUids, next){ + if (!ignorerUids.length) { + return next(null, uids); + } + + var readingUids = uids.filter(function(uid) { + return ignorerUids.indexOf(uid.toString()) === -1; + }); + + next(null, readingUids); + } + ], callback); + }; + + }(exports)); diff --git a/src/categories/delete.js b/src/categories/delete.js index 8d99bb60d1..c8a383037e 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -38,6 +38,7 @@ module.exports = function(Categories) { 'cid:' + cid + ':tids:posts', 'cid:' + cid + ':pids', 'cid:' + cid + ':read_by_uid', + 'cid:' + cid + ':ignorers', 'cid:' + cid + ':children', 'category:' + cid ], next); diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index 69a4a26b54..148420dfb9 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -8,6 +8,7 @@ var websockets = require('./index'); var user = require('../user'); var posts = require('../posts'); var topics = require('../topics'); +var categories = require('../categories'); var privileges = require('../privileges'); var notifications = require('../notifications'); var plugins = require('../plugins'); @@ -27,6 +28,12 @@ SocketHelpers.notifyNew = function(uid, type, result) { function(uids, next) { privileges.topics.filterUids('read', result.posts[0].topic.tid, uids, next); }, + function(uids, next) { + topics.filterIgnoringUids(result.posts[0].topic.tid, uids, next); + }, + function(uids, next) { + categories.filterIgnoringUids(result.posts[0].topic.cid, uids, next); + }, function(uids, next) { plugins.fireHook('filter:sockets.sendNewPostToUids', {uidsTo: uids, uidFrom: uid, type: type}, next); } diff --git a/src/socket.io/topics.js b/src/socket.io/topics.js index 18cad2d980..451a1d0dce 100644 --- a/src/socket.io/topics.js +++ b/src/socket.io/topics.js @@ -72,8 +72,15 @@ SocketTopics.createTopicFromPosts = function(socket, data, callback) { topics.createTopicFromPosts(socket.uid, data.title, data.pids, data.fromTid, callback); }; -SocketTopics.toggleFollow = function(socket, tid, callback) { - followCommand(topics.toggleFollow, socket, tid, callback); +SocketTopics.changeWatching = function(socket, data, callback) { + if (!data.tid || !data.type) { + return callback(new Error('[[error:invalid-data]]')); + } + var commands = ['follow', 'unfollow', 'ignore']; + if (commands.indexOf(data.type) === -1) { + return callback(new Error('[[error:invalid-command]]')); + } + followCommand(topics[data.type], socket, data.tid, callback); }; SocketTopics.follow = function(socket, tid, callback) { diff --git a/src/topics.js b/src/topics.js index fbdd507d9a..8c09238e18 100644 --- a/src/topics.js +++ b/src/topics.js @@ -131,6 +131,9 @@ var social = require('./social'); hasRead: function(next) { Topics.hasReadTopics(tids, uid, next); }, + isIgnored: function(next) { + Topics.isIgnoring(tids, uid, next); + }, bookmarks: function(next) { Topics.getUserBookmarks(tids, uid, next); }, @@ -157,7 +160,8 @@ var social = require('./social'); topics[i].pinned = parseInt(topics[i].pinned, 10) === 1; topics[i].locked = parseInt(topics[i].locked, 10) === 1; topics[i].deleted = parseInt(topics[i].deleted, 10) === 1; - topics[i].unread = !results.hasRead[i]; + topics[i].ignored = results.isIgnored[i]; + topics[i].unread = !results.hasRead[i] && !results.isIgnored[i]; topics[i].bookmark = results.bookmarks[i]; topics[i].unreplied = !topics[i].teaser; } @@ -184,6 +188,7 @@ var social = require('./social'); threadTools: async.apply(plugins.fireHook, 'filter:topic.thread_tools', {topic: topicData, uid: uid, tools: []}), tags: async.apply(Topics.getTopicTagsObjects, topicData.tid), isFollowing: async.apply(Topics.isFollowing, [topicData.tid], uid), + isIgnoring: async.apply(Topics.isIgnoring, [topicData.tid], uid), bookmark: async.apply(Topics.getUserBookmark, topicData.tid, uid), postSharing: async.apply(social.getActivePostSharing) }, next); @@ -194,6 +199,8 @@ var social = require('./social'); topicData.thread_tools = results.threadTools.tools; topicData.tags = results.tags; topicData.isFollowing = results.isFollowing[0]; + topicData.isReading = !results.isFollowing[0] && !results.isIgnoring[0]; + topicData.isIgnoring = results.isIgnoring[0]; topicData.bookmark = results.bookmark; topicData.postSharing = results.postSharing; diff --git a/src/topics/delete.js b/src/topics/delete.js index f979d3083a..a960ff0421 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -113,6 +113,7 @@ module.exports = function(Topics) { function(next) { db.deleteAll([ 'tid:' + tid + ':followers', + 'tid:' + tid + ':ignorers', 'tid:' + tid + ':posts', 'tid:' + tid + ':posts:votes', 'tid:' + tid + ':bookmarks', diff --git a/src/topics/follow.js b/src/topics/follow.js index 209151d8e5..7b4054ac03 100644 --- a/src/topics/follow.js +++ b/src/topics/follow.js @@ -56,12 +56,12 @@ module.exports = function(Topics) { if (!exists) { return next(new Error('[[error:no-topic]]')); } - db.setAdd('tid:' + tid + ':followers', uid, next); + follow(tid, uid, next); }, - async.apply(plugins.fireHook, 'action:topic.follow', { uid: uid, tid: tid }), - function(next) { - db.sortedSetAdd('uid:' + uid + ':followed_tids', Date.now(), tid, next); - } + function (next) { + unignore(tid, uid, next); + }, + async.apply(plugins.fireHook, 'action:topic.follow', {uid: uid, tid: tid}) ], callback); }; @@ -75,14 +75,77 @@ module.exports = function(Topics) { if (!exists) { return next(new Error('[[error:no-topic]]')); } + unfollow(tid, uid, next); + }, + function (next) { + unignore(tid, uid, next); + }, + async.apply(plugins.fireHook, 'action:topic.unfollow', {uid: uid, tid: tid}), + ], callback); + }; + + Topics.ignore = function(tid, uid, callback) { + callback = callback || function() {}; + async.waterfall([ + function (next) { + Topics.exists(tid, next); + }, + function (exists, next) { + if (!exists) { + return next(new Error('[[error:no-topic]]')); + } + ignore(tid, uid, next); + }, + function (next) { + unfollow(tid, uid, next); + }, + async.apply(plugins.fireHook, 'action:topic.ignore', {uid: uid, tid: tid}) + ], callback); + }; + + function follow(tid, uid, callback) { + async.waterfall([ + function (next) { + db.setAdd('tid:' + tid + ':followers', uid, next); + }, + function (next) { + db.sortedSetAdd('uid:' + uid + ':followed_tids', Date.now(), tid, next); + } + ], callback); + } + + function unfollow(tid, uid, callback) { + async.waterfall([ + function (next) { db.setRemove('tid:' + tid + ':followers', uid, next); }, - async.apply(plugins.fireHook, 'action:topic.unfollow', { uid: uid, tid: tid }), - function(next) { + function (next) { db.sortedSetRemove('uid:' + uid + ':followed_tids', tid, next); } ], callback); - }; + } + + function ignore(tid, uid, callback) { + async.waterfall([ + function (next) { + db.setAdd('tid:' + tid + ':ignorers', uid, next); + }, + function(next) { + db.sortedSetAdd('uid:' + uid + ':ignored_tids', Date.now(), tid, next); + } + ], callback); + } + + function unignore(tid, uid, callback) { + async.waterfall([ + function (next) { + db.setRemove('tid:' + tid + ':ignorers', uid, next); + }, + function(next) { + db.sortedSetRemove('uid:' + uid + ':ignored_tids', tid, next); + } + ], callback); + } Topics.isFollowing = function(tids, uid, callback) { if (!Array.isArray(tids)) { @@ -97,10 +160,46 @@ module.exports = function(Topics) { db.isMemberOfSets(keys, uid, callback); }; + Topics.isIgnoring = function(tids, uid, callback) { + if (!Array.isArray(tids)) { + return callback(); + } + if (!parseInt(uid, 10)) { + return callback(null, tids.map(function() { return false; })); + } + var keys = tids.map(function(tid) { + return 'tid:' + tid + ':ignorers'; + }); + db.isMemberOfSets(keys, uid, callback); + }; + Topics.getFollowers = function(tid, callback) { db.getSetMembers('tid:' + tid + ':followers', callback); }; + Topics.getIgnorers = function(tid, callback) { + db.getSetMembers('tid:' + tid + ':ignorers', callback); + }; + + Topics.filterIgnoringUids = function(tid, uids, callback){ + async.waterfall([ + function (next){ + Topics.getIgnorers(tid, next); + }, + function (ignorerUids, next){ + if (!ignorerUids.length) { + return next(null, uids); + } + + var readingUids = uids.filter(function(uid) { + return ignorerUids.indexOf(uid.toString()) === -1; + }); + + next(null, readingUids); + } + ], callback); + }; + Topics.notifyFollowers = function(postData, exceptUid, callback) { callback = callback || function() {}; var followers; diff --git a/src/topics/unread.js b/src/topics/unread.js index 9dcfd8bdd9..c62ae5ac3a 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -87,6 +87,9 @@ module.exports = function(Topics) { } user.getIgnoredCategories(uid, next); }, + ignoredTids: function(next) { + user.getIgnoredTids(uid, 0, -1, next); + }, recentTids: function(next) { db.getSortedSetRevRangeByScoreWithScores('topics:recent', 0, -1, '+inf', cutoff, next); }, @@ -116,6 +119,9 @@ module.exports = function(Topics) { }); var tids = results.recentTids.filter(function(recentTopic) { + if (results.ignoredTids.indexOf(recentTopic.value.toString()) !== -1) { + return false; + } switch (filter) { case 'new': return !userRead[recentTopic.value]; diff --git a/src/user.js b/src/user.js index 19c60fc1fd..c7fb18e4fc 100644 --- a/src/user.js +++ b/src/user.js @@ -1,12 +1,12 @@ 'use strict'; -var async = require('async'), +var async = require('async'); - plugins = require('./plugins'), - db = require('./database'), - topics = require('./topics'), - privileges = require('./privileges'), - utils = require('../public/src/utils'); +var plugins = require('./plugins'); +var db = require('./database'); +var topics = require('./topics'); +var privileges = require('./privileges'); +var utils = require('../public/src/utils'); (function(User) { @@ -19,6 +19,7 @@ var async = require('async'), require('./user/auth')(User); require('./user/create')(User); require('./user/posts')(User); + require('./user/topics')(User); require('./user/categories')(User); require('./user/follow')(User); require('./user/profile')(User); diff --git a/src/user/categories.js b/src/user/categories.js index 612b5ec14e..7a87f5e441 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -45,6 +45,9 @@ module.exports = function(User) { return next(new Error('[[error:no-category]]')); } db.sortedSetAdd('uid:' + uid + ':ignored:cids', Date.now(), cid, next); + }, + function (next) { + db.sortedSetAdd('cid:' + cid + ':ignorers', Date.now(), uid, next); } ], callback); }; @@ -63,6 +66,9 @@ module.exports = function(User) { return next(new Error('[[error:no-category]]')); } db.sortedSetRemove('uid:' + uid + ':ignored:cids', cid, next); + }, + function (next) { + db.sortedSetRemove('cid:' + cid + ':ignorers', uid, next); } ], callback); }; diff --git a/src/user/delete.js b/src/user/delete.js index 99119c7ffb..6c5a92e225 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -102,8 +102,12 @@ module.exports = function(User) { }, function(next) { var keys = [ - 'uid:' + uid + ':notifications:read', 'uid:' + uid + ':notifications:unread', - 'uid:' + uid + ':favourites', 'uid:' + uid + ':followed_tids', 'user:' + uid + ':settings', + 'uid:' + uid + ':notifications:read', + 'uid:' + uid + ':notifications:unread', + 'uid:' + uid + ':favourites', + 'uid:' + uid + ':followed_tids', + 'uid:' + uid + ':ignored_tids', + 'user:' + uid + ':settings', 'uid:' + uid + ':topics', 'uid:' + uid + ':posts', 'uid:' + uid + ':chats', 'uid:' + uid + ':chats:unread', 'uid:' + uid + ':chat:rooms', 'uid:' + uid + ':chat:rooms:unread', diff --git a/src/user/posts.js b/src/user/posts.js index 78a2db0923..c5d8cfba1a 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -1,9 +1,9 @@ 'use strict'; -var async = require('async'), - db = require('../database'), - meta = require('../meta'), - privileges = require('../privileges'); +var async = require('async'); +var db = require('../database'); +var meta = require('../meta'); +var privileges = require('../privileges'); module.exports = function(User) { @@ -83,13 +83,6 @@ module.exports = function(User) { db.sortedSetAdd('uid:' + uid + ':posts', timestamp, pid, callback); }; - User.addTopicIdToUser = function(uid, tid, timestamp, callback) { - async.parallel([ - async.apply(db.sortedSetAdd, 'uid:' + uid + ':topics', timestamp, tid), - async.apply(User.incrementUserFieldBy, uid, 'topiccount', 1) - ], callback); - }; - User.incrementUserPostCountBy = function(uid, value, callback) { callback = callback || function() {}; User.incrementUserFieldBy(uid, 'postcount', value, function(err, newpostcount) { diff --git a/src/user/topics.js b/src/user/topics.js new file mode 100644 index 0000000000..2df535a1ce --- /dev/null +++ b/src/user/topics.js @@ -0,0 +1,19 @@ +'use strict'; + +var async = require('async'); +var db = require('../database'); + +module.exports = function(User) { + + User.getIgnoredTids = function(uid, start, stop, callback) { + db.getSortedSetRevRange('uid:' + uid + ':ignored_tids', start, stop, callback); + }; + + User.addTopicIdToUser = function(uid, tid, timestamp, callback) { + async.parallel([ + async.apply(db.sortedSetAdd, 'uid:' + uid + ':topics', timestamp, tid), + async.apply(User.incrementUserFieldBy, uid, 'topiccount', 1) + ], callback); + }; + +}; \ No newline at end of file diff --git a/tests/topics.js b/tests/topics.js index 60c71960cd..ebf97f17b6 100644 --- a/tests/topics.js +++ b/tests/topics.js @@ -57,28 +57,28 @@ describe('Topic\'s', function() { }); it('should fail to create new topic with invalid user id', function(done) { - topics.post({uid: null, title: topic.title, content: topic.content, cid: topic.categoryId}, function(err, result) { + topics.post({uid: null, title: topic.title, content: topic.content, cid: topic.categoryId}, function(err) { assert.equal(err.message, '[[error:no-privileges]]'); done(); }); }); it('should fail to create new topic with empty title', function(done) { - topics.post({uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId}, function(err, result) { + topics.post({uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId}, function(err) { assert.ok(err); done(); }); }); it('should fail to create new topic with empty content', function(done) { - topics.post({uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId}, function(err, result) { + topics.post({uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId}, function(err) { assert.ok(err); done(); }); }); it('should fail to create new topic with non-existant category id', function(done) { - topics.post({uid: topic.userId, title: topic.title, content: topic.content, cid: 99}, function(err, result) { + topics.post({uid: topic.userId, title: topic.title, content: topic.content, cid: 99}, function(err) { assert.equal(err.message, '[[error:no-category]]', 'received no error'); done(); }); @@ -107,21 +107,21 @@ describe('Topic\'s', function() { }); it('should fail to create new reply with invalid user id', function(done) { - topics.reply({uid: null, content: 'test post', tid: newTopic.tid}, function(err, result) { + topics.reply({uid: null, content: 'test post', tid: newTopic.tid}, function(err) { assert.equal(err.message, '[[error:no-privileges]]'); done(); }); }); it('should fail to create new reply with empty content', function(done) { - topics.reply({uid: topic.userId, content: '', tid: newTopic.tid}, function(err, result) { + topics.reply({uid: topic.userId, content: '', tid: newTopic.tid}, function(err) { assert.ok(err); done(); }); }); it('should fail to create new reply with invalid topic id', function(done) { - topics.reply({uid: null, content: 'test post', tid: 99}, function(err, result) { + topics.reply({uid: null, content: 'test post', tid: 99}, function(err) { assert.equal(err.message, '[[error:no-topic]]'); done(); }); @@ -189,10 +189,113 @@ describe('Topic\'s', function() { }); }); + describe('.ignore', function(){ + var newTid; + var uid; + var newTopic; + before(function(done){ + uid = topic.userId; + async.waterfall([ + function(done){ + topics.post({uid: topic.userId, title: 'Topic to be ignored', content: 'Just ignore me, please!', cid: topic.categoryId}, function(err, result) { + newTopic = result.topicData; + newTid = newTopic.tid; + done(); + }); + }, + function(done){ + topics.markUnread( newTid, uid, done ); + } + ],done); + }); + + it('should not appear in the unread list', function(done){ + async.waterfall([ + function(done){ + topics.ignore( newTid, uid, done ); + }, + function(done){ + topics.getUnreadTopics(0, uid, 0, -1, done ); + }, + function(results, done){ + var topics = results.topics; + var tids = topics.map( function(topic){ return topic.tid; } ); + assert.equal(tids.indexOf(newTid), -1, 'The topic appeared in the unread list.'); + done(); + } + ], done); + }); + + it('should not appear as unread in the recent list', function(done){ + async.waterfall([ + function(done){ + topics.ignore( newTid, uid, done ); + }, + function(done){ + topics.getLatestTopics( uid, 0, -1, 'year', done ); + }, + function(results, done){ + var topics = results.topics; + var topic; + var i; + for(i = 0; i < topics.length; ++i){ + if( topics[i].tid == newTid ){ + assert.equal(false, topics[i].unread, 'ignored topic was marked as unread in recent list'); + return done(); + } + } + assert.ok(topic, 'topic didn\'t appear in the recent list'); + done(); + } + ], done); + }); + + it('should appear as unread again when marked as reading', function(done){ + async.waterfall([ + function(done){ + topics.ignore( newTid, uid, done ); + }, + function(done){ + topics.follow( newTid, uid, done ); + }, + function(done){ + topics.getUnreadTopics(0, uid, 0, -1, done ); + }, + function(results, done){ + var topics = results.topics; + var tids = topics.map( function(topic){ return topic.tid; } ); + assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); + done(); + } + ], done); + }); + + it('should appear as unread again when marked as following', function(done){ + async.waterfall([ + function(done){ + topics.ignore( newTid, uid, done ); + }, + function(done){ + topics.follow( newTid, uid, done ); + }, + function(done){ + topics.getUnreadTopics(0, uid, 0, -1, done ); + }, + function(results, done){ + var topics = results.topics; + var tids = topics.map( function(topic){ return topic.tid; } ); + assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); + done(); + } + ], done); + }); + }); + + describe('.fork', function(){ var newTopic; - var replies = new Array(); + var replies = []; var topicPids; var originalBookmark = 5; function postReply( next ){