From 046d0b16375d14dc0eef07edb9d8728ec8367567 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Fri, 20 Nov 2020 11:31:14 -0500 Subject: [PATCH] feat: allow pins to expire (if set) (#8908) * fix: add back topic assert middleware for pin route * feat: server-side handling of pin expiries * refactor: togglePin to not require uid parameter [breaking] * feat: automatic unpinning if pin has expiration set * feat: client-side modal for setting pin expiration * refactor: categories.getPinnedTids to accept multiple cids ... in preparation for pin expiry logic, direct access to *:pinned zsets is discouraged * fix: remove references to since-removed jobs file for topics * feat: expire pins when getPinnedTids is called * refactor: make the togglePin change non-breaking The 'action:topic.pin' hook now sends uid again, as before. However, if it is a system action (that is, a pin that expired), 'system' will be sent in instead of a valid uid --- public/language/en-GB/error.json | 1 + public/language/en-GB/topic.json | 3 ++ public/src/client/category/tools.js | 75 +++++++++++++++++++++++------ src/categories/topics.js | 8 ++- src/controllers/write/topics.js | 6 +++ src/routes/write/topics.js | 2 +- src/topics/sorted.js | 8 +-- src/topics/tools.js | 39 +++++++++++++-- src/user/jobs.js | 2 +- src/views/modals/set-pin-expiry.tpl | 5 ++ 10 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 src/views/modals/set-pin-expiry.tpl diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 206e7bbf26..1b054dee4e 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -11,6 +11,7 @@ "invalid-tid": "Invalid Topic ID", "invalid-pid": "Invalid Post ID", "invalid-uid": "Invalid User ID", + "invalid-date": "A valid date must be provided", "invalid-username": "Invalid Username", "invalid-email": "Invalid Email", diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index ef7d946bcf..8dc2f22401 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -103,6 +103,9 @@ "post_restore_confirm": "Are you sure you want to restore this post?", "post_purge_confirm": "Are you sure you want to purge this post?", + "pin-modal-expiry": "Expiration Date", + "pin-modal-help": "You can optionally set an expiration date for the pinned topic(s) here. Alternatively, you can leave this field blank to have the topic stay pinned until it is manually unpinned.", + "load_categories": "Loading Categories", "confirm_move": "Move", "confirm_fork": "Fork", diff --git a/public/src/client/category/tools.js b/public/src/client/category/tools.js index 410c295751..e9ae03431c 100644 --- a/public/src/client/category/tools.js +++ b/public/src/client/category/tools.js @@ -17,37 +17,37 @@ define('forum/category/tools', [ handlePinnedTopicSort(); components.get('topic/delete').on('click', function () { - categoryCommand('del', '/state', 'delete', true, onDeletePurgeComplete); + categoryCommand('del', '/state', 'delete', onDeletePurgeComplete); return false; }); components.get('topic/restore').on('click', function () { - categoryCommand('put', '/state', 'restore', true, onDeletePurgeComplete); + categoryCommand('put', '/state', 'restore', onDeletePurgeComplete); return false; }); components.get('topic/purge').on('click', function () { - categoryCommand('del', '', 'purge', true, onDeletePurgeComplete); + categoryCommand('del', '', 'purge', onDeletePurgeComplete); return false; }); components.get('topic/lock').on('click', function () { - categoryCommand('put', '/lock', 'lock', false, onCommandComplete); + categoryCommand('put', '/lock', 'lock', onCommandComplete); return false; }); components.get('topic/unlock').on('click', function () { - categoryCommand('del', '/lock', 'unlock', false, onCommandComplete); + categoryCommand('del', '/lock', 'unlock', onCommandComplete); return false; }); components.get('topic/pin').on('click', function () { - categoryCommand('put', '/pin', 'pin', false, onCommandComplete); + categoryCommand('put', '/pin', 'pin', onCommandComplete); return false; }); components.get('topic/unpin').on('click', function () { - categoryCommand('del', '/pin', 'unpin', false, onCommandComplete); + categoryCommand('del', '/pin', 'unpin', onCommandComplete); return false; }); @@ -123,14 +123,15 @@ define('forum/category/tools', [ socket.on('event:topic_moved', onTopicMoved); }; - function categoryCommand(method, path, command, confirm, onComplete) { + function categoryCommand(method, path, command, onComplete) { if (!onComplete) { onComplete = function () {}; } const tids = topicSelect.getSelectedTids(); + const body = {}; const execute = function (ok) { if (ok) { - Promise.all(tids.map(tid => api[method](`/topics/${tid}${path}`))) + Promise.all(tids.map(tid => api[method](`/topics/${tid}${path}`, body))) .then(onComplete) .catch(app.alertError); } @@ -140,15 +141,59 @@ define('forum/category/tools', [ return app.alertError('[[error:no-topics-selected]]'); } - if (confirm) { - translator.translate('[[topic:thread_tools.' + command + '_confirm]]', function (msg) { - bootbox.confirm(msg, execute); - }); - } else { - execute(true); + switch (command) { + case 'delete': + case 'restore': + case 'purge': + bootbox.confirm(`[[topic:thread_tools.${command}_confirm]]`, execute); + break; + + case 'pin': + requestPinExpiry(body, execute.bind(null, true)); + break; + + default: + execute(true); + break; } } + function requestPinExpiry(body, onSuccess) { + app.parseAndTranslate('modals/set-pin-expiry', {}, function (html) { + const modal = bootbox.dialog({ + title: '[[topic:thread_tools.pin]]', + message: html, + onEscape: true, + size: 'small', + buttons: { + save: { + label: '[[global:save]]', + className: 'btn-primary', + callback: function () { + const expiryEl = modal.get(0).querySelector('#expiry'); + let expiry = expiryEl.value; + + // No expiry set + if (expiry === '') { + return onSuccess(); + } + + // Expiration date set + expiry = new Date(expiry); + + if (expiry && expiry.getTime() > Date.now()) { + body.expiry = expiry.getTime(); + onSuccess(); + } else { + app.alertError('[[error:invalid-date]]'); + } + }, + }, + }, + }); + }); + } + CategoryTools.removeListeners = function () { socket.removeListener('event:topic_deleted', setDeleteState); socket.removeListener('event:topic_restored', setDeleteState); diff --git a/src/categories/topics.js b/src/categories/topics.js index bf396b38bb..49361fdcad 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -134,6 +134,10 @@ module.exports = function (Categories) { }; Categories.getPinnedTids = async function (data) { + if (!Array.isArray(data.cid)) { + data.cid = [data.cid]; + } + if (plugins.hasListeners('filter:categories.getPinnedTids')) { const result = await plugins.fireHook('filter:categories.getPinnedTids', { pinnedTids: [], @@ -142,7 +146,9 @@ module.exports = function (Categories) { return result && result.pinnedTids; } - return await db.getSortedSetRevRange('cid:' + data.cid + ':tids:pinned', data.start, data.stop); + const pinnedSets = data.cid.map(cid => `cid:${cid}:tids:pinned`); + const pinnedTids = await db.getSortedSetRevRange(pinnedSets, data.start, data.stop); + return topics.tools.checkPinExpiry(pinnedTids); }; Categories.modifyTopicsByPrivilege = function (topics, privileges) { diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 7e44b4184f..81b06d01a0 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -38,6 +38,12 @@ Topics.purge = async (req, res) => { Topics.pin = async (req, res) => { await api.topics.pin(req, { tids: [req.params.tid] }); + + // Pin expiry was not available w/ sockets hence not included in api lib method + if (req.body.expiry) { + topics.tools.setPinExpiry(req.params.tid, req.body.expiry, req.uid); + } + helpers.formatApiResponse(200, res); }; diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index b46de2f365..744f83aa66 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -17,7 +17,7 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/state', [...middlewares], controllers.write.topics.restore); setupApiRoute(router, 'delete', '/:tid/state', [...middlewares], controllers.write.topics.delete); - setupApiRoute(router, 'put', '/:tid/pin', [...middlewares], controllers.write.topics.pin); + setupApiRoute(router, 'put', '/:tid/pin', [...middlewares, middleware.assert.topic], controllers.write.topics.pin); setupApiRoute(router, 'delete', '/:tid/pin', [...middlewares], controllers.write.topics.unpin); setupApiRoute(router, 'put', '/:tid/lock', [...middlewares], controllers.write.topics.lock); diff --git a/src/topics/sorted.js b/src/topics/sorted.js index 28844ffee7..6dd7619620 100644 --- a/src/topics/sorted.js +++ b/src/topics/sorted.js @@ -57,18 +57,20 @@ module.exports = function (Topics) { async function getCidTids(params) { const sets = []; - const pinnedSets = []; params.cids.forEach(function (cid) { if (params.sort === 'recent') { sets.push('cid:' + cid + ':tids'); } else { sets.push('cid:' + cid + ':tids' + (params.sort ? ':' + params.sort : '')); } - pinnedSets.push('cid:' + cid + ':tids:pinned'); }); const [tids, pinnedTids] = await Promise.all([ db.getSortedSetRevRange(sets, 0, meta.config.recentMaxTopics - 1), - db.getSortedSetRevRange(pinnedSets, 0, -1), + categories.getPinnedTids({ + cid: params.cids, + start: 0, + stop: -1, + }), ]); return pinnedTids.concat(tids); } diff --git a/src/topics/tools.js b/src/topics/tools.js index 269538599e..577614bd94 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const db = require('../database'); +const topics = require('.'); const categories = require('../categories'); const user = require('../user'); const plugins = require('../plugins'); @@ -108,13 +109,44 @@ module.exports = function (Topics) { return await togglePin(tid, uid, false); }; + topicTools.setPinExpiry = async (tid, expiry, uid) => { + if (isNaN(parseInt(expiry, 10)) || expiry <= Date.now()) { + throw new Error('[[error:invalid-data]]'); + } + + const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']); + const isAdminOrMod = await privileges.categories.isAdminOrMod(topicData.cid, uid); + if (!isAdminOrMod) { + throw new Error('[[error:no-privileges]]'); + } + + await Topics.setTopicField(tid, 'pinExpiry', expiry); + plugins.fireHook('action:topic.setPinExpiry', { topic: _.clone(topicData), uid: uid }); + }; + + topicTools.checkPinExpiry = async (tids) => { + const expiry = (await topics.getTopicsFields(tids, ['pinExpiry'])).map(obj => obj.pinExpiry); + const now = Date.now(); + + tids = await Promise.all(tids.map(async (tid, idx) => { + if (expiry[idx] && parseInt(expiry[idx], 10) <= now) { + await togglePin(tid, 'system', false); + return null; + } + + return tid; + })); + + return tids.filter(Boolean); + }; + async function togglePin(tid, uid, pin) { const topicData = await Topics.getTopicData(tid); if (!topicData) { throw new Error('[[error:no-topic]]'); } - const isAdminOrMod = await privileges.categories.isAdminOrMod(topicData.cid, uid); - if (!isAdminOrMod) { + + if (uid !== 'system' && !await privileges.topics.can('moderate', tid, uid)) { throw new Error('[[error:no-privileges]]'); } @@ -130,6 +162,7 @@ module.exports = function (Topics) { ], tid)); } else { promises.push(db.sortedSetRemove('cid:' + topicData.cid + ':tids:pinned', tid)); + promises.push(Topics.deleteTopicField(tid, 'pinExpiry')); promises.push(db.sortedSetAddBulk([ ['cid:' + topicData.cid + ':tids', topicData.lastposttime, tid], ['cid:' + topicData.cid + ':tids:posts', topicData.postcount, tid], @@ -142,7 +175,7 @@ module.exports = function (Topics) { topicData.isPinned = pin; // deprecate in v2.0 topicData.pinned = pin; - plugins.fireHook('action:topic.pin', { topic: _.clone(topicData), uid: uid }); + plugins.fireHook('action:topic.pin', { topic: _.clone(topicData), uid }); return topicData; } diff --git a/src/user/jobs.js b/src/user/jobs.js index 3ba4a2db13..d170bfec31 100644 --- a/src/user/jobs.js +++ b/src/user/jobs.js @@ -9,7 +9,7 @@ var jobs = {}; module.exports = function (User) { User.startJobs = function () { - winston.verbose('[user/jobs] (Re-)starting user jobs...'); + winston.verbose('[user/jobs] (Re-)starting jobs...'); var started = 0; var digestHour = meta.config.digestHour; diff --git a/src/views/modals/set-pin-expiry.tpl b/src/views/modals/set-pin-expiry.tpl new file mode 100644 index 0000000000..5c60737a88 --- /dev/null +++ b/src/views/modals/set-pin-expiry.tpl @@ -0,0 +1,5 @@ +
+ + +

[[topic:pin-modal-help]]

+
\ No newline at end of file