Browse Source

add a notification drawer like trello

Jonathan Baird 5 years ago
parent
commit
9819c9f801

+ 157 - 190
client/components/activities/activities.jade

@@ -8,234 +8,201 @@ template(name="activities")
       +cardActivities
 
 template(name="boardActivities")
-  each currentBoard.activities
-    .activity
-      +userAvatar(userId=user._id)
-      p.activity-desc
-        +memberName(user=user)
+  each activityData in currentBoard.activities
+    +activity(activity=activityData card=card mode=mode)
 
-        if($eq activityType 'deleteAttachment')
-          | {{{_ 'activity-delete-attach' cardLink}}}.
+template(name="cardActivities")
+  each activityData in currentCard.activities
+    +activity(activity=activityData card=card mode=mode)
+
+template(name="activity")
+  .activity
+    +userAvatar(userId=activity.user._id)
+    p.activity-desc
+      +memberName(user=activity.user)
+
+      //- attachment activity -------------------------------------------------
+      if($eq activity.activityType 'deleteAttachment')
+        | {{{_ 'activity-delete-attach' cardLink}}}.
+
+      if($eq activity.activityType 'addAttachment')
+        | {{{_ 'activity-attached' attachmentLink cardLink}}}.
+        if($neq mode 'board')
+          if activity.attachment.isImage
+            img.attachment-image-preview(src=activity.attachment.url)
+
+      //- board activity ------------------------------------------------------
+      if($eq mode 'board')
+        if($eq activity.activityType 'createBoard')
+          | {{_ 'activity-created' boardLabel}}.
 
-        if($eq activityType 'addAttachment')
-          | {{{_ 'activity-attached' attachmentLink cardLink}}}.
+        if($eq activity.activityType 'importBoard')
+          | {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
 
-        if($eq activityType 'addBoardMember')
+        if($eq activity.activityType 'addBoardMember')
           | {{{_ 'activity-added' memberLink boardLabel}}}.
 
-        if($eq activityType 'addComment')
-          | {{{_ 'activity-on' cardLink}}}
-          a.activity-comment(href="{{ card.absoluteUrl }}")
-            +viewer
-              = comment.text
-
-        if($eq activityType 'addChecklist')
-          | {{{_ 'activity-checklist-added' cardLink}}}.
-          .activity-checklist(href="{{ card.absoluteUrl }}")
-            +viewer
-              = checklist.title
-        if($eq activityType 'removeChecklist')
-          | {{{_ 'activity-checklist-removed' cardLink}}}.
-
-        if($eq activityType 'checkedItem')
-          | {{{_ 'activity-checked-item' checkItem checklist.title cardLink}}}.
-
-        if($eq activityType 'uncheckedItem')
-          | {{{_ 'activity-unchecked-item' checkItem checklist.title cardLink}}}.
-
-        if($eq activityType 'checklistCompleted')
-          | {{{_ 'activity-checklist-completed' checklist.title cardLink}}}.
+        if($eq activity.activityType 'removeBoardMember')
+          | {{{_ 'activity-excluded' memberLink boardLabel}}}.
 
-        if($eq activityType 'checklistUncompleted')
-          | {{{_ 'activity-checklist-uncompleted' checklist.title cardLink}}}.
+      //- card activity -------------------------------------------------------
+      if($eq activity.activityType 'createCard')
+        if($eq mode 'card')
+          | {{{_ 'activity-added' cardLabel activity.listName}}}.
+        else
+          | {{{_ 'activity-added' cardLabel boardLabel}}}.
 
-        if($eq activityType 'addChecklistItem')
-          | {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
-          .activity-checklist(href="{{ card.absoluteUrl }}")
-            +viewer
-              = checklistItem.title
-        if($eq activityType 'removedChecklistItem')
-          | {{{_ 'activity-checklist-item-removed' checklist.title cardLink}}}.
+      if($eq activity.activityType 'importCard')
+        | {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
 
-        if($eq activityType 'archivedCard')
-          | {{{_ 'activity-archived' cardLink}}}.
+      if($eq activity.activityType 'moveCard')
+        | {{{_ 'activity-moved' cardLabel activity.oldList.title activity.list.title}}}.
 
-        if($eq activityType 'archivedList')
-          | {{_ 'activity-archived' list.title}}.
+      if($eq activity.activityType 'moveCardBoard')
+        | {{{_ 'activity-moved' cardLink activity.oldBoardName activity.boardName}}}.
 
-        if($eq activityType 'archivedSwimlane')
-          | {{_ 'activity-archived' swimlane.title}}.
+      if($eq activity.activityType 'archivedCard')
+        | {{{_ 'activity-archived' cardLink}}}.
 
-        if($eq activityType 'createBoard')
-          | {{_ 'activity-created' boardLabel}}.
+      if($eq activity.activityType 'restoredCard')
+        | {{{_ 'activity-sent' cardLink boardLabel}}}.
 
-        if($eq activityType 'createCard')
-          | {{{_ 'activity-added' cardLink boardLabel}}}.
+      //- checklist activity --------------------------------------------------
+      if($eq activity.activityType 'addChecklist')
+        | {{{_ 'activity-checklist-added' cardLink}}}.
+        if($eq mode 'card')
+          .activity-checklist
+            +viewer
+              = activity.checklist.title
+        else
+          a.activity-checklist(href="{{ activity.card.absoluteUrl }}")
+            +viewer
+              = activity.checklist.title
 
-        if($eq activityType 'createCustomField')
-          | {{_ 'activity-customfield-created' customField}}.
+      if($eq activity.activityType 'removedChecklist')
+        | {{{_ 'activity-checklist-removed' cardLink}}}.
 
-        if($eq activityType 'createList')
-          | {{_ 'activity-added' list.title boardLabel}}.
+      if($eq activity.activityType 'completeChecklist')
+        | {{{_ 'activity-checklist-completed' activity.checklist.title cardLink}}}.
 
-        if($eq activityType 'createSwimlane')
-          | {{_ 'activity-added' swimlane.title boardLabel}}.
+      if($eq activity.activityType 'uncompleteChecklist')
+        | {{{_ 'activity-checklist-uncompleted' activity.checklist.title cardLink}}}.
 
-        if($eq activityType 'removeList')
-          | {{_ 'activity-removed' title boardLabel}}.
+      if($eq activity.activityType 'checkedItem')
+        | {{{_ 'activity-checked-item' checkItem activity.checklist.title cardLink}}}.
 
-        if($eq activityType 'importBoard')
-          | {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
+      if($eq activity.activityType 'uncheckedItem')
+        | {{{_ 'activity-unchecked-item' checkItem activity.checklist.title cardLink}}}.
 
-        if($eq activityType 'importCard')
-          | {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
+      if($eq activity.activityType 'addChecklistItem')
+        | {{{_ 'activity-checklist-item-added' activity.checklist.title cardLink}}}.
+        .activity-checklist(href="{{ activity.card.absoluteUrl }}")
+          +viewer
+            = activity.checklistItem.title
 
-        if($eq activityType 'importList')
-          | {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
+      if($eq activity.activityType 'removedChecklistItem')
+        | {{{_ 'activity-checklist-item-removed' activity.checklist.title cardLink}}}.
 
-        if($eq activityType 'joinMember')
-          if($eq user._id member._id)
-            | {{{_ 'activity-joined' cardLink}}}.
+      //- comment activity ----------------------------------------------------
+      if($eq mode 'card')
+        //- if we are in card mode we display the comment in a way that it
+        //- can be edited by the owner
+        if($eq activity.activityType 'addComment')
+          +inlinedForm(classNames='js-edit-comment')
+            +editor(autofocus=true)
+              = activity.comment.text
+            .edit-controls
+              button.primary(type="submit") {{_ 'edit'}}
           else
-            | {{{_ 'activity-added' memberLink cardLink}}}.
-
-        if($eq activityType 'moveCardBoard')
-          | {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
-
-        if($eq activityType 'moveCard')
-          | {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
-
-        if($eq activityType 'removeBoardMember')
-          | {{{_ 'activity-excluded' memberLink boardLabel}}}.
+            .activity-comment
+              +viewer
+                = activity.comment.text
+            span(title=activity.createdAt).activity-meta {{ moment activity.createdAt }}
+              if ($eq currentUser._id activity.comment.userId)
+                = ' - '
+                a.js-open-inlined-form {{_ "edit"}}
+                = ' - '
+                a.js-delete-comment {{_ "delete"}}
 
-        if($eq activityType 'restoredCard')
-          | {{{_ 'activity-sent' cardLink boardLabel}}}.
+        if($eq activity.activityType 'deleteComment')
+          | {{{_ 'activity-deleteComment' currentData.commentId}}}.
 
-        if($eq activityType 'addedLabel')
-          | {{{_ 'activity-added-label' lastLabel cardLink}}}.
+        if($eq activity.activityType 'editComment')
+          | {{{_ 'activity-editComment' currentData.commentId}}}.
+      else
+        //- if we are not in card mode we only display a summary of the comment
+        if($eq activity.activityType 'addComment')
+          | {{{_ 'activity-on' cardLink}}}
+          a.activity-comment(href="{{ activity.card.absoluteUrl }}")
+            +viewer
+              = activity.comment.text
 
-        if($eq activityType 'removedLabel')
-          | {{{_ 'activity-removed-label' lastLabel cardLink}}}.
+      //- customField activity ------------------------------------------------
+      if($eq mode 'board')
+        if($eq activity.activityType 'createCustomField')
+          | {{_ 'activity-customfield-created' customField}}.
 
-        if($eq activityType 'setCustomField')
+        if($eq activity.activityType 'setCustomField')
           | {{{_ 'activity-set-customfield' lastCustomField lastCustomFieldValue cardLink}}}.
 
-        if($eq activityType 'unsetCustomField')
+        if($eq activity.activityType 'unsetCustomField')
           | {{{_ 'activity-unset-customfield' lastCustomField cardLink}}}.
 
-        if($eq activityType 'unjoinMember')
-          if($eq user._id member._id)
-            | {{{_ 'activity-unjoined' cardLink}}}.
-          else
-            | {{{_ 'activity-removed' memberLink cardLink}}}.
+      //- label activity ------------------------------------------------------
+      if($eq activity.activityType 'addedLabel')
+        | {{{_ 'activity-added-label' lastLabel cardLink}}}.
 
-        span(title=createdAt).activity-meta {{ moment createdAt }}
+      if($eq activity.activityType 'removedLabel')
+        | {{{_ 'activity-removed-label' lastLabel cardLink}}}.
 
-template(name="cardActivities")
-  each currentCard.activities
-    .activity
-      +userAvatar(userId=user._id)
-      p.activity-desc
-        +memberName(user=user)
-        if($eq activityType 'createCard')
-          | {{_ 'activity-added' cardLabel listName}}.
-        if($eq activityType 'importCard')
-          | {{{_ 'activity-imported' cardLabel list.title sourceLink}}}.
-        if($eq activityType 'joinMember')
-          if($eq user._id member._id)
-            | {{_ 'activity-joined' cardLabel}}.
-          else
-            | {{{_ 'activity-added' memberLink cardLabel}}}.
-        if($eq activityType 'unjoinMember')
-          if($eq user._id member._id)
-            | {{_ 'activity-unjoined' cardLabel}}.
-          else
-            | {{{_ 'activity-removed' cardLabel memberLink}}}.
-        if($eq activityType 'archivedCard')
-          | {{_ 'activity-archived' cardLabel}}.
-
-        if($eq activityType 'addedLabel')
-          | {{{_ 'activity-added-label-card' lastLabel }}}.
+      //- list activity -------------------------------------------------------
+      if($neq mode 'card')
+        if($eq activity.activityType 'createList')
+          | {{{_ 'activity-added' listLabel boardLabel}}}.
 
-        if($eq activityType 'removedLabel')
-          | {{{_ 'activity-removed-label-card' lastLabel }}}.
+        if($eq activity.activityType 'importList')
+          | {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
 
-        if($eq activityType 'removeChecklist')
-          | {{{_ 'activity-checklist-removed' cardLabel}}}.
+        if($eq activity.activityType 'removeList')
+          | {{{_ 'activity-removed' activity.title boardLabel}}}.
 
-        if($eq activityType 'checkedItem')
-          | {{{_ 'activity-checked-item-card' checkItem checklist.title }}}.
+        if($eq activity.activityType 'archivedList')
+          | {{_ 'activity-archived' listLabel}}.
 
-        if($eq activityType 'uncheckedItem')
-          | {{{_ 'activity-unchecked-item-card' checkItem checklist.title }}}.
+      //- member activity ----------------------------------------------------
+      if($eq activity.activityType 'joinMember')
+        if($eq user._id activity.member._id)
+          | {{{_ 'activity-joined' cardLink}}}.
+        else
+          | {{{_ 'activity-added' memberLink cardLink}}}.
 
-        if($eq activityType 'checklistCompleted')
-          | {{{_ 'activity-checklist-completed-card' checklist.title }}}.
+      if($eq activity.activityType 'unjoinMember')
+        if($eq user._id activity.member._id)
+          | {{{_ 'activity-unjoined' cardLink}}}.
+        else
+          | {{{_ 'activity-removed' memberLink cardLink}}}.
 
-        if($eq activityType 'checklistUncompleted')
-          | {{{_ 'activity-checklist-uncompleted-card' checklist.title }}}.
+      //- swimlane activity --------------------------------------------------
+      if($neq mode 'card')
+        if($eq activity.activityType 'createSwimlane')
+          | {{{_ 'activity-added' activity.swimlane.title boardLabel}}}.
 
-        if($eq activityType 'restoredCard')
-          | {{_ 'activity-sent' cardLabel boardLabel}}.
-        if($eq activityType 'moveCard')
-          | {{_ 'activity-moved' cardLabel oldList.title list.title}}.
+        if($eq activity.activityType 'archivedSwimlane')
+          | {{_ 'activity-archived' activity.swimlane.title}}.
 
-        if($eq activityType 'moveCardBoard')
-          | {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
 
-        if($eq activityType 'addAttachment')
-          | {{{_ 'activity-attached' attachmentLink cardLabel}}}.
-          if attachment.isImage
-            img.attachment-image-preview(src=attachment.url)
-        if($eq activityType 'deleteAttachment')
-          | {{{_ 'activity-delete-attach'  cardLabel}}}.
-        if($eq activityType 'removedChecklist')
-          | {{{_ 'activity-checklist-removed' cardLabel}}}.
-        if($eq activityType 'addChecklist')
-          | {{{_ 'activity-checklist-added' cardLabel}}}.
-          .activity-checklist
-            +viewer
-              = checklist.title
-        if($eq activityType 'addChecklistItem')
-          | {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
-          .activity-checklist(href="{{ card.absoluteUrl }}")
-            +viewer
-              = checklistItem.title
-
-        if(currentData.timeKey)
-          | {{{_ activityType }}}
+      //- I don't understand this part ----------------------------------------
+      if(currentData.timeKey)
+        | {{{_ activity.activityType }}}
+        = ' '
+        i(title=currentData.timeValue).activity-meta {{ moment currentData.timeValue 'LLL' }}
+        if (currentData.timeOldValue)
           = ' '
-          i(title=currentData.timeValue).activity-meta {{ moment currentData.timeValue 'LLL' }}
-          if (currentData.timeOldValue)
-              = ' '
-              | {{{_ "previous_as" }}}
-              = ' '
-              i(title=currentData.timeOldValue).activity-meta {{ moment currentData.timeOldValue 'LLL' }}
-          = ' @'
-        else if(currentData.timeValue)
-          | {{{_ activityType currentData.timeValue}}}
-
-
-        if($eq activityType 'deleteComment')
-          | {{{_ 'activity-deleteComment' currentData.commentId}}}.
-        if($eq activityType 'editComment')
-          | {{{_ 'activity-editComment' currentData.commentId}}}.
-        if($eq activityType 'addComment')
-          +inlinedForm(classNames='js-edit-comment')
-            +editor(autofocus=true)
-              = comment.text
-            .edit-controls
-              button.primary(type="submit") {{_ 'edit'}}
-          else
-            .activity-comment
-              +viewer
-                = comment.text
-            span(title=createdAt).activity-meta {{ moment createdAt }}
-              if ($eq currentUser._id comment.userId)
-                = ' - '
-                a.js-open-inlined-form {{_ "edit"}}
-                = ' - '
-                a.js-delete-comment {{_ "delete"}}
-
-        else
-          span(title=createdAt).activity-meta {{ moment createdAt }}
+            | {{{_ "previous_as" }}}
+            = ' '
+            i(title=currentData.timeOldValue).activity-meta {{ moment currentData.timeOldValue 'LLL' }}
+        = ' @'
+      else if(currentData.timeValue)
+        | {{{_ activity.activityType currentData.timeValue}}}
+
+      span(title=activity.createdAt).activity-meta {{ moment activity.createdAt }}

+ 72 - 39
client/components/activities/activities.js

@@ -41,7 +41,9 @@ BlazeComponent.extendComponent({
       });
     });
   },
+}).register('activities');
 
+BlazeComponent.extendComponent({
   loadNextPage() {
     if (this.loadNextPageLocked === false) {
       this.page.set(this.page.get() + 1);
@@ -50,41 +52,37 @@ BlazeComponent.extendComponent({
   },
 
   checkItem() {
-    const checkItemId = this.currentData().checklistItemId;
+    const checkItemId = this.currentData().activity.checklistItemId;
     const checkItem = ChecklistItems.findOne({ _id: checkItemId });
-    return checkItem.title;
+    return checkItem && checkItem.title;
   },
 
   boardLabel() {
+    const data = this.currentData();
+    if (data.mode !== 'board') {
+      return createBoardLink(data.activity.board(), data.activity.listName);
+    }
     return TAPi18n.__('this-board');
   },
 
   cardLabel() {
+    const data = this.currentData();
+    if (data.mode !== 'card') {
+      return createCardLink(this.currentData().activity.card());
+    }
     return TAPi18n.__('this-card');
   },
 
   cardLink() {
-    const card = this.currentData().card();
-    return (
-      card &&
-      Blaze.toHTML(
-        HTML.A(
-          {
-            href: card.absoluteUrl(),
-            class: 'action-card',
-          },
-          card.title,
-        ),
-      )
-    );
+    return createCardLink(this.currentData().activity.card());
   },
 
   lastLabel() {
-    const lastLabelId = this.currentData().labelId;
+    const lastLabelId = this.currentData().activity.labelId;
     if (!lastLabelId) return null;
-    const lastLabel = Boards.findOne(Session.get('currentBoard')).getLabelById(
-      lastLabelId,
-    );
+    const lastLabel = Boards.findOne(
+      this.currentData().activity.boardId,
+    ).getLabelById(lastLabelId);
     if (lastLabel && (lastLabel.name === undefined || lastLabel.name === '')) {
       return lastLabel.color;
     } else {
@@ -94,7 +92,7 @@ BlazeComponent.extendComponent({
 
   lastCustomField() {
     const lastCustomField = CustomFields.findOne(
-      this.currentData().customFieldId,
+      this.currentData().activity.customFieldId,
     );
     if (!lastCustomField) return null;
     return lastCustomField.name;
@@ -102,10 +100,10 @@ BlazeComponent.extendComponent({
 
   lastCustomFieldValue() {
     const lastCustomField = CustomFields.findOne(
-      this.currentData().customFieldId,
+      this.currentData().activity.customFieldId,
     );
     if (!lastCustomField) return null;
-    const value = this.currentData().value;
+    const value = this.currentData().activity.value;
     if (
       lastCustomField.settings.dropdownItems &&
       lastCustomField.settings.dropdownItems.length > 0
@@ -122,11 +120,13 @@ BlazeComponent.extendComponent({
   },
 
   listLabel() {
-    return this.currentData().list().title;
+    const activity = this.currentData().activity;
+    const list = activity.list();
+    return (list && list.title) || activity.title;
   },
 
   sourceLink() {
-    const source = this.currentData().source;
+    const source = this.currentData().activity.source;
     if (source) {
       if (source.url) {
         return Blaze.toHTML(
@@ -146,30 +146,31 @@ BlazeComponent.extendComponent({
 
   memberLink() {
     return Blaze.toHTMLWithData(Template.memberName, {
-      user: this.currentData().member(),
+      user: this.currentData().activity.member(),
     });
   },
 
   attachmentLink() {
-    const attachment = this.currentData().attachment();
+    const attachment = this.currentData().activity.attachment();
     // trying to display url before file is stored generates js errors
     return (
-      attachment &&
-      attachment.url({ download: true }) &&
-      Blaze.toHTML(
-        HTML.A(
-          {
-            href: attachment.url({ download: true }),
-            target: '_blank',
-          },
-          attachment.name(),
-        ),
-      )
+      (attachment &&
+        attachment.url({ download: true }) &&
+        Blaze.toHTML(
+          HTML.A(
+            {
+              href: attachment.url({ download: true }),
+              target: '_blank',
+            },
+            attachment.name(),
+          ),
+        )) ||
+      this.currentData().activity.attachmentName
     );
   },
 
   customField() {
-    const customField = this.currentData().customField();
+    const customField = this.currentData().activity.customField();
     if (!customField) return null;
     return customField.name;
   },
@@ -199,4 +200,36 @@ BlazeComponent.extendComponent({
       },
     ];
   },
-}).register('activities');
+}).register('activity');
+
+function createCardLink(card) {
+  return (
+    card &&
+    Blaze.toHTML(
+      HTML.A(
+        {
+          href: card.absoluteUrl(),
+          class: 'action-card',
+        },
+        card.title,
+      ),
+    )
+  );
+}
+
+function createBoardLink(board, list) {
+  let text = board.title;
+  if (list) text += `: ${list}`;
+  return (
+    board &&
+    Blaze.toHTML(
+      HTML.A(
+        {
+          href: board.absoluteUrl(),
+          class: 'action-board',
+        },
+        text,
+      ),
+    )
+  );
+}

+ 2 - 0
client/components/main/header.jade

@@ -35,6 +35,8 @@ template(name="header")
       a#header-new-board-icon.js-create-board
         i.fa.fa-plus(title="Create a new board")
 
+      +notifications
+
       +headerUserBar
 
   #header(class=currentBoard.colorClass)

+ 1 - 1
client/components/main/header.styl

@@ -99,7 +99,7 @@
   height: 28px
   font-size: 12px
   display: flex
-  z-index: 17
+  z-index: 21
 
   #header-user-bar,
   #header-new-board-icon,

+ 10 - 0
client/components/notifications/notification.jade

@@ -0,0 +1,10 @@
+template(name='notification')
+  li.notification(class="{{#if read}}read{{/if}}")
+    .read-status
+      .materialCheckBox(class="{{#if read}}is-checked{{/if}}")
+      +notificationIcon(activityData)
+    .details
+      +activity(activity=activityData mode='none')
+    if read
+      .remove
+        a.fa.fa-trash

+ 28 - 0
client/components/notifications/notification.js

@@ -0,0 +1,28 @@
+Template.notification.events({
+  'click .read-status .materialCheckBox'() {
+    const update = {};
+    update[`profile.notifications.${this.index}.read`] = this.read
+      ? null
+      : Date.now();
+    Users.update(Meteor.userId(), { $set: update });
+  },
+  'click .remove a'() {
+    Meteor.user().removeNotification(this.activityData._id);
+  },
+});
+
+Template.notification.helpers({
+  mode: 'board',
+  isOfActivityType(activityId, type) {
+    const activity = Activities.findOne(activityId);
+    return activity && activity.activityType === type;
+  },
+  activityType(activityId) {
+    const activity = Activities.findOne(activityId);
+    return activity ? activity.activityType : '';
+  },
+  activityUser(activityId) {
+    const activity = Activities.findOne(activityId);
+    return activity && activity.userId;
+  },
+});

+ 57 - 0
client/components/notifications/notification.styl

@@ -0,0 +1,57 @@
+#notifications-drawer
+  &.show-read .notification.read
+    display: flex
+
+  .notification
+    display: flex
+    float: none
+    padding: 12px 8px 8px
+    color: black
+    border-bottom: 1px solid #dbdbdb
+
+    &.read
+      display: none
+
+    .read-status
+      width: 30px
+
+      input
+        width: 24px
+        height: 24px
+
+      .activity-type
+        margin: 16px 0 0
+        width: 17px
+        height: 17px
+        font-size: 17px
+        display: block
+        color: #bbb
+
+    .details
+      width: calc(100% - 30px)
+
+      .activity
+        display: flex
+
+        .activity-desc
+          width: 100%;
+
+        .activity-comment
+          display: block
+          width: 100%
+          border-radius: 3px
+          background: #fff
+          text-decoration: none
+          box-shadow: 0 1px 2px rgba(0,0,0,0.2)
+          margin-top: 5px
+          padding: 5px
+
+        .activity-meta
+          display: block
+          font-size: 0.8em
+          color: #999
+          font-style: italic
+
+    .remove
+      a:hover
+        color #eb4646 !important

+ 53 - 0
client/components/notifications/notificationIcon.jade

@@ -0,0 +1,53 @@
+template(name='notificationIcon')
+  if($in activityType 'deleteAttachment' 'addAttachment')
+    i.fa.fa-paperclip.activity-type(title="attachment")
+  else if($in activityType 'createBoard' 'importBoard')
+    i.fa.fa-chalkboard.activity-type(title="board")
+
+  else if($in activityType 'createCard' 'importCard' 'moveCard')
+    +cardNotificationIcon
+  else if($in activityType 'moveCardBoard' 'archivedCard' 'restoredCard')
+    +cardNotificationIcon
+    //- $in can only handle up to 3 cases so we have to break this case over 2 cases... use a simple template to keep it
+    //- DRY and consistant
+
+  else if($in activityType 'addChecklist' 'removedChecklist' 'completeChecklist')
+    +checklistNotificationIcon
+  else if($in activityType 'uncompleteChecklist')
+    +checklistNotificationIcon
+    //- $in can only handle up to 3 cases so we have to break this case over 2 cases... use a simple template to keep it
+    //- DRY and consistant
+
+  else if($in activityType 'checkedItem' 'uncheckedItem' 'addChecklistItem' 'removedChecklistItem')
+    i.fa.fa-check-square.activity-type(title="checklist item")
+  else if($in activityType 'addComment')
+    i.fa.fa-comment-o.activity-type(title="comment")
+  else if($in activityType 'createCustomField' 'setCustomField' 'unsetCustomField')
+    i.fa.fa-code.activity-type(title="custom field")
+  else if($in activityType 'addedLabel' 'removedLabel')
+    i.fa.fa-tag.activity-type(title="label")
+
+  else if($in activityType 'createList' 'removeList' 'archivedList')
+    +listNotificationIcon
+  else if($in activityType  'importList')
+    +listNotificationIcon
+    //- $in can only handle up to 3 cases so we have to break this case over 2 cases... use a simple template to keep it
+    //- DRY and consistant
+
+    //- elswhere in the app we use fa-trello to indicate lists...
+    //- i personally like fa-columns a bit better
+  else if($in activityType 'unjoinMember' 'addBoardMember' 'joinMember' 'removeBoardMember')
+    i.fa.fa-user.activity-type(title="member")
+  else if($in activityType 'createSwimlane' 'archivedSwimlane')
+    i.fa.fa-th-large.activity-type(title="swimlane")
+  else
+    i.fa.fa-bug.activity-type(title="can't find icon for #{activityType}")
+
+template(name='cardNotificationIcon')
+  i.fa.fa-clone.activity-type(title="card")
+
+template(name='checklistNotificationIcon')
+  i.fa.fa-list.activity-type(title="checklist")
+
+template(name='listNotificationIcon')
+  i.fa.fa-columns.activity-type(title="list")

+ 5 - 0
client/components/notifications/notifications.jade

@@ -0,0 +1,5 @@
+template(name='notifications')
+  #notifications.board-header-btns.right
+    a.notifications-drawer-toggle.fa.fa-bell(class="{{#if $gt unreadNotifications 0}}alert{{/if}}")
+    if $.Session.get 'showNotificationsDrawer'
+      +notificationsDrawer(unreadNotifications=unreadNotifications)

+ 32 - 0
client/components/notifications/notifications.js

@@ -0,0 +1,32 @@
+// this hides the notifications drawer if anyone clicks off of the panel
+Template.body.events({
+  click(event) {
+    if (
+      !$(event.target).is('#notifications *') &&
+      Session.get('showNotificationsDrawer')
+    ) {
+      toggleNotificationsDrawer();
+    }
+  },
+});
+
+Template.notifications.helpers({
+  unreadNotifications() {
+    const notifications = Users.findOne(Meteor.userId()).notifications();
+    const unreadNotifications = _.filter(notifications, v => !v.read);
+    return unreadNotifications.length;
+  },
+});
+
+Template.notifications.events({
+  'click .notifications-drawer-toggle'() {
+    toggleNotificationsDrawer();
+  },
+});
+
+export function toggleNotificationsDrawer() {
+  Session.set(
+    'showNotificationsDrawer',
+    !Session.get('showNotificationsDrawer'),
+  );
+}

+ 17 - 0
client/components/notifications/notifications.styl

@@ -0,0 +1,17 @@
+#notifications
+  position: relative
+
+  .notifications-drawer-toggle
+    display: block
+    line-height: 28px
+    color: #f2f2f2
+    margin: 0 10px
+    width: 28px
+    height: 28px
+    text-align: center
+    border: 0
+    padding: 0
+
+    &.alert
+      background-color: #eb4646;
+

+ 16 - 0
client/components/notifications/notificationsDrawer.jade

@@ -0,0 +1,16 @@
+template(name='notificationsDrawer')
+  section#notifications-drawer(class="{{#if $.Session.get 'showReadNotifications'}}show-read{{/if}}")
+    .header
+      if $.Session.get 'showReadNotifications'
+        a.toggle-read Filter by Unread
+      else
+        a.toggle-read View All
+      h5 Notifications
+        if($gt unreadNotifications 0)
+          |(#{unreadNotifications})
+      a.fa.fa-times-thin.close
+    ul.notifications
+      each transformedProfile.notifications
+        +notification(activityData=activity index=dbIndex read=read)
+    if($gt unreadNotifications 0)
+      a.all-read Mark all as read

+ 38 - 0
client/components/notifications/notificationsDrawer.js

@@ -0,0 +1,38 @@
+import { toggleNotificationsDrawer } from './notifications.js';
+
+Template.notificationsDrawer.onCreated(function() {
+  Meteor.subscribe('notificationActivities');
+  Meteor.subscribe('notificationCards');
+  Meteor.subscribe('notificationUsers');
+  Meteor.subscribe('notificationsAttachments');
+  Meteor.subscribe('notificationChecklistItems');
+  Meteor.subscribe('notificationChecklists');
+  Meteor.subscribe('notificationComments');
+  Meteor.subscribe('notificationLists');
+  Meteor.subscribe('notificationSwimlanes');
+});
+
+Template.notificationsDrawer.helpers({
+  transformedProfile() {
+    return Users.findOne(Meteor.userId());
+  },
+});
+
+Template.notificationsDrawer.events({
+  'click .all-read'() {
+    const notifications = Meteor.user().profile.notifications;
+    for (const index in notifications) {
+      if (notifications.hasOwnProperty(index) && !notifications[index].read) {
+        const update = {};
+        update[`profile.notifications.${index}.read`] = Date.now();
+        Users.update(Meteor.userId(), { $set: update });
+      }
+    }
+  },
+  'click .close'() {
+    toggleNotificationsDrawer();
+  },
+  'click .toggle-read'() {
+    Session.set('showReadNotifications', !Session.get('showReadNotifications'));
+  },
+});

+ 57 - 0
client/components/notifications/notificationsDrawer.styl

@@ -0,0 +1,57 @@
+belize = #2980b9
+
+section#notifications-drawer
+  position: fixed
+  top: 28px
+  right: 0
+  width: 400px
+  background-color: #fafafa
+  box-shadow: 0 1px 2px rgba(0,0,0,0.15)
+  border-radius: 2px
+  max-height: calc(100vh - 28px - 36px)
+  color: black
+  padding-top 36px;
+  overflow: scroll
+
+  a:hover
+    color: belize !important
+
+  .header
+    position: fixed
+    top 28px
+    right 0
+    width calc(400px - 32px)
+    padding: 8px 16px
+    background: #ededed
+    border-bottom: 1px solid #dbdbdb
+    z-index 2
+
+    .toggle-read
+      position absolute
+      left 16px
+      top calc(50% - 8px)
+      color belize
+
+    h5
+      text-align: center
+      margin: 0
+
+    .close
+      position: absolute
+      top: calc(50% - 12px)
+      right: 12px
+      font-size: 24px
+      height: 24px
+      line-height: 24px
+      opacity 1
+
+  .all-read
+    color belize
+    background-color: #fafafa
+    margin 8px 16px 12px
+    display inline-block
+
+  ul.notifications
+    display: block
+    padding: 0px 16px
+    margin: 0

+ 1 - 1
i18n/en.i18n.json

@@ -64,7 +64,7 @@
   "activity-unchecked-item": "unchecked %s in checklist %s of %s",
   "activity-checklist-added": "added checklist to %s",
   "activity-checklist-removed": "removed a checklist from %s",
-  "activity-checklist-completed": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
+  "activity-checklist-completed": "completed checklist %s of %s",
   "activity-checklist-uncompleted": "uncompleted the checklist %s of %s",
   "activity-checklist-item-added": "added checklist item to '%s' in %s",
   "activity-checklist-item-removed": "removed a checklist item from '%s' in %s",

+ 6 - 6
models/attachments.js

@@ -219,6 +219,9 @@ if (Meteor.isServer) {
         type: 'card',
         activityType: 'addAttachment',
         attachmentId: doc._id,
+        // this preserves the name so that notifications can be meaningful after
+        // this file is removed
+        attachmentName: doc.original.name,
         boardId: doc.boardId,
         cardId: doc.cardId,
         listId: doc.listId,
@@ -246,18 +249,15 @@ if (Meteor.isServer) {
       type: 'card',
       activityType: 'deleteAttachment',
       attachmentId: doc._id,
+      // this preserves the name so that notifications can be meaningful after
+      // this file is removed
+      attachmentName: doc.original.name,
       boardId: doc.boardId,
       cardId: doc.cardId,
       listId: doc.listId,
       swimlaneId: doc.swimlaneId,
     });
   });
-
-  Attachments.files.after.remove((userId, doc) => {
-    Activities.remove({
-      attachmentId: doc._id,
-    });
-  });
 }
 
 export default Attachments;

+ 6 - 0
models/lists.js

@@ -369,6 +369,9 @@ if (Meteor.isServer) {
       activityType: 'createList',
       boardId: doc.boardId,
       listId: doc._id,
+      // this preserves the name so that the activity can be useful after the
+      // list is deleted
+      title: doc.title,
     });
   });
 
@@ -397,6 +400,9 @@ if (Meteor.isServer) {
         activityType: 'archivedList',
         listId: doc._id,
         boardId: doc.boardId,
+        // this preserves the name so that the activity can be useful after the
+        // list is deleted
+        title: doc.title,
       });
     }
   });

+ 30 - 3
models/users.js

@@ -165,7 +165,20 @@ Users.attachSchema(
       /**
        * enabled notifications for the user
        */
-      type: [String],
+      type: [Object],
+      optional: true,
+    },
+    'profile.notifications.$.activity': {
+      /**
+       * The id of the activity this notification references
+       */
+      type: String,
+    },
+    'profile.notifications.$.read': {
+      /**
+       * the date on which this notification was read
+       */
+      type: Date,
       optional: true,
     },
     'profile.showCardsCountAt': {
@@ -429,6 +442,20 @@ Users.helpers({
     return _.contains(notifications, activityId);
   },
 
+  notifications() {
+    const { notifications = [] } = this.profile || {};
+    for (const index in notifications) {
+      if (!notifications.hasOwnProperty(index)) continue;
+      const notification = notifications[index];
+      // this preserves their db sort order for editing
+      notification.dbIndex = index;
+      notification.activity = Activities.findOne(notification.activity);
+    }
+    // this sorts them newest to oldest to match Trello's behavior
+    notifications.reverse();
+    return notifications;
+  },
+
   hasShowDesktopDragHandles() {
     const profile = this.profile || {};
     return profile.showDesktopDragHandles || false;
@@ -573,7 +600,7 @@ Users.mutations({
   addNotification(activityId) {
     return {
       $addToSet: {
-        'profile.notifications': activityId,
+        'profile.notifications': { activity: activityId },
       },
     };
   },
@@ -581,7 +608,7 @@ Users.mutations({
   removeNotification(activityId) {
     return {
       $pull: {
-        'profile.notifications': activityId,
+        'profile.notifications': { activity: activityId },
       },
     };
   },

+ 3 - 7
server/notifications/profile.js

@@ -1,9 +1,5 @@
 Meteor.startup(() => {
-  // XXX: add activity id to profile.notifications,
-  // it can be displayed and rendered on web or mobile UI
-  // will uncomment the following code once UI implemented
-  //
-  // Notifications.subscribe('profile', (user, title, description, params) => {
-  // user.addNotification(params.activityId);
-  // });
+  Notifications.subscribe('profile', (user, title, description, params) => {
+    user.addNotification(params.activityId);
+  });
 });

+ 100 - 0
server/publications/notifications.js

@@ -0,0 +1,100 @@
+// We use these when displaying notifications in the notificationsDrawer
+
+// gets all activities associated with the current user
+Meteor.publish('notificationActivities', () => {
+  return activities();
+});
+
+// gets all attachments associated with activities associated with the current user
+Meteor.publish('notificationAttachments', function() {
+  return Attachments.find({
+    _id: {
+      $in: activities()
+        .map(v => v.attachmentId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all cards associated with activities associated with the current user
+Meteor.publish('notificationCards', function() {
+  return Cards.find({
+    _id: {
+      $in: activities()
+        .map(v => v.cardId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all checklistItems associated with activities associated with the current user
+Meteor.publish('notificationChecklistItems', function() {
+  return ChecklistItems.find({
+    _id: {
+      $in: activities()
+        .map(v => v.checklistItemId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all checklists associated with activities associated with the current user
+Meteor.publish('notificationChecklists', function() {
+  return Checklists.find({
+    _id: {
+      $in: activities()
+        .map(v => v.checklistId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all comments associated with activities associated with the current user
+Meteor.publish('notificationComments', function() {
+  return CardComments.find({
+    _id: {
+      $in: activities()
+        .map(v => v.commentId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all lists associated with activities associated with the current user
+Meteor.publish('notificationLists', function() {
+  return Lists.find({
+    _id: {
+      $in: activities()
+        .map(v => v.listId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all swimlanes associated with activities associated with the current user
+Meteor.publish('notificationSwimlanes', function() {
+  return Swimlanes.find({
+    _id: {
+      $in: activities()
+        .map(v => v.swimlaneId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+// gets all users associated with activities associated with the current user
+Meteor.publish('notificationUsers', function() {
+  return Users.find({
+    _id: {
+      $in: activities()
+        .map(v => v.userId)
+        .filter(v => !!v),
+    },
+  });
+});
+
+function activities() {
+  return Activities.find({
+    _id: { $in: Meteor.user().profile.notifications.map(v => v.activity) },
+  });
+}