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 composer:showHelpTab }}} + + {{{ end }}} +
+
+ diff --git a/nodebb-theme-harmony/scss/topic.scss b/nodebb-theme-harmony/scss/topic.scss index 94f10bc36..56f68ebf0 100644 --- a/nodebb-theme-harmony/scss/topic.scss +++ b/nodebb-theme-harmony/scss/topic.scss @@ -136,4 +136,178 @@ body.template-topic { } } } + body.template-topic { + .breadcrumb .breadcrumb-item:last-child { + display: none; + } + .topic { + .posts-container { + max-width: 960px; + width: 960px; + } + + .posts { + // fixes code blocks pushing content out on mobile + @include media-breakpoint-down(md) { + max-width: calc(100vw - $grid-gutter-width); + } + + &.timeline { + @include timeline-style; + } + + .post-header { + font-size: 0.8125rem; + line-height: 1.25rem; + + .bookmarked { + transition: $transition-fade; + } + } + + > [component="post"] > [component="post/footer"] { + margin-left: calc($spacer * 2.5); + } + + [component="post"] { + &.selected .post-container { + background-color: mix($body-bg, $body-color, 90%); + } + &.deleted .post-container .content { opacity: .65; } + + [component="post/content"] { + @include fix-lists(); + + > blockquote { + > blockquote { + > *:not(.blockquote) { + display: none; + } + } + + > blockquote.uncollapsed { + > *:not(.blockquote) { + display: block; + } + } + } + + @include media-breakpoint-up(lg) { + table { // text-break breaks table formatting + word-break: initial!important; + } + } + } + } + + [component="post/upvote"], [component="post/downvote"] { + &.upvoted, &.downvoted { + background-color: var(--btn-ghost-active-color); + + &:hover { + background-color: var(--btn-ghost-hover-color); + } + } + } + + // ------------------- + // Post Reactions Styling + // ------------------- + [component="post/reactions"] { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + padding-left: calc($spacer * 2.5); + + button { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 5px; + border-radius: 50%; + transition: background-color 0.2s ease-in-out; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + &.reacted { + color: var(--primary-color); + font-weight: bold; + } + } + + .reaction-count { + font-size: 0.85rem; + color: var(--text-muted); + } + } + } + } + + .quick-reply { + @include topic-avatars(); + } + + [component="post/replies/container"] { + .icon { + display: none !important; + } + + .post-header .icon { + display: initial !important; + + .status { + display: none; + } + } + + .timeline-event { + display: none !important; + } + + [component="post"] { + padding-top: 0 !important; + padding-bottom: $spacer; + &:last-of-type { + padding-bottom: 0; + .post-footer { + border-bottom: none !important; + } + } + } + } + + [component="topic/thumb/list"] { + height: calc($font-size-base * 4); + } + } + + @include media-breakpoint-up(sm) { + body.template-topic { + .topic .posts { + [component="post"] { + [component="post/actions"] { + opacity: 0; + transition: $transition-fade; + + &:has([aria-expanded="true"]) { + opacity: 1; + } + } + [component="post/actions"]:focus-within { + opacity: 1; + } + &:hover { + > .post-footer > [component="post/actions"] { + opacity: 1; + } + } + } + } + } + } + } \ No newline at end of file diff --git a/nodebb-theme-harmony/templates/account/posts.tpl b/nodebb-theme-harmony/templates/account/posts.tpl index d8934a07b..ecd0a5801 100644 --- a/nodebb-theme-harmony/templates/account/posts.tpl +++ b/nodebb-theme-harmony/templates/account/posts.tpl @@ -32,4 +32,9 @@ {{{ end }}} + + Reactions + + + diff --git a/nodebb-theme-harmony/templates/partials/topic/post.tpl b/nodebb-theme-harmony/templates/partials/topic/post.tpl index e2e92ad70..aa807291e 100644 --- a/nodebb-theme-harmony/templates/partials/topic/post.tpl +++ b/nodebb-theme-harmony/templates/partials/topic/post.tpl @@ -93,6 +93,28 @@
+ + +
+ + {{{ 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', {