diff --git a/install/data/defaults.json b/install/data/defaults.json index c1c01f4a5e..717d4eae52 100644 --- a/install/data/defaults.json +++ b/install/data/defaults.json @@ -116,5 +116,6 @@ "eventLoopLagThreshold": 100, "eventLoopInterval": 500, "onlineCutoff": 30, - "timeagoCutoff": 30 + "timeagoCutoff": 30, + "categoryWatchState": "watching" } \ No newline at end of file diff --git a/public/language/en-GB/admin/settings/user.json b/public/language/en-GB/admin/settings/user.json index 0268336e5a..a504f5ef8d 100644 --- a/public/language/en-GB/admin/settings/user.json +++ b/public/language/en-GB/admin/settings/user.json @@ -70,5 +70,9 @@ "email-post-notif": "Send an email when replies are made to topics I am subscribed to", "follow-created-topics": "Follow topics you create", "follow-replied-topics": "Follow topics that you reply to", - "default-notification-settings": "Default notification settings" + "default-notification-settings": "Default notification settings", + "categoryWatchState": "Default category watch state", + "categoryWatchState.watching": "Watching", + "categoryWatchState.notwatching": "Not Watching", + "categoryWatchState.ignoring": "Ignoring" } \ No newline at end of file diff --git a/public/language/en-GB/category.json b/public/language/en-GB/category.json index d581c8277d..96122292f0 100644 --- a/public/language/en-GB/category.json +++ b/public/language/en-GB/category.json @@ -10,16 +10,18 @@ "no_replies": "No one has replied", "no_new_posts": "No new posts.", - "share_this_category": "Share this category", "watch": "Watch", "ignore": "Ignore", "watching": "Watching", + "not-watching": "Not Watching", "ignoring": "Ignoring", - "watching.description": "Show topics in unread", - "ignoring.description": "Do not show topics in unread", + "watching.description": "Show topics in unread and recent", + "not-watching.description": "Do not show topics in unread, show in recent", + "ignoring.description": "Do not show topics in unread and recent", - "watch.message": "You are now watching updates from this category and all subcategories", - "ignore.message": "You are now ignoring updates from this category and all subcategories", + "watching.message": "You are now watching updates from this category and all subcategories", + "notwatching.message": "You are not watching updates from this category and all subcategories", + "ignoring.message": "You are now ignoring updates from this category and all subcategories", "watched-categories": "Watched categories" } diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 5c94a03779..f5f91059be 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -30,6 +30,7 @@ "watched_categories": "Watched categories", "watched": "Watched", "ignored": "Ignored", + "default-category-watch-state": "Default category watch state", "followers": "Followers", "following": "Following", "blocks": "Blocks", diff --git a/public/src/client/account/categories.js b/public/src/client/account/categories.js index b5feb6faa6..7acebaec4a 100644 --- a/public/src/client/account/categories.js +++ b/public/src/client/account/categories.js @@ -14,25 +14,28 @@ define('forum/account/categories', ['forum/account/header'], function (header) { function handleIgnoreWatch(cid) { var category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching"], [component="category/ignoring"]').on('click', function () { + category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { var $this = $(this); - var command = $this.attr('component') === 'category/watching' ? 'watch' : 'ignore'; + var state = $this.attr('data-state'); - socket.emit('categories.' + command, { cid: cid, uid: ajaxify.data.uid }, function (err, modified_cids) { + socket.emit('categories.setWatchState', { cid: cid, state: state, uid: ajaxify.data.uid }, function (err, modified_cids) { if (err) { return app.alertError(err.message); } modified_cids.forEach(function (cid) { var category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching/menu"]').toggleClass('hidden', command !== 'watch'); - category.find('[component="category/watching/check"]').toggleClass('fa-check', command === 'watch'); + category.find('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); + category.find('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); - category.find('[component="category/ignoring/menu"]').toggleClass('hidden', command !== 'ignore'); - category.find('[component="category/ignoring/check"]').toggleClass('fa-check', command === 'ignore'); + category.find('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); + category.find('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); + + category.find('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); + category.find('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); }); - app.alertSuccess('[[category:' + command + '.message]]'); + app.alertSuccess('[[category:' + state + '.message]]'); }); }); } diff --git a/public/src/client/category.js b/public/src/client/category.js index 9fdd4dcd5b..f02eeb7c40 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -62,22 +62,25 @@ define('forum/category', [ } function handleIgnoreWatch(cid) { - $('[component="category/watching"], [component="category/ignoring"]').on('click', function () { + $('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { var $this = $(this); - var command = $this.attr('component') === 'category/watching' ? 'watch' : 'ignore'; + var state = $this.attr('data-state'); - socket.emit('categories.' + command, cid, function (err) { + socket.emit('categories.setWatchState', { cid: cid, state: state }, function (err) { if (err) { return app.alertError(err.message); } - $('[component="category/watching/menu"]').toggleClass('hidden', command !== 'watch'); - $('[component="category/watching/check"]').toggleClass('fa-check', command === 'watch'); + $('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); + $('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); - $('[component="category/ignoring/menu"]').toggleClass('hidden', command !== 'ignore'); - $('[component="category/ignoring/check"]').toggleClass('fa-check', command === 'ignore'); + $('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); + $('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); - app.alertSuccess('[[category:' + command + '.message]]'); + $('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); + $('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); + + app.alertSuccess('[[category:' + state + '.message]]'); }); }); } diff --git a/src/categories/delete.js b/src/categories/delete.js index 69f5a24045..3024791b2a 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -52,7 +52,7 @@ module.exports = function (Categories) { 'cid:' + cid + ':tids:posts', 'cid:' + cid + ':pids', 'cid:' + cid + ':read_by_uid', - 'cid:' + cid + ':ignorers', + 'cid:' + cid + ':uid:watch:state', 'cid:' + cid + ':children', 'cid:' + cid + ':tag:whitelist', 'category:' + cid, diff --git a/src/categories/index.js b/src/categories/index.js index cba2da02c8..278f72c498 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -21,6 +21,7 @@ require('./unread')(Categories); require('./activeusers')(Categories); require('./recentreplies')(Categories); require('./update')(Categories); +require('./watch')(Categories); Categories.exists = function (cid, callback) { db.exists('category:' + cid, callback); @@ -45,8 +46,8 @@ Categories.getCategoryById = function (data, callback) { topicCount: function (next) { Categories.getTopicCount(data, next); }, - isIgnored: function (next) { - Categories.isIgnored([data.cid], data.uid, next); + watchState: function (next) { + Categories.getWatchState([data.cid], data.uid, next); }, parent: function (next) { if (category.parentCid) { @@ -64,7 +65,9 @@ Categories.getCategoryById = function (data, callback) { category.topics = results.topics.topics; category.nextStart = results.topics.nextStart; category.topic_count = results.topicCount; - category.isIgnored = results.isIgnored[0]; + category.isWatched = results.watchState[0] === Categories.watchStates.watching; + category.isNotWatched = results.watchState[0] === Categories.watchStates.notwatching; + category.isIgnored = results.watchState[0] === Categories.watchStates.ignoring; category.parent = results.parent; calculateTopicPostCount(category); @@ -76,14 +79,6 @@ Categories.getCategoryById = function (data, callback) { ], callback); }; -Categories.isIgnored = function (cids, uid, callback) { - if (parseInt(uid, 10) <= 0) { - return setImmediate(callback, null, cids.map(() => false)); - } - const keys = cids.map(cid => 'cid:' + cid + ':ignorers'); - db.isMemberOfSortedSets(keys, uid, callback); -}; - Categories.getAllCidsFromSet = function (key, callback) { const cids = cache.get(key); if (cids) { @@ -443,20 +438,4 @@ Categories.buildForSelectCategories = function (categories, callback) { callback(null, categoriesData); }; -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) { - db.isSortedSetMembers('cid:' + cid + ':ignorers', uids, next); - }, - function (isIgnoring, next) { - const readingUids = uids.filter((uid, index) => uid && !isIgnoring[index]); - next(null, readingUids); - }, - ], callback); -}; - Categories.async = require('../promisify')(Categories); diff --git a/src/categories/watch.js b/src/categories/watch.js new file mode 100644 index 0000000000..66a9ec6594 --- /dev/null +++ b/src/categories/watch.js @@ -0,0 +1,80 @@ +'use strict'; + +const async = require('async'); + +const db = require('../database'); +const user = require('../user'); + +module.exports = function (Categories) { + Categories.watchStates = { + ignoring: 1, + notwatching: 2, + watching: 3, + }; + + Categories.isIgnored = function (cids, uid, callback) { + if (!(parseInt(uid, 10) > 0)) { + return setImmediate(callback, null, cids.map(() => false)); + } + async.waterfall([ + function (next) { + Categories.getWatchState(cids, uid, next); + }, + function (states, next) { + next(null, states.map(state => state === Categories.watchStates.ignoring)); + }, + ], callback); + }; + + Categories.getWatchState = function (cids, uid, callback) { + if (!(parseInt(uid, 10) > 0)) { + return setImmediate(callback, null, cids.map(() => Categories.watchStates.notwatching)); + } + if (!Array.isArray(cids) || !cids.length) { + return setImmediate(callback, null, []); + } + async.waterfall([ + function (next) { + const keys = cids.map(cid => 'cid:' + cid + ':uid:watch:state'); + async.parallel({ + userSettings: async.apply(user.getSettings, uid), + states: async.apply(db.sortedSetsScore, keys, uid), + }, next); + }, + function (results, next) { + next(null, results.states.map(state => state || Categories.watchStates[results.userSettings.categoryWatchState])); + }, + ], callback); + }; + + Categories.getIgnorers = function (cid, start, stop, callback) { + const count = (stop === -1) ? -1 : (stop - start + 1); + db.getSortedSetRevRangeByScore('cid:' + cid + ':uid:watch:state', start, count, Categories.watchStates.ignoring, Categories.watchStates.ignoring, callback); + }; + + Categories.filterIgnoringUids = function (cid, uids, callback) { + async.waterfall([ + function (next) { + Categories.getUidsWatchStates(cid, uids, next); + }, + function (states, next) { + const readingUids = uids.filter((uid, index) => uid && states[index] !== Categories.watchStates.ignoring); + next(null, readingUids); + }, + ], callback); + }; + + Categories.getUidsWatchStates = function (cid, uids, callback) { + async.waterfall([ + function (next) { + async.parallel({ + userSettings: async.apply(user.getMultipleUserSettings, uids), + states: async.apply(db.sortedSetScores, 'cid:' + cid + ':uid:watch:state', uids), + }, next); + }, + function (results, next) { + next(null, results.states.map((state, index) => state || Categories.watchStates[results.userSettings[index].categoryWatchState])); + }, + ], callback); + }; +}; diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js index 43eff07889..1371435f49 100644 --- a/src/controllers/accounts/categories.js +++ b/src/controllers/accounts/categories.js @@ -21,8 +21,8 @@ categoriesController.get = function (req, res, callback) { } async.parallel({ - ignored: function (next) { - user.getIgnoredCategories(userData.uid, next); + states: function (next) { + user.getCategoryWatchState(userData.uid, next); }, categories: function (next) { categories.buildForSelect(userData.uid, 'find', next); @@ -32,7 +32,9 @@ categoriesController.get = function (req, res, callback) { function (results) { results.categories.forEach(function (category) { if (category) { - category.isIgnored = results.ignored.includes(String(category.cid)); + category.isIgnored = results.states[category.cid] === categories.watchStates.ignoring; + category.isWatched = results.states[category.cid] === categories.watchStates.watching; + category.isNotWatched = results.states[category.cid] === categories.watchStates.notwatching; } }); userData.categories = results.categories; diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 9cc3561c5f..3661b5d6f1 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -161,6 +161,8 @@ settingsController.get = function (req, res, callback) { }; }); + userData.categoryWatchState = { [userData.settings.categoryWatchState]: true }; + userData.disableCustomUserSkins = meta.config.disableCustomUserSkins; userData.allowUserHomePage = meta.config.allowUserHomePage; diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js index 5eef187cf8..6068de3091 100644 --- a/src/controllers/helpers.js +++ b/src/controllers/helpers.js @@ -239,6 +239,20 @@ helpers.getCategories = function (set, uid, privilege, selectedCid, callback) { ], callback); }; +helpers.getCategoriesByStates = function (uid, selectedCid, states, callback) { + async.waterfall([ + function (next) { + user.getCategoriesByStates(uid, states, next); + }, + function (cids, next) { + privileges.categories.filterCids('read', cids, uid, next); + }, + function (cids, next) { + getCategoryData(cids, uid, selectedCid, next); + }, + ], callback); +}; + helpers.getWatchedCategories = function (uid, selectedCid, callback) { async.waterfall([ function (next) { diff --git a/src/controllers/recent.js b/src/controllers/recent.js index aee3890067..e18c853c68 100644 --- a/src/controllers/recent.js +++ b/src/controllers/recent.js @@ -5,6 +5,7 @@ var async = require('async'); var nconf = require('nconf'); var user = require('../user'); +var categories = require('../categories'); var topics = require('../topics'); var meta = require('../meta'); var helpers = require('./helpers'); @@ -47,8 +48,8 @@ recentController.getData = function (req, url, sort, callback) { settings: function (next) { user.getSettings(req.uid, next); }, - watchedCategories: function (next) { - helpers.getWatchedCategories(req.uid, cid, next); + categories: function (next) { + helpers.getCategoriesByStates(req.uid, cid, [categories.watchStates.watching, categories.watchStates.notwatching], next); }, rssToken: function (next) { user.auth.getFeedToken(req.uid, next); @@ -58,7 +59,7 @@ recentController.getData = function (req, url, sort, callback) { function (results, next) { rssToken = results.rssToken; settings = results.settings; - categoryData = results.watchedCategories; + categoryData = results.categories; var start = Math.max(0, (page - 1) * settings.topicsPerPage); stop = start + settings.topicsPerPage - 1; diff --git a/src/controllers/unread.js b/src/controllers/unread.js index 3c2259bb4f..c1f9804437 100644 --- a/src/controllers/unread.js +++ b/src/controllers/unread.js @@ -8,6 +8,7 @@ var querystring = require('querystring'); var meta = require('../meta'); var pagination = require('../pagination'); var user = require('../user'); +var categories = require('../categories'); var topics = require('../topics'); var plugins = require('../plugins'); var helpers = require('./helpers'); @@ -35,7 +36,7 @@ unreadController.get = function (req, res, next) { if (plugins.hasListeners('filter:unread.categories')) { plugins.fireHook('filter:unread.categories', { uid: req.uid, cid: cid }, next); } else { - helpers.getWatchedCategories(req.uid, cid, next); + helpers.getCategoriesByStates(req.uid, cid, [categories.watchStates.watching], next); } }, settings: function (next) { diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js index cd90546e39..029d5948be 100644 --- a/src/database/mongo/sorted.js +++ b/src/database/mongo/sorted.js @@ -56,6 +56,10 @@ module.exports = function (db, module) { query.score.$lte = max; } + if (max === min) { + query.score = max; + } + const fields = { _id: 0, _key: 0 }; if (!withScores) { fields.score = 0; @@ -115,10 +119,12 @@ module.exports = function (db, module) { }; function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores, callback) { - if (parseInt(count, 10) === -1) { - count = 0; + if (parseInt(count, 10) === 0) { + return setImmediate(callback, null, []); } - var stop = start + count - 1; + const stop = (parseInt(count, 10) === -1) ? -1 : (start + count - 1); + + console.log(key, start, stop); getSortedSetRange(key, start, stop, min, max, sort, withScores, callback); } @@ -261,7 +267,7 @@ module.exports = function (db, module) { module.sortedSetsScore = function (keys, value, callback) { if (!Array.isArray(keys) || !keys.length) { - return callback(); + return callback(null, []); } value = helpers.valueToString(value); db.collection('objects').find({ _key: { $in: keys }, value: value }, { projection: { _id: 0, value: 0 } }).toArray(function (err, result) { diff --git a/src/database/postgres/sorted.js b/src/database/postgres/sorted.js index ad749c19bd..014bda1ba7 100644 --- a/src/database/postgres/sorted.js +++ b/src/database/postgres/sorted.js @@ -348,7 +348,7 @@ SELECT z."score" s module.sortedSetsScore = function (keys, value, callback) { if (!Array.isArray(keys) || !keys.length) { - return callback(); + return callback(null, []); } value = helpers.valueToString(value); diff --git a/src/database/redis/sorted.js b/src/database/redis/sorted.js index 43b797e2f9..72a522c8eb 100644 --- a/src/database/redis/sorted.js +++ b/src/database/redis/sorted.js @@ -170,6 +170,9 @@ module.exports = function (redisClient, module) { }; module.sortedSetsScore = function (keys, value, callback) { + if (!Array.isArray(keys) || !keys.length) { + return callback(null, []); + } helpers.execKeysValue(redisClient, 'batch', 'zscore', keys, value, function (err, scores) { if (err) { return callback(err); diff --git a/src/search.js b/src/search.js index 978db18cc9..321c8f4619 100644 --- a/src/search.js +++ b/src/search.js @@ -330,7 +330,7 @@ function getSearchCids(data, callback) { async.parallel({ watchedCids: function (next) { if (data.categories.includes('watched')) { - user.getWatchedCategories(data.uid, next); + user.getCategoriesByStates(data.uid, [categories.watchStates.watching], next); } else { setImmediate(next, null, []); } diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js index 2af93cad2d..c65cbba9be 100644 --- a/src/socket.io/categories.js +++ b/src/socket.io/categories.js @@ -155,20 +155,28 @@ SocketCategories.getSelectCategories = function (socket, data, callback) { ], callback); }; -SocketCategories.watch = function (socket, cid, callback) { - ignoreOrWatch(user.watchCategory, socket, cid, callback); +SocketCategories.setWatchState = function (socket, data, callback) { + if (!data || !data.cid || !data.state) { + return callback(new Error('[[error:invalid-data]]')); + } + ignoreOrWatch(function (uid, cid, next) { + user.setCategoryWatchState(uid, cid, categories.watchStates[data.state], next); + }, socket, data, callback); +}; + +SocketCategories.watch = function (socket, data, callback) { + ignoreOrWatch(user.watchCategory, socket, data, callback); }; -SocketCategories.ignore = function (socket, cid, callback) { - ignoreOrWatch(user.ignoreCategory, socket, cid, callback); +SocketCategories.ignore = function (socket, data, callback) { + ignoreOrWatch(user.ignoreCategory, socket, data, callback); }; -function ignoreOrWatch(fn, socket, cid, callback) { +function ignoreOrWatch(fn, socket, data, callback) { var targetUid = socket.uid; - var cids = [parseInt(cid, 10)]; - if (typeof cid === 'object') { - targetUid = cid.uid; - cids = [parseInt(cid.cid, 10)]; + var cids = [parseInt(data.cid, 10)]; + if (data.hasOwnProperty('uid')) { + targetUid = data.uid; } async.waterfall([ diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index 256389cac6..bc18e4b69a 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'); @@ -63,15 +64,15 @@ function filterTidCidIgnorers(uids, tid, cid, callback) { topicIgnored: function (next) { db.isSetMembers('tid:' + tid + ':ignorers', uids, next); }, - categoryIgnored: function (next) { - db.sortedSetScores('cid:' + cid + ':ignorers', uids, next); + categoryWatchStates: function (next) { + categories.getUidsWatchStates(cid, uids, next); }, }, next); }, function (results, next) { uids = uids.filter(function (uid, index) { return results.topicFollowed[index] || - (!results.topicFollowed[index] && !results.topicIgnored[index] && !results.categoryIgnored[index]); + (!results.topicIgnored[index] && results.categoryWatchStates[index] !== categories.watchStates.ignoring); }); next(null, uids); }, diff --git a/src/topics/unread.js b/src/topics/unread.js index ab07395171..5179d994ea 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -194,8 +194,8 @@ module.exports = function (Topics) { isTopicsFollowed: function (next) { db.sortedSetScores('uid:' + uid + ':followed_tids', tids, next); }, - ignoredCids: function (next) { - categories.isIgnored(cids, uid, next); + categoryWatchState: function (next) { + categories.getWatchState(cids, uid, next); }, readableCids: function (next) { privileges.categories.filterCids('read', cids, uid, next); @@ -205,7 +205,7 @@ module.exports = function (Topics) { function (results, next) { cid = cid && cid.map(String); results.readableCids = results.readableCids.map(String); - const isCidIgnored = _.zipObject(cids, results.ignoredCids); + const userCidState = _.zipObject(cids, results.categoryWatchState); topicData.forEach(function (topic, index) { function cidMatch(topicCid) { @@ -214,7 +214,7 @@ module.exports = function (Topics) { if (topic && topic.cid && cidMatch(topic.cid) && !blockedUids.includes(parseInt(topic.uid, 10))) { topic.tid = parseInt(topic.tid, 10); - if ((results.isTopicsFollowed[index] || !isCidIgnored[topic.cid])) { + if ((results.isTopicsFollowed[index] || userCidState[topic.cid] === categories.watchStates.watching)) { tidsByFilter[''].push(topic.tid); } diff --git a/src/upgrades/1.12.0/category_watch_state.js b/src/upgrades/1.12.0/category_watch_state.js new file mode 100644 index 0000000000..20c3aa221c --- /dev/null +++ b/src/upgrades/1.12.0/category_watch_state.js @@ -0,0 +1,46 @@ +'use strict'; + +var async = require('async'); + +var db = require('../../database'); +var batch = require('../../batch'); +var categories = require('../../categories'); + +module.exports = { + name: 'Update category watch data', + timestamp: Date.UTC(2018, 11, 13), + method: function (callback) { + const progress = this.progress; + let keys; + async.waterfall([ + function (next) { + db.getSortedSetRange('categories:cids', 0, -1, next); + }, + function (cids, next) { + keys = cids.map(cid => 'cid:' + cid + ':ignorers'); + batch.processSortedSet('users:joindate', function (uids, next) { + progress.incr(uids.length); + + async.eachSeries(cids, function (cid, next) { + db.isSortedSetMembers('cid:' + cid + ':ignorers', uids, function (err, isMembers) { + if (err) { + return next(err); + } + uids = uids.filter((uid, index) => isMembers[index]); + if (!uids.length) { + return setImmediate(next); + } + const states = uids.map(() => categories.watchStates.ignoring); + db.sortedSetAdd('cid:' + cid + ':uid:watch:state', states, uids, next); + }); + }, next); + }, { + progress: progress, + }, next); + }, + function (next) { + db.deleteAll(keys, next); + }, + ], callback); + }, +}; diff --git a/src/user/categories.js b/src/user/categories.js index b81bb8935e..23492d2b3b 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -1,15 +1,39 @@ 'use strict'; -var async = require('async'); +const async = require('async'); +const _ = require('lodash'); -var db = require('../database'); -var categories = require('../categories'); +const db = require('../database'); +const categories = require('../categories'); module.exports = function (User) { - User.getIgnoredCategories = function (uid, callback) { + User.setCategoryWatchState = function (uid, cid, state, callback) { + if (!(parseInt(uid, 10) > 0)) { + return setImmediate(callback); + } + const isStateValid = Object.keys(categories.watchStates).some(key => categories.watchStates[key] === parseInt(state, 10)); + if (!isStateValid) { + return setImmediate(callback, new Error('[[error:invalid-watch-state]]')); + } + async.waterfall([ + function (next) { + categories.exists(cid, next); + }, + function (exists, next) { + if (!exists) { + return next(new Error('[[error:no-category]]')); + } + + db.sortedSetAdd('cid:' + cid + ':uid:watch:state', state, uid, next); + }, + ], callback); + }; + + User.getCategoryWatchState = function (uid, callback) { if (parseInt(uid, 10) <= 0) { - return setImmediate(callback, null, []); + return setImmediate(callback, null, {}); } + let cids; async.waterfall([ function (next) { @@ -17,69 +41,49 @@ module.exports = function (User) { }, function (_cids, next) { cids = _cids; - db.isMemberOfSortedSets(cids.map(cid => 'cid:' + cid + ':ignorers'), uid, next); + categories.getWatchState(cids, uid, next); }, - function (isMembers, next) { - next(null, cids.filter((cid, index) => isMembers[index])); + function (states, next) { + next(null, _.zipObject(cids, states)); }, ], callback); }; + User.getIgnoredCategories = function (uid, callback) { + if (parseInt(uid, 10) <= 0) { + return setImmediate(callback, null, []); + } + User.getCategoriesByStates(uid, [categories.watchStates.ignoring], callback); + }; + User.getWatchedCategories = function (uid, callback) { - async.waterfall([ - function (next) { - async.parallel({ - ignored: function (next) { - User.getIgnoredCategories(uid, next); - }, - all: function (next) { - categories.getAllCidsFromSet('categories:cid', next); - }, - }, next); - }, - function (results, next) { - const ignored = new Set(results.ignored); - const watched = results.all.filter(cid => cid && !ignored.has(String(cid))); - next(null, watched); - }, - ], callback); + if (parseInt(uid, 10) <= 0) { + return setImmediate(callback, null, []); + } + User.getCategoriesByStates(uid, [categories.watchStates.watching], callback); }; - User.ignoreCategory = function (uid, cid, callback) { - if (uid <= 0) { - return setImmediate(callback); + User.getCategoriesByStates = function (uid, states, callback) { + if (!(parseInt(uid, 10) > 0)) { + return categories.getAllCidsFromSet('categories:cid', callback); } async.waterfall([ function (next) { - categories.exists(cid, next); + User.getCategoryWatchState(uid, next); }, - function (exists, next) { - if (!exists) { - return next(new Error('[[error:no-category]]')); - } - - db.sortedSetAdd('cid:' + cid + ':ignorers', Date.now(), uid, next); + function (userState, next) { + const cids = Object.keys(userState); + next(null, cids.filter(cid => states.includes(userState[cid]))); }, ], callback); }; - User.watchCategory = function (uid, cid, callback) { - if (uid <= 0) { - return callback(); - } - - async.waterfall([ - function (next) { - categories.exists(cid, next); - }, - function (exists, next) { - if (!exists) { - return next(new Error('[[error:no-category]]')); - } + User.ignoreCategory = function (uid, cid, callback) { + User.setCategoryWatchState(uid, cid, categories.watchStates.ignoring, callback); + }; - db.sortedSetRemove('cid:' + cid + ':ignorers', uid, next); - }, - ], callback); + User.watchCategory = function (uid, cid, callback) { + User.setCategoryWatchState(uid, cid, categories.watchStates.watching, callback); }; }; diff --git a/src/user/settings.js b/src/user/settings.js index c59af120cd..ca532e52cb 100644 --- a/src/user/settings.js +++ b/src/user/settings.js @@ -79,6 +79,7 @@ module.exports = function (User) { settings.topicSearchEnabled = parseInt(getSetting(settings, 'topicSearchEnabled', 0), 10) === 1; settings.bootswatchSkin = settings.bootswatchSkin || ''; settings.scrollToMyPost = parseInt(getSetting(settings, 'scrollToMyPost', 1), 10) === 1; + settings.categoryWatchState = getSetting(settings, 'categoryWatchState', 'notwatching'); notifications.getAllNotificationTypes(next); }, @@ -137,6 +138,7 @@ module.exports = function (User) { outgoingChatSound: data.outgoingChatSound, upvoteNotifFreq: data.upvoteNotifFreq, bootswatchSkin: data.bootswatchSkin, + categoryWatchState: data.categoryWatchState, }; async.waterfall([ diff --git a/src/views/admin/settings/user.tpl b/src/views/admin/settings/user.tpl index 5094b297e0..a10867a271 100644 --- a/src/views/admin/settings/user.tpl +++ b/src/views/admin/settings/user.tpl @@ -307,6 +307,15 @@ +
+ + +
+ diff --git a/test/categories.js b/test/categories.js index 448655acbc..f5b838066d 100644 --- a/test/categories.js +++ b/test/categories.js @@ -284,18 +284,22 @@ describe('Categories', function () { }); it('should ignore category', function (done) { - socketCategories.ignore({ uid: posterUid }, categoryObj.cid, function (err) { + socketCategories.ignore({ uid: posterUid }, { cid: categoryObj.cid }, function (err) { assert.ifError(err); Categories.isIgnored([categoryObj.cid], posterUid, function (err, isIgnored) { assert.ifError(err); assert.equal(isIgnored[0], true); - done(); + Categories.getIgnorers(categoryObj.cid, 0, -1, function (err, ignorers) { + assert.ifError(err); + assert.deepEqual(ignorers, [posterUid]); + done(); + }); }); }); }); it('should watch category', function (done) { - socketCategories.watch({ uid: posterUid }, categoryObj.cid, function (err) { + socketCategories.watch({ uid: posterUid }, { cid: categoryObj.cid }, function (err) { assert.ifError(err); Categories.isIgnored([categoryObj.cid], posterUid, function (err, isIgnored) { assert.ifError(err); @@ -305,6 +309,13 @@ describe('Categories', function () { }); }); + it('should error if watch state does not exist', function (done) { + socketCategories.setWatchState({ uid: posterUid }, { cid: categoryObj.cid, state: 'invalid-state' }, function (err) { + assert.equal(err.message, '[[error:invalid-watch-state]]'); + done(); + }); + }); + it('should check if user is moderator', function (done) { socketCategories.isModerator({ uid: posterUid }, {}, function (err, isModerator) { assert.ifError(err); diff --git a/test/database/sorted.js b/test/database/sorted.js index da2e25c951..f0320962cd 100644 --- a/test/database/sorted.js +++ b/test/database/sorted.js @@ -228,6 +228,22 @@ describe('Sorted Set methods', function () { }); }); + it('should return empty array if count is 0', function (done) { + db.getSortedSetRevRangeByScore('sortedSetTest1', 0, 0, '+inf', '-inf', function (err, values) { + assert.ifError(err); + assert.deepEqual(values, []); + done(); + }); + }); + + it('should return elements from 1 to end', function (done) { + db.getSortedSetRevRangeByScore('sortedSetTest1', 1, -1, '+inf', '-inf', function (err, values) { + assert.ifError(err); + assert.deepEqual(values, ['value2', 'value1']); + done(); + }); + }); + it('should return elements from 3 to last', function (done) { db.sortedSetAdd('partialZset', [1, 2, 3, 4, 5], ['value1', 'value2', 'value3', 'value4', 'value5'], function (err) { assert.ifError(err); @@ -523,6 +539,15 @@ describe('Sorted Set methods', function () { done(); }); }); + + it('should return empty array if keys is empty array', function (done) { + db.sortedSetsScore([], 'value1', function (err, scores) { + assert.equal(err, null); + assert.equal(arguments.length, 2); + assert.deepEqual(scores, []); + done(); + }); + }); }); describe('sortedSetScores()', function () { diff --git a/test/topics.js b/test/topics.js index 633293739f..0c467acdb6 100644 --- a/test/topics.js +++ b/test/topics.js @@ -1377,6 +1377,38 @@ describe('Topic\'s', function () { ], done); }); + it('should not return topics in category you ignored/not watching', function (done) { + var ignoredCid; + var tid; + async.waterfall([ + function (next) { + categories.create({ + name: 'ignored category', + description: 'ignored category', + }, next); + }, + function (category, next) { + ignoredCid = category.cid; + privileges.categories.rescind(['read'], category.cid, 'registered-users', next); + }, + function (next) { + topics.post({ uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: ignoredCid }, next); + }, + function (data, next) { + tid = data.topicData.tid; + User.ignoreCategory(uid, ignoredCid, next); + }, + function (next) { + topics.getUnreadTids({ uid: uid }, next); + }, + function (unreadTids, next) { + unreadTids = unreadTids.map(String); + assert(!unreadTids.includes(String(tid))); + next(); + }, + ], done); + }); + it('should not return topic as unread if new post is from blocked user', function (done) { var blockedUid; var topic;