diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 11f9d2547f..0b431d12ba 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -23,6 +23,7 @@ "login-to-view": "🔒 Log in to view", "edit": "Edit", "delete": "Delete", + "delete-event": "Delete Event", "purge": "Purge", "restore": "Restore", "move": "Move", @@ -47,6 +48,7 @@ "unpinned-by": "Unpinned by", "deleted-by": "Deleted by", "restored-by": "Restored by", + "moved-from-by": "Moved from %1 by", "queued-by": "Post queued for approval →", "bookmark_instructions" : "Click here to return to the last read post in this thread.", diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 8c5fb9c4eb..abad800516 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -112,6 +112,8 @@ paths: $ref: 'write/topics/tid/thumbs/order.yaml' /topics/{tid}/events: $ref: 'write/topics/tid/events.yaml' + /topics/{tid}/events/{eventId}: + $ref: 'write/topics/tid/events/eventId.yaml' /posts/{pid}: $ref: 'write/posts/pid.yaml' /posts/{pid}/state: diff --git a/public/openapi/write/topics/tid/events/eventId.yaml b/public/openapi/write/topics/tid/events/eventId.yaml new file mode 100644 index 0000000000..4b0de4ad53 --- /dev/null +++ b/public/openapi/write/topics/tid/events/eventId.yaml @@ -0,0 +1,33 @@ +delete: + tags: + - topics + summary: Delete a topic event + description: This operation deletes a single topic event from the topic + parameters: + - in: path + name: tid + schema: + type: string + required: true + description: a valid topic id + example: 1 + - in: path + name: eventId + schema: + type: string + required: true + description: a valid topic event id + example: 1 + responses: + '200': + description: Topic event successfully deleted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js index 102e353740..c069332fa3 100644 --- a/public/src/client/topic/posts.js +++ b/public/src/client/topic/posts.js @@ -279,8 +279,7 @@ define('forum/topic/posts', [ posts.find('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); Posts.addBlockquoteEllipses(posts); hidePostToolsForDeletedPosts(posts); - Posts.addTopicEvents(); - addNecroPostMessage(); + addNecroPostMessage(Posts.addTopicEvents); }; Posts.addTopicEvents = function (events) { @@ -316,6 +315,7 @@ define('forum/topic/posts', [ } return new Promise((resolve) => { + event.isAdminOrMod = ajaxify.data.privileges.isAdminOrMod; app.parseAndTranslate('partials/topic/event', event, function (html) { html = html.get(0); @@ -333,14 +333,15 @@ define('forum/topic/posts', [ }); }; - function addNecroPostMessage() { + function addNecroPostMessage(callback) { var necroThreshold = ajaxify.data.necroThreshold * 24 * 60 * 60 * 1000; if (!necroThreshold || (config.topicPostSort !== 'newest_to_oldest' && config.topicPostSort !== 'oldest_to_newest')) { - return; + return callback && callback(); } - $('[component="post"]').each(function () { - var post = $(this); + var postEls = $('[component="post"]').toArray(); + Promise.all(postEls.map(function (post) { + post = $(post); var prev = post.prev('[component="post"]'); if (post.is(':has(.necro-post)') || !prev.length) { return; @@ -350,27 +351,34 @@ define('forum/topic/posts', [ } var diff = post.attr('data-timestamp') - prev.attr('data-timestamp'); - if (Math.abs(diff) >= necroThreshold) { - var suffixAgo = $.timeago.settings.strings.suffixAgo; - var prefixAgo = $.timeago.settings.strings.prefixAgo; - var suffixFromNow = $.timeago.settings.strings.suffixFromNow; - var prefixFromNow = $.timeago.settings.strings.prefixFromNow; - - $.timeago.settings.strings.suffixAgo = ''; - $.timeago.settings.strings.prefixAgo = ''; - $.timeago.settings.strings.suffixFromNow = ''; - $.timeago.settings.strings.prefixFromNow = ''; - - var translationText = (diff > 0 ? '[[topic:timeago_later,' : '[[topic:timeago_earlier,') + $.timeago.inWords(diff) + ']]'; - - $.timeago.settings.strings.suffixAgo = suffixAgo; - $.timeago.settings.strings.prefixAgo = prefixAgo; - $.timeago.settings.strings.suffixFromNow = suffixFromNow; - $.timeago.settings.strings.prefixFromNow = prefixFromNow; - app.parseAndTranslate('partials/topic/necro-post', { text: translationText }, function (html) { - html.insertBefore(post); - }); - } + return new Promise(function (resolve) { + if (Math.abs(diff) >= necroThreshold) { + var suffixAgo = $.timeago.settings.strings.suffixAgo; + var prefixAgo = $.timeago.settings.strings.prefixAgo; + var suffixFromNow = $.timeago.settings.strings.suffixFromNow; + var prefixFromNow = $.timeago.settings.strings.prefixFromNow; + + $.timeago.settings.strings.suffixAgo = ''; + $.timeago.settings.strings.prefixAgo = ''; + $.timeago.settings.strings.suffixFromNow = ''; + $.timeago.settings.strings.prefixFromNow = ''; + + var translationText = (diff > 0 ? '[[topic:timeago_later,' : '[[topic:timeago_earlier,') + $.timeago.inWords(diff) + ']]'; + + $.timeago.settings.strings.suffixAgo = suffixAgo; + $.timeago.settings.strings.prefixAgo = prefixAgo; + $.timeago.settings.strings.suffixFromNow = suffixFromNow; + $.timeago.settings.strings.prefixFromNow = prefixFromNow; + app.parseAndTranslate('partials/topic/necro-post', { text: translationText }, function (html) { + html.insertBefore(post); + resolve(); + }); + } else { + resolve(); + } + }); + })).then(function () { + callback && callback(); }); } diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index d0616ec773..1ba9efeae6 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -49,6 +49,17 @@ define('forum/topic/threadTools', [ return false; }); + topicContainer.on('click', '[component="topic/event/delete"]', function () { + const eventId = $(this).attr('data-topic-event-id'); + const eventEl = $(this).parents('[component="topic/event"]'); + api.del(`/topics/${tid}/events/${eventId}`, {}) + .then(function () { + eventEl.remove(); + }) + .catch(app.alertError); + return false; + }); + // todo: should also use topicCommand, but no write api call exists for this yet topicContainer.on('click', '[component="topic/mark-unread"]', function () { socket.emit('topics.markUnread', tid, function (err) { diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 19b3eb3172..3cbef43311 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -211,3 +211,11 @@ Topics.getEvents = async (req, res) => { helpers.formatApiResponse(200, res, await topics.events.get(req.params.tid)); }; + +Topics.deleteEvent = async (req, res) => { + if (!await privileges.topics.isAdminOrMod(req.params.tid, req.uid)) { + return helpers.formatApiResponse(403, res); + } + await topics.events.purge(req.params.tid, [req.params.eventId]); + helpers.formatApiResponse(200, res); +}; diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 9f7f009cef..b62124f95b 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -42,6 +42,7 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); setupApiRoute(router, 'get', '/:tid/events', [middleware.assert.topic], controllers.write.topics.getEvents); + setupApiRoute(router, 'delete', '/:tid/events/:eventId', [middleware.assert.topic], controllers.write.topics.deleteEvent); return router; }; diff --git a/src/topics/events.js b/src/topics/events.js index 68dc825011..0e9e64182b 100644 --- a/src/topics/events.js +++ b/src/topics/events.js @@ -1,8 +1,10 @@ 'use strict'; +const _ = require('lodash'); const db = require('../database'); const user = require('../user'); const posts = require('../posts'); +const categories = require('../categories'); const plugins = require('../plugins'); const Events = module.exports; @@ -42,6 +44,10 @@ Events._types = { icon: 'fa-trash-o', text: '[[topic:restored-by]]', }, + move: { + icon: 'fa-arrow-circle-right', + // text: '[[topic:moved-from-by]]', + }, 'post-queue': { icon: 'fa-history', text: '[[topic:queued-by]]', @@ -83,6 +89,12 @@ async function getUserInfo(uids) { return userMap; } +async function getCategoryInfo(cids) { + const uniqCids = _.uniq(cids); + const catData = await categories.getCategoriesFields(uniqCids, ['name', 'slug', 'icon', 'color', 'bgColor']); + return _.zipObject(uniqCids, catData); +} + async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { // Add posts from post queue const isPrivileged = await user.isPrivileged(uid); @@ -98,7 +110,10 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { }); } - const users = await getUserInfo(events.map(event => event.uid).filter(Boolean)); + const [users, fromCategories] = await Promise.all([ + getUserInfo(events.map(event => event.uid).filter(Boolean)), + getCategoryInfo(events.map(event => event.fromCid).filter(Boolean)), + ]); // Remove events whose types no longer exist (e.g. plugin uninstalled) events = events.filter(event => Events._types.hasOwnProperty(event.type)); @@ -111,6 +126,10 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { if (event.hasOwnProperty('uid')) { event.user = users.get(event.uid === 'system' ? 'system' : parseInt(event.uid, 10)); } + if (event.hasOwnProperty('fromCid')) { + event.fromCategory = fromCategories[event.fromCid]; + event.text = `[[topic:moved-from-by, ${event.fromCategory.name}]]`; + } Object.assign(event, Events._types[event.type]); }); @@ -149,11 +168,19 @@ Events.log = async (tid, payload) => { return events; }; -Events.purge = async (tid) => { - // Should only be called on topic purge - const keys = [`topic:${tid}:events`]; - const eventIds = await db.getSortedSetRange(keys[0], 0, -1); - keys.push(...eventIds.map(id => `topicEvent:${id}`)); - - await db.deleteAll(keys); +Events.purge = async (tid, eventIds = []) => { + if (eventIds.length) { + const isTopicEvent = await db.isSortedSetMembers(`topic:${tid}:events`, eventIds); + eventIds = eventIds.filter((id, index) => isTopicEvent[index]); + await Promise.all([ + db.sortedSetRemove(`topic:${tid}:events`, eventIds), + db.deleteAll(eventIds.map(id => `topicEvent:${id}`)), + ]); + } else { + const keys = [`topic:${tid}:events`]; + const eventIds = await db.getSortedSetRange(keys[0], 0, -1); + keys.push(...eventIds.map(id => `topicEvent:${id}`)); + + await db.deleteAll(keys); + } }; diff --git a/src/topics/tools.js b/src/topics/tools.js index 5bc7554407..1fe032fe13 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -268,6 +268,7 @@ module.exports = function (Topics) { oldCid: oldCid, }), Topics.updateCategoryTagsCount([oldCid, cid], tags), + Topics.events.log(tid, { type: 'move', uid: data.uid, fromCid: oldCid }), ]); const hookData = _.clone(data); hookData.fromCid = oldCid;