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
v1.18.x
Julian Lam 4 years ago committed by GitHub
parent e5d94d9096
commit 046d0b1637
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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;

@ -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>
Loading…
Cancel
Save