diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json index c89266b661..201d10ef0a 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -6,6 +6,7 @@ "popular-month": "Popular topics this month", "popular-alltime": "All time popular topics", "recent": "Recent Topics", + "top": "Top Voted Topics", "moderator-tools": "Moderator Tools", "flagged-content": "Flagged Content", "ip-blacklist": "IP Blacklist", diff --git a/public/language/en-GB/top.json b/public/language/en-GB/top.json new file mode 100644 index 0000000000..b8a05bfa5f --- /dev/null +++ b/public/language/en-GB/top.json @@ -0,0 +1,4 @@ +{ + "title": "Top", + "no_top_topics": "No top topics" +} \ No newline at end of file diff --git a/public/src/client/top.js b/public/src/client/top.js new file mode 100644 index 0000000000..9e80cb668a --- /dev/null +++ b/public/src/client/top.js @@ -0,0 +1,52 @@ +'use strict'; + +define('forum/top', ['forum/recent', 'forum/infinitescroll'], function (recent, infinitescroll) { + var Top = {}; + + $(window).on('action:ajaxify.start', function (ev, data) { + if (ajaxify.currentPage !== data.url) { + recent.removeListeners(); + } + }); + + Top.init = function () { + app.enterRoom('top_topics'); + + recent.watchForNewPosts(); + + recent.handleCategorySelection(); + + $('#new-topics-alert').on('click', function () { + $(this).addClass('hide'); + }); + + if (!config.usePagination) { + infinitescroll.init(loadMoreTopics); + } + + $(window).trigger('action:topics.loaded', { topics: ajaxify.data.topics }); + }; + + function loadMoreTopics(direction) { + if (direction < 0 || !$('[component="category"]').length) { + return; + } + + infinitescroll.loadMore('topics.loadMoreTopTopics', { + after: $('[component="category"]').attr('data-nextstart'), + count: config.topicsPerPage, + cid: utils.params().cid, + filter: ajaxify.data.selectedFilter.filter, + }, function (data, done) { + if (data.topics && data.topics.length) { + recent.onTopicsLoaded('top', data.topics, true, done); + $('[component="category"]').attr('data-nextstart', data.nextStart); + } else { + done(); + $('#load-more-btn').hide(); + } + }); + } + + return Top; +}); diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 0bb3ef1744..cadb7c12f8 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -254,6 +254,10 @@ function getHomePageRoutes(userData, callback) { route: 'recent', name: 'Recent', }, + { + route: 'top', + name: 'Top', + }, { route: 'popular', name: 'Popular', @@ -292,6 +296,3 @@ function getHomePageRoutes(userData, callback) { }, ], callback); } - - -module.exports = settingsController; diff --git a/src/controllers/admin/homepage.js b/src/controllers/admin/homepage.js index bc0971622f..45fabeb2d4 100644 --- a/src/controllers/admin/homepage.js +++ b/src/controllers/admin/homepage.js @@ -37,6 +37,10 @@ homePageController.get = function (req, res, next) { route: 'recent', name: 'Recent', }, + { + route: 'top', + name: 'Top', + }, { route: 'popular', name: 'Popular', diff --git a/src/controllers/index.js b/src/controllers/index.js index 5539cae729..bddab21a11 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -19,6 +19,7 @@ Controllers.category = require('./category'); Controllers.unread = require('./unread'); Controllers.recent = require('./recent'); Controllers.popular = require('./popular'); +Controllers.top = require('./top'); Controllers.tags = require('./tags'); Controllers.search = require('./search'); Controllers.user = require('./user'); diff --git a/src/controllers/top.js b/src/controllers/top.js new file mode 100644 index 0000000000..7b500533d5 --- /dev/null +++ b/src/controllers/top.js @@ -0,0 +1,84 @@ + +'use strict'; + +var async = require('async'); +var nconf = require('nconf'); +var querystring = require('querystring'); + +var user = require('../user'); +var topics = require('../topics'); +var meta = require('../meta'); +var helpers = require('./helpers'); +var pagination = require('../pagination'); + +var topController = module.exports; + +topController.get = function (req, res, next) { + var page = parseInt(req.query.page, 10) || 1; + var stop = 0; + var settings; + var cid = req.query.cid; + var filter = req.params.filter || ''; + var categoryData; + var rssToken; + + if (!helpers.validFilters[filter]) { + return next(); + } + + async.waterfall([ + function (next) { + async.parallel({ + settings: function (next) { + user.getSettings(req.uid, next); + }, + watchedCategories: function (next) { + helpers.getWatchedCategories(req.uid, cid, next); + }, + rssToken: function (next) { + user.auth.getFeedToken(req.uid, next); + }, + }, next); + }, + function (results, next) { + rssToken = results.rssToken; + settings = results.settings; + categoryData = results.watchedCategories; + + var start = Math.max(0, (page - 1) * settings.topicsPerPage); + stop = start + settings.topicsPerPage - 1; + + topics.getTopTopics(cid, req.uid, start, stop, filter, next); + }, + function (data) { + data.categories = categoryData.categories; + data.selectedCategory = categoryData.selectedCategory; + data.selectedCids = categoryData.selectedCids; + data.nextStart = stop + 1; + data.set = 'topics:votes'; + data['feeds:disableRSS'] = parseInt(meta.config['feeds:disableRSS'], 10) === 1; + data.rssFeedUrl = nconf.get('relative_path') + '/top.rss'; + if (req.uid) { + data.rssFeedUrl += '?uid=' + req.uid + '&token=' + rssToken; + } + data.title = meta.config.homePageTitle || '[[pages:home]]'; + data.filters = helpers.buildFilters('top', filter); + + data.selectedFilter = data.filters.find(function (filter) { + return filter && filter.selected; + }); + + var pageCount = Math.max(1, Math.ceil(data.topicCount / settings.topicsPerPage)); + data.pagination = pagination.create(page, pageCount, req.query); + + if (req.originalUrl.startsWith(nconf.get('relative_path') + '/api/top') || req.originalUrl.startsWith(nconf.get('relative_path') + '/top')) { + data.title = '[[pages:top]]'; + data.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[top:title]]' }]); + } + + data.querystring = cid ? '?' + querystring.stringify({ cid: cid }) : ''; + + res.render('top', data); + }, + ], next); +}; diff --git a/src/posts.js b/src/posts.js index f6b22b89ed..cf30bd4c64 100644 --- a/src/posts.js +++ b/src/posts.js @@ -256,11 +256,27 @@ Posts.updatePostVoteCount = function (postData, callback) { function (next) { async.waterfall([ function (next) { - topics.getTopicField(postData.tid, 'mainPid', next); + topics.getTopicFields(postData.tid, ['mainPid', 'cid'], next); }, - function (mainPid, next) { - if (parseInt(mainPid, 10) === parseInt(postData.pid, 10)) { - return next(); + function (topicData, next) { + if (parseInt(topicData.mainPid, 10) === parseInt(postData.pid, 10)) { + async.parallel([ + function (next) { + topics.setTopicFields(postData.tid, { + upvotes: postData.upvotes, + downvotes: postData.downvotes, + }, next); + }, + function (next) { + db.sortedSetAdd('topics:votes', postData.votes, postData.tid, next); + }, + function (next) { + db.sortedSetAdd('cid:' + topicData.cid + ':tids:votes', postData.votes, postData.tid, next); + }, + ], function (err) { + next(err); + }); + return; } db.sortedSetAdd('tid:' + postData.tid + ':posts:votes', postData.votes, postData.pid, next); }, @@ -270,7 +286,10 @@ Posts.updatePostVoteCount = function (postData, callback) { db.sortedSetAdd('posts:votes', postData.votes, postData.pid, next); }, function (next) { - Posts.setPostFields(postData.pid, { upvotes: postData.upvotes, downvotes: postData.downvotes }, next); + Posts.setPostFields(postData.pid, { + upvotes: postData.upvotes, + downvotes: postData.downvotes, + }, next); }, ], function (err) { callback(err); diff --git a/src/routes/index.js b/src/routes/index.js index e9f9b26c0a..ce0a4045e8 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -65,6 +65,7 @@ function categoryRoutes(app, middleware, controllers) { setupPageRoute(app, '/categories', middleware, [], controllers.categories.list); setupPageRoute(app, '/popular/:term?', middleware, [], controllers.popular.get); setupPageRoute(app, '/recent/:filter?', middleware, [], controllers.recent.get); + setupPageRoute(app, '/top/:filter?', middleware, [], controllers.top.get); setupPageRoute(app, '/unread/:filter?', middleware, [middleware.authenticate], controllers.unread.get); setupPageRoute(app, '/category/:category_id/:slug/:topic_index', middleware, [], controllers.category.get); diff --git a/src/topics.js b/src/topics.js index 44c263210d..f20c069d98 100644 --- a/src/topics.js +++ b/src/topics.js @@ -21,6 +21,7 @@ require('./topics/delete')(Topics); require('./topics/unread')(Topics); require('./topics/recent')(Topics); require('./topics/popular')(Topics); +require('./topics/top')(Topics); require('./topics/user')(Topics); require('./topics/fork')(Topics); require('./topics/posts')(Topics); @@ -165,6 +166,9 @@ Topics.getTopicsByTids = function (tids, uid, callback) { topics[i].bookmark = results.bookmarks[i]; topics[i].unreplied = !topics[i].teaser; + topics[i].upvotes = parseInt(topics[i].upvotes, 10) || 0; + topics[i].downvotes = parseInt(topics[i].downvotes, 10) || 0; + topics[i].votes = topics[i].upvotes - topics[i].downvotes; topics[i].icons = []; } } @@ -226,6 +230,10 @@ Topics.getTopicWithPosts = function (topicData, set, uid, start, stop, reverse, topicData.locked = parseInt(topicData.locked, 10) === 1; topicData.pinned = parseInt(topicData.pinned, 10) === 1; + topicData.upvotes = parseInt(topicData.upvotes, 10) || 0; + topicData.downvotes = parseInt(topicData.downvotes, 10) || 0; + topicData.votes = topicData.upvotes - topicData.downvotes; + topicData.icons = []; plugins.fireHook('filter:topic.get', { topic: topicData, uid: uid }, next); diff --git a/src/topics/top.js b/src/topics/top.js new file mode 100644 index 0000000000..b4f10b9340 --- /dev/null +++ b/src/topics/top.js @@ -0,0 +1,90 @@ + + +'use strict'; + +var async = require('async'); + +var db = require('../database'); +var privileges = require('../privileges'); +var user = require('../user'); +var meta = require('../meta'); + +module.exports = function (Topics) { + Topics.getTopTopics = function (cid, uid, start, stop, filter, callback) { + var topTopics = { + nextStart: 0, + topics: [], + }; + if (cid && !Array.isArray(cid)) { + cid = [cid]; + } + async.waterfall([ + function (next) { + var key = 'topics:votes'; + if (cid) { + key = cid.map(function (cid) { + return 'cid:' + cid + ':tids:votes'; + }); + } + db.getSortedSetRevRange(key, 0, 199, next); + }, + function (tids, next) { + filterTids(tids, uid, filter, cid, next); + }, + function (tids, next) { + topTopics.topicCount = tids.length; + tids = tids.slice(start, stop + 1); + Topics.getTopicsByTids(tids, uid, next); + }, + function (topicData, next) { + topTopics.topics = topicData; + topTopics.nextStart = stop + 1; + next(null, topTopics); + }, + ], callback); + }; + + function filterTids(tids, uid, filter, cid, callback) { + async.waterfall([ + function (next) { + if (filter === 'watched') { + Topics.filterWatchedTids(tids, uid, next); + } else if (filter === 'new') { + Topics.filterNewTids(tids, uid, next); + } else if (filter === 'unreplied') { + Topics.filterUnrepliedTids(tids, next); + } else { + Topics.filterNotIgnoredTids(tids, uid, next); + } + }, + function (tids, next) { + privileges.topics.filterTids('read', tids, uid, next); + }, + function (tids, next) { + async.parallel({ + ignoredCids: function (next) { + if (filter === 'watched' || parseInt(meta.config.disableRecentCategoryFilter, 10) === 1) { + return next(null, []); + } + user.getIgnoredCategories(uid, next); + }, + topicData: function (next) { + Topics.getTopicsFields(tids, ['tid', 'cid'], next); + }, + }, next); + }, + function (results, next) { + cid = cid && cid.map(String); + tids = results.topicData.filter(function (topic) { + if (topic && topic.cid) { + return results.ignoredCids.indexOf(topic.cid.toString()) === -1 && (!cid || (cid.length && cid.indexOf(topic.cid.toString()) !== -1)); + } + return false; + }).map(function (topic) { + return topic.tid; + }); + next(null, tids); + }, + ], callback); + } +}; diff --git a/src/upgrades/1.7.3/topic_votes.js b/src/upgrades/1.7.3/topic_votes.js new file mode 100644 index 0000000000..76a4d0900c --- /dev/null +++ b/src/upgrades/1.7.3/topic_votes.js @@ -0,0 +1,60 @@ +'use strict'; + +var async = require('async'); +var batch = require('../../batch'); +var db = require('../../database'); + +module.exports = { + name: 'Add votes to topics', + timestamp: Date.UTC(2017, 11, 8), + method: function (callback) { + var progress = this.progress; + + batch.processSortedSet('topics:tid', function (tids, next) { + async.eachLimit(tids, 500, function (tid, _next) { + progress.incr(); + var topicData; + async.waterfall([ + function (next) { + db.getObjectFields('topic:' + tid, ['mainPid', 'cid'], next); + }, + function (_topicData, next) { + topicData = _topicData; + if (!topicData.mainPid || !topicData.cid) { + return _next(); + } + db.getObject('post:' + topicData.mainPid, next); + }, + function (postData, next) { + if (!postData) { + return _next(); + } + var upvotes = parseInt(postData.upvotes, 10) || 0; + var downvotes = parseInt(postData.downvotes, 10) || 0; + var data = { + upvotes: upvotes, + downvotes: downvotes, + }; + var votes = upvotes - downvotes; + async.parallel([ + function (next) { + db.setObject('topic:' + tid, data, next); + }, + function (next) { + db.sortedSetAdd('topics:votes', votes, tid, next); + }, + function (next) { + db.sortedSetAdd('cid:' + topicData.cid + ':tids:votes', votes, tid, next); + }, + ], function (err) { + next(err); + }); + }, + ], _next); + }, next); + }, { + progress: progress, + batch: 500, + }, callback); + }, +}; diff --git a/test/controllers.js b/test/controllers.js index 81d08e5572..5f42cc698c 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -118,6 +118,19 @@ describe('Controllers', function () { }); }); + it('should load top', function (done) { + meta.configs.set('homePageRoute', 'top', function (err) { + assert.ifError(err); + + request(nconf.get('url'), function (err, res, body) { + assert.ifError(err); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + it('should load popular', function (done) { meta.configs.set('homePageRoute', 'popular', function (err) { assert.ifError(err);