Bläddra i källkod

adds card comment reactions feature

Kai Lehmann 3 år sedan
förälder
incheckning
2977120129

+ 1 - 0
.eslintrc.json

@@ -122,6 +122,7 @@
     "Activities": true,
     "Attachments": true,
     "Boards": true,
+    "CardCommentReactions": true,
     "CardComments": true,
     "DatePicker": true,
     "Cards": true,

+ 20 - 3
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}}}.
 

+ 53 - 0
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 (

+ 54 - 1
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

+ 59 - 0
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 });
+  });
+}

+ 42 - 5
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);

+ 5 - 0
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();

+ 5 - 1
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 }),
     ];
   }