diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 2c507278f3..b45f55ce19 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -211,5 +211,7 @@ "no-connection": "There seems to be a problem with your internet connection", "socket-reconnect-failed": "Unable to reach the server at this time. Click here to try again, or try again later", - "plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP" + "plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP", + + "topic-event-unrecognized": "Topic event '%1' unrecognized" } diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 3de03e6293..759ca53e51 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -22,8 +22,10 @@ "login-to-view": "🔒 Log in to view", "edit": "Edit", "delete": "Delete", + "deleted": "Deleted", "purge": "Purge", "restore": "Restore", + "restored": "Restored", "move": "Move", "change-owner": "Change Owner", "fork": "Fork", @@ -31,8 +33,10 @@ "share": "Share", "tools": "Tools", "locked": "Locked", + "unlocked": "Unlocked", "pinned": "Pinned", "pinned-with-expiry": "Pinned until %1", + "unpinned": "Unpinned", "moved": "Moved", "moved-from": "Moved from %1", "copy-ip": "Copy IP", diff --git a/public/openapi/read/topic/topic_id.yaml b/public/openapi/read/topic/topic_id.yaml index 2dab408fa6..0513d711af 100644 --- a/public/openapi/read/topic/topic_id.yaml +++ b/public/openapi/read/topic/topic_id.yaml @@ -242,6 +242,19 @@ get: flagId: type: number description: The flag identifier, if this particular post has been flagged before + events: + type: array + items: + type: object + properties: + type: + type: string + id: + type: number + timestamp: + type: number + timestampISO: + type: string category: $ref: ../../components/schemas/CategoryObject.yaml#/CategoryObject tagWhitelist: diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js index e0dd634049..4823a43f21 100644 --- a/public/src/client/topic/posts.js +++ b/public/src/client/topic/posts.js @@ -271,9 +271,38 @@ define('forum/topic/posts', [ posts.find('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); Posts.addBlockquoteEllipses(posts); hidePostToolsForDeletedPosts(posts); + addTopicEvents(); addNecroPostMessage(); }; + function addTopicEvents() { + if (config.topicPostSort !== 'newest_to_oldest' && config.topicPostSort !== 'oldest_to_newest') { + return; + } + + // TODO: Handle oldest_to_newest + const postTimestamps = ajaxify.data.posts.map(post => post.timestamp); + ajaxify.data.events.forEach((event) => { + const beforeIdx = postTimestamps.findIndex(timestamp => timestamp > event.timestamp); + let postEl; + if (beforeIdx > -1) { + postEl = document.querySelector(`[component="post"][data-pid="${ajaxify.data.posts[beforeIdx].pid}"]`); + } + + app.parseAndTranslate('partials/topic/event', event, function (html) { + html = html.get(0); + + if (postEl) { + document.querySelector('[component="topic"]').insertBefore(html, postEl); + } else { + document.querySelector('[component="topic"]').append(html); + } + + $(html).find('.timeago').timeago(); + }); + }); + } + function addNecroPostMessage() { var necroThreshold = ajaxify.data.necroThreshold * 24 * 60 * 60 * 1000; if (!necroThreshold || (config.topicPostSort !== 'newest_to_oldest' && config.topicPostSort !== 'oldest_to_newest')) { diff --git a/src/api/helpers.js b/src/api/helpers.js index 2e82cd2399..c5e7c1d727 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -63,6 +63,12 @@ exports.doTopicAction = async function (action, event, caller, { tids }) { }; async function logTopicAction(action, req, tid, title) { + // No 'purge' topic event (since topic is now gone) + if (action !== 'purge') { + await topics.events.log(tid, { type: action }); + } + + // Only log certain actions to system event log var actionsToLog = ['delete', 'restore', 'purge']; if (!actionsToLog.includes(action)) { return; diff --git a/src/topics/events.js b/src/topics/events.js new file mode 100644 index 0000000000..7db04751f4 --- /dev/null +++ b/src/topics/events.js @@ -0,0 +1,90 @@ +'use strict'; + +const db = require('../database'); +const plugins = require('../plugins'); + +const Events = module.exports; + +Events._types = { + pin: { + icon: 'fa-thumb-tack', + text: '[[topic:pinned]]', + }, + pin_expiry: { + icon: 'fa-thumb-tack', + text: '[[topic:pinned-with-expiry]]', + }, + unpin: { + icon: 'fa-thumb-tack', + text: '[[topic:unpinned]]', + }, + lock: { + icon: 'fa-lock', + text: '[[topic:locked]]', + }, + unlock: { + icon: 'fa-unlock', + text: '[[topic:unlocked]]', + }, + delete: { + icon: 'fa-trash', + text: '[[topic:deleted]]', + }, + restore: { + icon: 'fa-trash-o', + text: '[[topic:restored]]', + }, +}; +Events._ready = false; + +Events.init = async () => { + if (!Events._ready) { + // Allow plugins to define additional topic event types + const { types } = await plugins.hooks.fire('filter:topicEvents.init', { types: Events._types }); + Events._types = types; + Events._ready = true; + } +}; + +Events.get = async (tid) => { + await Events.init(); + const topics = require('.'); + + if (!await topics.exists(tid)) { + throw new Error('[[error:no-topic]]'); + } + + const eventIds = await db.getSortedSetRangeWithScores(`topic:${tid}:events`, 0, -1); + const keys = eventIds.map(obj => `topicEvent:${obj.value}`); + const timestamps = eventIds.map(obj => obj.score); + const events = await db.getObjects(keys); + events.forEach((event, idx) => { + event.id = parseInt(eventIds[idx].value, 10); + event.timestamp = timestamps[idx]; + event.timestampISO = new Date(timestamps[idx]).toISOString(); + + Object.assign(event, Events._types[event.type]); + }); + + return events; +}; + +Events.log = async (tid, payload) => { + await Events.init(); + const topics = require('.'); + const { type } = payload; + const now = Date.now(); + + if (!Events._types.hasOwnProperty(type)) { + throw new Error(`[[error:topic-event-unrecognized, ${type}]]`); + } else if (!await topics.exists(tid)) { + throw new Error('[[error:no-topic]]'); + } + + const eventId = await db.incrObjectField('global', 'nextTopicEventId'); + + await Promise.all([ + db.setObject(`topicEvent:${eventId}`, payload), + db.sortedSetAdd(`topic:${tid}:events`, now, eventId), + ]); +}; diff --git a/src/topics/index.js b/src/topics/index.js index 7779b1bb61..a5a947e65b 100644 --- a/src/topics/index.js +++ b/src/topics/index.js @@ -33,6 +33,7 @@ require('./tools')(Topics); Topics.thumbs = require('./thumbs'); require('./bookmarks')(Topics); require('./merge')(Topics); +Topics.events = require('./events'); Topics.exists = async function (tid) { return await db.exists('topic:' + tid); @@ -171,6 +172,7 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev merger, related, thumbs, + events, ] = await Promise.all([ getMainPostAndReplies(topicData, set, uid, start, stop, reverse), categories.getCategoryData(topicData.cid), @@ -183,11 +185,13 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev getMerger(topicData), getRelated(topicData, uid), Topics.thumbs.get(topicData.tid), + Topics.events.get(topicData.tid), ]); topicData.thumbs = thumbs; restoreThumbValue(topicData); topicData.posts = posts; + topicData.events = events; topicData.category = category; topicData.tagWhitelist = tagWhitelist[0]; topicData.minTags = category.minTags;