mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 08:36:12 +01:00
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
src/views/modals/set-pin-expiry.tpl
Normal file
5
src/views/modals/set-pin-expiry.tpl
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="form-group">
|
||||
<label for="expiry">[[topic:pin-modal-expiry]]</label>
|
||||
<input id="expiry" type="date" class="form-control" />
|
||||
<p class="help-block">[[topic:pin-modal-help]]</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user