diff --git a/.eslintrc.json b/.eslintrc.json index 97ed9297b..ccd26984c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -122,6 +122,7 @@ "Activities": true, "Attachments": true, "Boards": true, + "CardCommentReactions": true, "CardComments": true, "DatePicker": true, "Cards": true, diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index 38825ab5d..63d8d9222 100644 --- a/client/components/activities/activities.jade +++ b/client/components/activities/activities.jade @@ -21,6 +21,22 @@ template(name="editOrDeleteComment") = ' - ' a.js-delete-comment {{_ "delete"}} +template(name="commentReactions") + .reactions + each reaction in reactions + span.reaction(class="{{#if isSelected reaction.userIds}}selected{{/if}}" data-codepoint="#{reaction.reactionCodepoint}" title="{{userNames reaction.userIds}}") + span.reaction-codepoint !{reaction.reactionCodepoint} + span.reaction-count #{reaction.userIds.length} + if (currentUser.isBoardMember) + a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}") + i.fa.fa-smile-o + i.fa.fa-plus + +template(name="addReactionPopup") + .reactions-popup + each codepoint in codepoints + span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint} + template(name="activity") .activity +userAvatar(userId=activity.user._id) @@ -124,6 +140,7 @@ template(name="activity") .activity-comment +viewer = activity.comment.text + +commentReactions(reactions=activity.comment.reactions commentId=activity.comment._id) span(title=activity.createdAt).activity-meta {{ moment activity.createdAt }} if($eq currentUser._id activity.comment.userId) +editOrDeleteComment @@ -150,20 +167,20 @@ template(name="activity") if($eq activity.activityType 'a-startAt') | {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}. - + if($eq activity.activityType 'a-dueAt') | {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}. if($eq activity.activityType 'a-endAt') | {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}. - + if($eq mode 'board') if($eq activity.activityType 'a-receivedAt') | {{{_ 'activity-receivedDate' (sanitize receivedDate) cardLink}}}. if($eq activity.activityType 'a-startAt') | {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}. - + if($eq activity.activityType 'a-dueAt') | {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}. diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index fa2628600..91bedba97 100644 --- a/client/components/activities/activities.js +++ b/client/components/activities/activities.js @@ -240,6 +240,59 @@ Template.activity.helpers({ }, }); +Template.commentReactions.events({ + 'click .reaction'(event) { + if (Meteor.user().isBoardMember()) { + const codepoint = event.currentTarget.dataset['codepoint']; + const commentId = Template.instance().data.commentId; + const cardComment = CardComments.findOne({_id: commentId}); + cardComment.toggleReaction(codepoint); + } + }, + 'click .open-comment-reaction-popup': Popup.open('addReaction'), +}) + +Template.addReactionPopup.events({ + 'click .add-comment-reaction'(event) { + if (Meteor.user().isBoardMember()) { + const codepoint = event.currentTarget.dataset['codepoint']; + const commentId = Template.instance().data.commentId; + const cardComment = CardComments.findOne({_id: commentId}); + cardComment.toggleReaction(codepoint); + } + Popup.close(); + }, +}) + +Template.addReactionPopup.helpers({ + codepoints() { + return [ + '👍', + '👎', + '👀', + '✅', + '❌', + '🙏', + '👏', + '🎉', + '🚀', + '😊', + '🤔', + '😔']; + } +}) + +Template.commentReactions.helpers({ + isSelected(userIds) { + return userIds.includes(Meteor.user()._id); + }, + userNames(userIds) { + return Users.find({_id: {$in: userIds}}) + .map(user => user.profile.fullname) + .join(', '); + } +}) + function createCardLink(card) { if (!card) return ''; return ( diff --git a/client/components/activities/activities.styl b/client/components/activities/activities.styl index 3df2b7816..48b5f8024 100644 --- a/client/components/activities/activities.styl +++ b/client/components/activities/activities.styl @@ -5,6 +5,20 @@ display: flex justify-content:space-between +.reactions-popup + .add-comment-reaction + display: inline-block + cursor: pointer + border-radius: 5px + font-size: 22px + text-align: center + line-height: 30px + width: 40px + + &:hover { + background-color: #b0c4de + } + .activities clear: both @@ -18,7 +32,7 @@ height: @width .activity-member - font-weight: 700 + font-weight: 700 .activity-desc word-wrap: break-word @@ -39,6 +53,45 @@ margin-top: 5px padding: 5px + .reactions + display: flex + margin-top: 5px + gap: 5px + + .open-comment-reaction-popup + display: flex + align-items: center + text-decoration: none + height: 24px; + + i.fa.fa-smile-o + font-size: 17px + font-weight: 500 + margin-left: 2px + + i.fa.fa-plus + font-size: 8px; + margin-top: -7px; + margin-left: 1px; + + .reaction + cursor: pointer + border: 1px solid grey + border-radius: 15px + display: flex + padding: 2px 5px + + &.selected { + background-color: #b0c4de + } + + &:hover { + background-color: #b0c4de + } + + .reaction-count + font-size: 12px + .activity-checklist display: block border-radius: 3px diff --git a/models/cardCommentReactions.js b/models/cardCommentReactions.js new file mode 100644 index 000000000..300458583 --- /dev/null +++ b/models/cardCommentReactions.js @@ -0,0 +1,59 @@ +const commentReactionSchema = new SimpleSchema({ + reactionCodepoint: { type: String, optional: false }, + userIds: { type: [String], defaultValue: [] } +}); + +CardCommentReactions = new Mongo.Collection('card_comment_reactions'); + +/** + * All reactions of a card comment + */ +CardCommentReactions.attachSchema( + new SimpleSchema({ + boardId: { + /** + * the board ID + */ + type: String, + optional: false + }, + cardId: { + /** + * the card ID + */ + type: String, + optional: false + }, + cardCommentId: { + /** + * the card comment ID + */ + type: String, + optional: false + }, + reactions: { + type: [commentReactionSchema], + defaultValue: [] + } + }), +); + +CardCommentReactions.allow({ + insert(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'], +}); + + +if (Meteor.isServer) { + Meteor.startup(() => { + CardCommentReactions._collection._ensureIndex({ cardCommentId: 1 }, { unique: true }); + }); +} diff --git a/models/cardComments.js b/models/cardComments.js index 799b541d8..b97642af9 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -93,6 +93,43 @@ CardComments.helpers({ user() { return Users.findOne(this.userId); }, + + reactions() { + const cardCommentReactions = CardCommentReactions.findOne({cardCommentId: this._id}); + return !!cardCommentReactions ? cardCommentReactions.reactions : []; + }, + + toggleReaction(reactionCodepoint) { + + const cardCommentReactions = CardCommentReactions.findOne({cardCommentId: this._id}); + const reactions = !!cardCommentReactions ? cardCommentReactions.reactions : []; + const userId = Meteor.userId(); + const reaction = reactions.find(r => r.reactionCodepoint === reactionCodepoint); + + if (!reaction) { + reactions.push({ reactionCodepoint, userIds: [userId] }); + } else { + const userHasReacted = reaction.userIds.includes(userId); + if (userHasReacted) { + reaction.userIds.splice(reaction.userIds.indexOf(userId), 1); + if (reaction.userIds.length === 0) { + reactions.splice(reactions.indexOf(reaction), 1); + } + } else { + reaction.userIds.push(userId); + } + } + if (!!cardCommentReactions) { + return CardCommentReactions.update({ _id: cardCommentReactions._id }, { $set: { reactions } }); + } else { + return CardCommentReactions.insert({ + boardId: this.boardId, + cardCommentId: this._id, + cardId: this.cardId, + reactions + }); + } + } }); CardComments.hookOptions.after.update = { fetchPrevious: false }; @@ -187,7 +224,7 @@ if (Meteor.isServer) { * comment: string, * authorId: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function( + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function ( req, res, ) { @@ -200,7 +237,7 @@ if (Meteor.isServer) { data: CardComments.find({ boardId: paramBoardId, cardId: paramCardId, - }).map(function(doc) { + }).map(function (doc) { return { _id: doc._id, comment: doc.text, @@ -228,7 +265,7 @@ if (Meteor.isServer) { JsonRoutes.add( 'GET', '/api/boards/:boardId/cards/:cardId/comments/:commentId', - function(req, res) { + function (req, res) { try { const paramBoardId = req.params.boardId; Authentication.checkBoardAccess(req.userId, paramBoardId); @@ -264,7 +301,7 @@ if (Meteor.isServer) { JsonRoutes.add( 'POST', '/api/boards/:boardId/cards/:cardId/comments', - function(req, res) { + function (req, res) { try { const paramBoardId = req.params.boardId; Authentication.checkBoardAccess(req.userId, paramBoardId); @@ -310,7 +347,7 @@ if (Meteor.isServer) { JsonRoutes.add( 'DELETE', '/api/boards/:boardId/cards/:cardId/comments/:commentId', - function(req, res) { + function (req, res) { try { const paramBoardId = req.params.boardId; Authentication.checkBoardAccess(req.userId, paramBoardId); diff --git a/server/publications/boards.js b/server/publications/boards.js index 12a30e317..23306e087 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -129,6 +129,7 @@ Meteor.publishRelations('board', function(boardId, isArchived) { this.cursor(Lists.find({ boardId, archived: isArchived })); this.cursor(Swimlanes.find({ boardId, archived: isArchived })); this.cursor(Integrations.find({ boardId })); + this.cursor(CardCommentReactions.find({ boardId })); this.cursor( CustomFields.find( { boardIds: { $in: [boardId] } }, @@ -161,6 +162,8 @@ Meteor.publishRelations('board', function(boardId, isArchived) { // Gather queries and send in bulk const cardComments = this.join(CardComments); cardComments.selector = _ids => ({ cardId: _ids }); + const cardCommentReactions = this.join(CardCommentReactions); + cardCommentReactions.selector = _ids => ({ cardId: _ids }); const attachments = this.join(Attachments); attachments.selector = _ids => ({ cardId: _ids }); const checklists = this.join(Checklists); @@ -194,12 +197,14 @@ Meteor.publishRelations('board', function(boardId, isArchived) { checklists.push(cardId); checklistItems.push(cardId); parentCards.push(cardId); + cardCommentReactions.push(cardId) }, ); // Send bulk queries for all found ids subCards.send(); cardComments.send(); + cardCommentReactions.send(); attachments.send(); checklists.send(); checklistItems.send(); diff --git a/server/publications/cards.js b/server/publications/cards.js index ce35aff2f..b2f3e7f3e 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -5,6 +5,7 @@ import Lists from '../../models/lists'; import Swimlanes from '../../models/swimlanes'; import Cards from '../../models/cards'; import CardComments from '../../models/cardComments'; +import CardCommentReactions from '../../models/cardCommentReactions'; import Attachments from '../../models/attachments'; import Checklists from '../../models/checklists'; import ChecklistItems from '../../models/checklistItems'; @@ -699,6 +700,8 @@ function findCards(sessionId, query) { type: 1, }; + const comments = CardComments.find({ cardId: { $in: cards.map(c => c._id) } }); + return [ cards, Boards.find( @@ -714,7 +717,8 @@ function findCards(sessionId, query) { Users.find({ _id: { $in: users } }, { fields: Users.safeFields }), Checklists.find({ cardId: { $in: cards.map(c => c._id) } }), Attachments.find({ cardId: { $in: cards.map(c => c._id) } }), - CardComments.find({ cardId: { $in: cards.map(c => c._id) } }), + comments, + CardCommentReactions.find({cardCommentId: {$in: comments.map(c => c._id) }}), SessionData.find({ userId, sessionId }), ]; }