diff --git a/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl
new file mode 100644
index 000000000..941f06f2e
--- /dev/null
+++ b/node_modules/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl
@@ -0,0 +1,75 @@
+
+
+
+
+
+ {{{ if posts.reactions.length }}}
+
+ {{{ each posts.reactions }}}
+
+ {{{ end }}}
+
+ {{{ end }}}
+
+
+
+
+
diff --git a/nodebb-theme-harmony/templates/partials/topic/reactions.tpl b/nodebb-theme-harmony/templates/partials/topic/reactions.tpl
index c8f127e78..808d154ec 100644
--- a/nodebb-theme-harmony/templates/partials/topic/reactions.tpl
+++ b/nodebb-theme-harmony/templates/partials/topic/reactions.tpl
@@ -1 +1,10 @@
-
\ No newline at end of file
+
+
+ {{#if posts.reactions.length}}
+ {{#each posts.reactions}}
+
+ {{this.emoji}} {{this.count}}
+
+ {{/each}}
+ {{/if}}
+
diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js
index c800704b7..5f686fc16 100644
--- a/public/src/client/topic/posts.js
+++ b/public/src/client/topic/posts.js
@@ -36,6 +36,11 @@ define('forum/topic/posts', [
Posts.modifyPostsByPrivileges(data.posts);
+ // Ensure reactions data is processed when a new post appears
+ data.posts.forEach((post) => {
+ post.reactions = post.reactions || [];
+ });
+
updatePostCounts(data.posts);
updateNavigatorLastPostTimestamp(data.posts[0]);
updatePostIndices(data.posts);
@@ -293,6 +298,11 @@ define('forum/topic/posts', [
Posts.addBlockquoteEllipses(posts);
hidePostToolsForDeletedPosts(posts);
await addNecroPostMessage();
+
+ // Attach reaction event listeners
+ posts.find('[component="post/reactions"]').each(function () {
+ attachReactionEvents($(this));
+ });
};
Posts.addTopicEvents = async function (events) {
@@ -448,3 +458,32 @@ define('forum/topic/posts', [
return Posts;
});
+function attachReactionEvents($reactionContainer) {
+ $reactionContainer.find('[component="post/reaction"]').off('click').on('click', async function () {
+ const $this = $(this);
+ const reaction = $this.attr('data-reaction');
+ const pid = $this.closest('[component="post"]').attr('data-pid');
+
+ try {
+ const response = await fetch(`${config.relative_path}/api/post/${pid}/reaction`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-csrf-token': config.csrf_token,
+ },
+ body: JSON.stringify({ reaction }),
+ });
+
+ if (!response.ok) throw new Error('Failed to react');
+
+ const result = await response.json();
+ if (result.success) {
+ // Update UI with new reaction count
+ const countElement = $this.find('.reaction-count');
+ countElement.text(parseInt(countElement.text(), 10) + 1);
+ }
+ } catch (error) {
+ console.error('Reaction failed:', error);
+ }
+ });
+}
diff --git a/public/src/modules/api.js b/public/src/modules/api.js
index fcee6b94b..1e014277b 100644
--- a/public/src/modules/api.js
+++ b/public/src/modules/api.js
@@ -142,3 +142,14 @@ export function del(route, data, onSuccess) {
},
}, onSuccess);
}
+
+export function postReaction(postId, reaction, onSuccess) {
+ return call({
+ url: `/reactions`,
+ method: 'POST',
+ data: { postId, reaction },
+ headers: {
+ 'x-csrf-token': config.csrf_token,
+ },
+ }, onSuccess);
+}
diff --git a/src/api/posts.js b/src/api/posts.js
index 4e3917a00..b125fb4c0 100644
--- a/src/api/posts.js
+++ b/src/api/posts.js
@@ -164,6 +164,50 @@ postsAPI.delete = async function (caller, data) {
});
};
+postsAPI.react = async function (caller, { pid, emoji }) {
+ if (!utils.isNumber(pid) || !emoji) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ if (!caller.uid) {
+ throw new Error('[[error:not-logged-in]]');
+ }
+
+ const canReact = await privileges.posts.can('topics:read', pid, caller.uid);
+ if (!canReact) {
+ throw new Error('[[error:no-privileges]]');
+ }
+
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ reactions[emoji] = (reactions[emoji] || 0) + 1;
+
+ await db.setObject(`post:${pid}:reactions`, reactions);
+ websockets.in(`topic_${pid}`).emit('event:reaction_updated', { pid, reactions });
+
+ return { reactions };
+};
+
+postsAPI.unreact = async function (caller, { pid, emoji }) {
+ if (!utils.isNumber(pid) || !emoji) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ if (!caller.uid) {
+ throw new Error('[[error:not-logged-in]]');
+ }
+
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ if (reactions[emoji] && reactions[emoji] > 0) {
+ reactions[emoji] -= 1;
+ if (reactions[emoji] === 0) {
+ delete reactions[emoji];
+ }
+ await db.setObject(`post:${pid}:reactions`, reactions);
+ }
+
+ websockets.in(`topic_${pid}`).emit('event:reaction_updated', { pid, reactions });
+
+ return { reactions };
+};
+
postsAPI.restore = async function (caller, data) {
await deleteOrRestore(caller, data, {
command: 'restore',
@@ -509,6 +553,73 @@ postsAPI.getReplies = async (caller, { pid }) => {
postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index]));
postData = postData.filter((postData, index) => postData && postPrivileges[index].read);
postData = await user.blocks.filter(uid, postData);
-
+ postsAPI.getReactions = async function (caller, { pid }) {
+ if (!utils.isNumber(pid)) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ const canRead = await privileges.posts.can('topics:read', pid, caller.uid);
+ if (!canRead) {
+ return null;
+ }
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ return { reactions };
+ };
+ postsAPI.getReactions = async function (caller, { pid }) {
+ if (!utils.isNumber(pid)) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ const canRead = await privileges.posts.can('topics:read', pid, caller.uid);
+ if (!canRead) {
+ return null;
+ }
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ return { reactions };
+ };
+ postsAPI.addReaction = async function (caller, { pid, emoji }) {
+ if (!utils.isNumber(pid) || !emoji) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ if (!caller.uid) {
+ throw new Error('[[error:not-logged-in]]');
+ }
+
+ const canReact = await privileges.posts.can('topics:read', pid, caller.uid);
+ if (!canReact) {
+ throw new Error('[[error:no-privileges]]');
+ }
+
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ reactions[emoji] = (reactions[emoji] || 0) + 1;
+
+ await db.setObject(`post:${pid}:reactions`, reactions);
+
+ websockets.in(`topic_${pid}`).emit('event:reaction_updated', { pid, reactions });
+
+ return { reactions };
+ };
+ postsAPI.removeReaction = async function (caller, { pid, emoji }) {
+ if (!utils.isNumber(pid) || !emoji) {
+ throw new Error('[[error:invalid-data]]');
+ }
+ if (!caller.uid) {
+ throw new Error('[[error:not-logged-in]]');
+ }
+ const canReact = await privileges.posts.can('topics:read', pid, caller.uid);
+ if (!canReact) {
+ throw new Error('[[error:no-privileges]]');
+ }
+ const reactions = await db.getObject(`post:${pid}:reactions`) || {};
+ if (reactions[emoji] && reactions[emoji] > 0) {
+ reactions[emoji] -= 1;
+ if (reactions[emoji] === 0) {
+ delete reactions[emoji];
+ }
+ await db.setObject(`post:${pid}:reactions`, reactions);
+ }
+
+ websockets.in(`topic_${pid}`).emit('event:reaction_updated', { pid, reactions });
+
+ return { reactions };
+ };
return postData;
};
diff --git a/src/routes/api.js b/src/routes/api.js
index 0fe575a32..781428964 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -32,7 +32,6 @@ module.exports = function (app, middleware, controllers) {
middleware.uploads.ratelimit,
middleware.applyCSRF,
];
-
router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost));
router.post('/user/:userslug/uploadpicture', [
...middlewares,
@@ -42,4 +41,31 @@ module.exports = function (app, middleware, controllers) {
middleware.canViewUsers,
middleware.checkAccountPermissions,
], helpers.tryRoute(controllers.accounts.edit.uploadPicture));
+ const postsAPI = require('../api/posts');
+
+ router.get('/post/:pid/reactions', async (req, res) => {
+ try {
+ const reactions = await postsAPI.getReactions(req.user, req.params);
+ res.json(reactions);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+ router.post('/post/:pid/reactions', async (req, res) => {
+ try {
+ const reactions = await postsAPI.addReaction(req.user, req.body);
+ res.json(reactions);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+ });
+ router.delete('/post/:pid/reactions', async (req, res) => {
+ try {
+ const reactions = await postsAPI.removeReaction(req.user, req.body);
+ res.json(reactions);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+ });
};
diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js
index 7286b79e8..b75ded91c 100644
--- a/src/socket.io/helpers.js
+++ b/src/socket.io/helpers.js
@@ -59,7 +59,22 @@ async function notifyUids(uid, uids, type, result) {
uidFrom: uid,
post: postToUid,
});
-
+ websockets.on('reaction:add', async function (socket, data) {
+ try {
+ const reactions = await postsAPI.addReaction(socket.user, data);
+ websockets.in(`topic_${data.pid}`).emit('event:reaction_updated', { pid: data.pid, reactions });
+ } catch (err) {
+ socket.emit('error', err.message);
+ }
+ });
+ websockets.on('reaction:remove', async function (socket, data) {
+ try {
+ const reactions = await postsAPI.removeReaction(socket.user, data);
+ websockets.in(`topic_${data.pid}`).emit('event:reaction_updated', { pid: data.pid, reactions });
+ } catch (err) {
+ socket.emit('error', err.message);
+ }
+ });
websockets.in(`uid_${toUid}`).emit('event:new_post', copyResult);
if (copyResult.topic && type === 'newTopic') {
await plugins.hooks.fire('filter:sockets.sendNewTopicToUid', {