Răsfoiți Sursa

Merge pull request #454 from floatinghotpot/notification

Add notifications, allow watch boards / lists / cards
Maxime Quandalle 9 ani în urmă
părinte
comite
1e8368dea5

+ 1 - 0
.eslintrc

@@ -115,6 +115,7 @@ globals:
   Utils: true
   Utils: true
   InlinedForm: true
   InlinedForm: true
   UnsavedEdits: true
   UnsavedEdits: true
+  Notifications: true
 
 
   # XXX Temp, we should remove these
   # XXX Temp, we should remove these
   allowIsBoardAdmin: true
   allowIsBoardAdmin: true

+ 1 - 1
client/components/activities/activities.js

@@ -51,7 +51,7 @@ BlazeComponent.extendComponent({
   cardLink() {
   cardLink() {
     const card = this.currentData().card();
     const card = this.currentData().card();
     return card && Blaze.toHTML(HTML.A({
     return card && Blaze.toHTML(HTML.A({
-      href: FlowRouter.path(card.absoluteUrl()),
+      href: card.absoluteUrl(),
       'class': 'action-card',
       'class': 'action-card',
     }, card.title));
     }, card.title));
   },
   },

+ 49 - 0
client/components/boards/boardHeader.jade

@@ -19,6 +19,17 @@ template(name="boardHeaderBar")
           i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
           i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
           span {{_ currentBoard.permission}}
           span {{_ currentBoard.permission}}
 
 
+        a.board-header-btn.js-watch-board
+          if $eq watchLevel "watching"
+            i.fa.fa-eye
+            span {{_ 'watching'}}
+          if $eq watchLevel "tracking"
+            i.fa.fa-user
+            span {{_ 'tracking'}}
+          if $eq watchLevel "muted"
+            i.fa.fa-times-circle
+            span {{_ 'muted'}}
+
   .board-header-btns.right
   .board-header-btns.right
     if isMiniScreen
     if isMiniScreen
       unless isSandstorm
       unless isSandstorm
@@ -34,6 +45,17 @@ template(name="boardHeaderBar")
           i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
           i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
           span {{_ currentBoard.permission}}
           span {{_ currentBoard.permission}}
 
 
+        a.board-header-btn.js-watch-board
+          if $eq watchLevel "watching"
+            i.fa.fa-eye
+            span {{_ 'watching'}}
+          if $eq watchLevel "tracking"
+            i.fa.fa-user
+            span {{_ 'tracking'}}
+          if $eq watchLevel "muted"
+            i.fa.fa-times-circle
+            span {{_ 'muted'}}
+
     a.board-header-btn.js-open-filter-view(
     a.board-header-btn.js-open-filter-view(
         title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{/if}}"
         title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{/if}}"
         class="{{#if Filter.isActive}}emphasis{{/if}}")
         class="{{#if Filter.isActive}}emphasis{{/if}}")
@@ -97,6 +119,33 @@ template(name="boardVisibilityList")
 template(name="boardChangeVisibilityPopup")
 template(name="boardChangeVisibilityPopup")
   +boardVisibilityList
   +boardVisibilityList
 
 
+template(name="boardChangeWatchPopup")
+  ul.pop-over-list
+    li
+      with "watching"
+        a.js-select-watch
+          i.fa.fa-eye.colorful
+          | {{_ 'watching'}}
+          if watchCheck
+            i.fa.fa-check
+          span.sub-name {{_ 'watching-info'}}
+    li
+      with "tracking"
+        a.js-select-watch
+          i.fa.fa-user.colorful
+          | {{_ 'tracking'}}
+          if watchCheck
+            i.fa.fa-check
+          span.sub-name {{_ 'tracking-info'}}
+    li
+      with "muted"
+        a.js-select-watch
+          i.fa.fa-times-circle.colorful
+          | {{_ 'muted'}}
+          if watchCheck
+            i.fa.fa-check
+          span.sub-name {{_ 'muted-info'}}
+
 template(name="boardChangeColorPopup")
 template(name="boardChangeColorPopup")
   .board-backgrounds-list.clearfix
   .board-backgrounds-list.clearfix
     each backgroundColors
     each backgroundColors

+ 28 - 0
client/components/boards/boardHeader.js

@@ -41,6 +41,11 @@ Template.boardChangeTitlePopup.events({
 });
 });
 
 
 BlazeComponent.extendComponent({
 BlazeComponent.extendComponent({
+  watchLevel() {
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    return currentBoard.getWatchLevel(Meteor.userId());
+  },
+
   isStarred() {
   isStarred() {
     const boardId = Session.get('currentBoard');
     const boardId = Session.get('currentBoard');
     const user = Meteor.user();
     const user = Meteor.user();
@@ -65,6 +70,7 @@ BlazeComponent.extendComponent({
       },
       },
       'click .js-open-board-menu': Popup.open('boardMenu'),
       'click .js-open-board-menu': Popup.open('boardMenu'),
       'click .js-change-visibility': Popup.open('boardChangeVisibility'),
       'click .js-change-visibility': Popup.open('boardChangeVisibility'),
+      'click .js-watch-board': Popup.open('boardChangeWatch'),
       'click .js-open-filter-view'() {
       'click .js-open-filter-view'() {
         Sidebar.setView('filter');
         Sidebar.setView('filter');
       },
       },
@@ -176,3 +182,25 @@ BlazeComponent.extendComponent({
     }];
     }];
   },
   },
 }).register('boardChangeVisibilityPopup');
 }).register('boardChangeVisibilityPopup');
+
+BlazeComponent.extendComponent({
+  watchLevel() {
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    return currentBoard.getWatchLevel(Meteor.userId());
+  },
+
+  watchCheck() {
+    return this.currentData() === this.watchLevel();
+  },
+
+  events() {
+    return [{
+      'click .js-select-watch'() {
+        const level = this.currentData();
+        Meteor.call('watch', 'board', Session.get('currentBoard'), level, (err, ret) => {
+          if (!err && ret) Popup.close();
+        });
+      },
+    }];
+  },
+}).register('boardChangeWatchPopup');

+ 5 - 0
client/components/cards/cardDetails.jade

@@ -10,6 +10,8 @@ template(name="cardDetails")
         h2.card-details-title.js-card-title(
         h2.card-details-title.js-card-title(
           class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
           class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
             = title
             = title
+            if isWatching
+              i.fa.fa-eye.card-details-watch
 
 
     if archived
     if archived
       p.warning {{_ 'card-archived'}}
       p.warning {{_ 'card-archived'}}
@@ -82,6 +84,9 @@ template(name="editCardTitleForm")
     a.fa.fa-times-thin.js-close-inlined-form
     a.fa.fa-times-thin.js-close-inlined-form
 
 
 template(name="cardDetailsActionsPopup")
 template(name="cardDetailsActionsPopup")
+  ul.pop-over-list
+    li: a.js-toggle-watch-card {{#if isWatching}}{{_ 'unwatch'}}{{else}}{{_ 'watch'}}{{/if}}
+  hr
   ul.pop-over-list
   ul.pop-over-list
     li: a.js-members {{_ 'card-edit-members'}}
     li: a.js-members {{_ 'card-edit-members'}}
     li: a.js-labels {{_ 'card-edit-labels'}}
     li: a.js-labels {{_ 'card-edit-labels'}}

+ 18 - 0
client/components/cards/cardDetails.js

@@ -23,6 +23,11 @@ BlazeComponent.extendComponent({
     this.calculateNextPeak();
     this.calculateNextPeak();
   },
   },
 
 
+  isWatching() {
+    const card = this.currentData();
+    return card.findWatcher(Meteor.userId());
+  },
+
   scrollParentContainer() {
   scrollParentContainer() {
     const cardPanelWidth = 510;
     const cardPanelWidth = 510;
     const bodyBoardComponent = this.parentComponent();
     const bodyBoardComponent = this.parentComponent();
@@ -128,6 +133,12 @@ BlazeComponent.extendComponent({
   }
   }
 }).register('inlinedCardDescription');
 }).register('inlinedCardDescription');
 
 
+Template.cardDetailsActionsPopup.helpers({
+  isWatching() {
+    return this.findWatcher(Meteor.userId());
+  },
+});
+
 Template.cardDetailsActionsPopup.events({
 Template.cardDetailsActionsPopup.events({
   'click .js-members': Popup.open('cardMembers'),
   'click .js-members': Popup.open('cardMembers'),
   'click .js-labels': Popup.open('cardLabels'),
   'click .js-labels': Popup.open('cardLabels'),
@@ -139,6 +150,13 @@ Template.cardDetailsActionsPopup.events({
     Popup.close();
     Popup.close();
   },
   },
   'click .js-more': Popup.open('cardMore'),
   'click .js-more': Popup.open('cardMore'),
+  'click .js-toggle-watch-card'() {
+    const currentCard = this;
+    const level = currentCard.findWatcher(Meteor.userId()) ? null : 'watching';
+    Meteor.call('watch', 'card', currentCard._id, level, (err, ret) => {
+      if (!err && ret) Popup.close();
+    });
+  },
 });
 });
 
 
 Template.editCardTitleForm.onRendered(function() {
 Template.editCardTitleForm.onRendered(function() {

+ 5 - 0
client/components/cards/cardDetails.styl

@@ -36,6 +36,11 @@
       font-size: 17px
       font-size: 17px
       padding: 10px
       padding: 10px
 
 
+    .card-details-watch
+      font-size: 17px
+      padding-left: 7px
+      color: #a6a6a6
+
     .card-details-title
     .card-details-title
       font-weight: bold
       font-weight: bold
       font-size: 1.33em
       font-size: 1.33em

+ 4 - 0
client/components/lists/list.styl

@@ -65,6 +65,10 @@
     text-overflow: ellipsis
     text-overflow: ellipsis
     word-wrap: break-word
     word-wrap: break-word
 
 
+  .list-header-watch-icon
+    padding-left: 10px
+    color: #a6a6a6
+
   .list-header-menu-icon
   .list-header-menu-icon
     position: absolute
     position: absolute
     padding: 7px
     padding: 7px

+ 5 - 0
client/components/lists/listHeader.jade

@@ -7,6 +7,8 @@ template(name="listHeader")
         class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
         class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
         = title
         = title
       if currentUser.isBoardMember
       if currentUser.isBoardMember
+        if isWatching
+          i.list-header-watch-icon.fa.fa-eye
         a.list-header-menu-icon.fa.fa-navicon.js-open-list-menu
         a.list-header-menu-icon.fa.fa-navicon.js-open-list-menu
 
 
 template(name="editListTitleForm")
 template(name="editListTitleForm")
@@ -17,6 +19,9 @@ template(name="editListTitleForm")
       a.fa.fa-times-thin.js-close-inlined-form
       a.fa.fa-times-thin.js-close-inlined-form
 
 
 template(name="listActionPopup")
 template(name="listActionPopup")
+  ul.pop-over-list
+    li: a.js-toggle-watch-list {{#if isWatching}}{{_ 'unwatch'}}{{else}}{{_ 'watch'}}{{/if}}
+  hr
   ul.pop-over-list
   ul.pop-over-list
     li: a.js-add-card {{_ 'add-card'}}
     li: a.js-add-card {{_ 'add-card'}}
     if cards.count
     if cards.count

+ 18 - 0
client/components/lists/listHeader.js

@@ -8,6 +8,11 @@ BlazeComponent.extendComponent({
     }
     }
   },
   },
 
 
+  isWatching() {
+    const list = this.currentData();
+    return list.findWatcher(Meteor.userId());
+  },
+
   events() {
   events() {
     return [{
     return [{
       'click .js-open-list-menu': Popup.open('listAction'),
       'click .js-open-list-menu': Popup.open('listAction'),
@@ -16,6 +21,12 @@ BlazeComponent.extendComponent({
   },
   },
 }).register('listHeader');
 }).register('listHeader');
 
 
+Template.listActionPopup.helpers({
+  isWatching() {
+    return this.findWatcher(Meteor.userId());
+  },
+});
+
 Template.listActionPopup.events({
 Template.listActionPopup.events({
   'click .js-add-card'() {
   'click .js-add-card'() {
     const listDom = document.getElementById(`js-list-${this._id}`);
     const listDom = document.getElementById(`js-list-${this._id}`);
@@ -29,6 +40,13 @@ Template.listActionPopup.events({
     MultiSelection.add(cardIds);
     MultiSelection.add(cardIds);
     Popup.close();
     Popup.close();
   },
   },
+  'click .js-toggle-watch-list'() {
+    const currentList = this;
+    const level = currentList.findWatcher(Meteor.userId()) ? null : 'watching';
+    Meteor.call('watch', 'list', currentList._id, level, (err, ret) => {
+      if (!err && ret) Popup.close();
+    });
+  },
   'click .js-close-list'(evt) {
   'click .js-close-list'(evt) {
     evt.preventDefault();
     evt.preventDefault();
     this.archive();
     this.archive();

+ 3 - 0
client/components/main/header.styl

@@ -18,6 +18,9 @@
       float: left
       float: left
       border-radius: 3px
       border-radius: 3px
 
 
+      .board-header-watch-icon
+        padding-left: 7px
+
       a.fa, a i.fa
       a.fa, a i.fa
         color: white
         color: white
 
 

+ 4 - 1
client/components/main/layouts.styl

@@ -279,9 +279,12 @@ kbd
 .fa.fa-globe.colorful
 .fa.fa-globe.colorful
   color: #4caf50
   color: #4caf50
 
 
-.fa.fa-lock.colorful
+.fa.fa-lock.colorful, .fa.fa-times-circle.colorful
   color: #f44336
   color: #f44336
 
 
+.fa.fa-user.colorful, .fa.fa-eye.colorful, .fa.fa-circle.colorful
+  color: #4336f4
+
 .pop-over .pop-over-list li a:not(.disabled):hover
 .pop-over .pop-over-list li a:not(.disabled):hover
   .fa, .fa.colorful
   .fa, .fa.colorful
     color: white
     color: white

+ 18 - 0
client/components/users/userHeader.jade

@@ -15,6 +15,7 @@ template(name="memberMenuPopup")
     li: a.js-change-avatar {{_ 'edit-avatar'}}
     li: a.js-change-avatar {{_ 'edit-avatar'}}
     li: a.js-change-password {{_ 'changePasswordPopup-title'}}
     li: a.js-change-password {{_ 'changePasswordPopup-title'}}
     li: a.js-change-language {{_ 'changeLanguagePopup-title'}}
     li: a.js-change-language {{_ 'changeLanguagePopup-title'}}
+    li: a.js-edit-notification {{_ 'editNotificationPopup-title'}}
   hr
   hr
   ul.pop-over-list
   ul.pop-over-list
     li: a.js-logout {{_ 'log-out'}}
     li: a.js-logout {{_ 'log-out'}}
@@ -32,6 +33,23 @@ template(name="editProfilePopup")
       input.js-profile-initials(type="text" value=profile.initials)
       input.js-profile-initials(type="text" value=profile.initials)
     input.primary.wide(type="submit" value="{{_ 'save'}}")
     input.primary.wide(type="submit" value="{{_ 'save'}}")
 
 
+template(name="editNotificationPopup")
+  ul.pop-over-list
+    li
+      a.js-toggle-tag-notify-watch
+        i.fa.fa-eye.colorful
+        | {{_ 'watching'}}
+        if hasTag "notify-watch"
+          i.fa.fa-check
+        span.sub-name {{_ 'notify-watch'}}
+    li
+      a.js-toggle-tag-notify-participate
+        i.fa.fa-user.colorful
+        | {{_ 'tracking'}}
+        if hasTag "notify-participate"
+          i.fa.fa-check
+        span.sub-name {{_ 'notify-participate'}}
+
 template(name="changePasswordPopup")
 template(name="changePasswordPopup")
   +atForm(state='changePwd')
   +atForm(state='changePwd')
 
 

+ 20 - 0
client/components/users/userHeader.js

@@ -8,6 +8,7 @@ Template.memberMenuPopup.events({
   'click .js-change-avatar': Popup.open('changeAvatar'),
   'click .js-change-avatar': Popup.open('changeAvatar'),
   'click .js-change-password': Popup.open('changePassword'),
   'click .js-change-password': Popup.open('changePassword'),
   'click .js-change-language': Popup.open('changeLanguage'),
   'click .js-change-language': Popup.open('changeLanguage'),
+  'click .js-edit-notification': Popup.open('editNotification'),
   'click .js-logout'(evt) {
   'click .js-logout'(evt) {
     evt.preventDefault();
     evt.preventDefault();
 
 
@@ -33,6 +34,25 @@ Template.editProfilePopup.events({
   },
   },
 });
 });
 
 
+Template.editNotificationPopup.helpers({
+  hasTag(tag) {
+    const user = Meteor.user();
+    return user && user.hasTag(tag);
+  },
+});
+
+// we defined github like rules, see: https://github.com/settings/notifications
+Template.editNotificationPopup.events({
+  'click .js-toggle-tag-notify-participate'() {
+    const user = Meteor.user();
+    if (user) user.toggleTag('notify-participate');
+  },
+  'click .js-toggle-tag-notify-watch'() {
+    const user = Meteor.user();
+    if (user) user.toggleTag('notify-watch');
+  },
+});
+
 // XXX For some reason the useraccounts autofocus isnt working in this case.
 // XXX For some reason the useraccounts autofocus isnt working in this case.
 // See https://github.com/meteor-useraccounts/core/issues/384
 // See https://github.com/meteor-useraccounts/core/issues/384
 Template.changePasswordPopup.onRendered(function() {
 Template.changePasswordPopup.onRendered(function() {

+ 34 - 1
i18n/en.i18n.json

@@ -1,5 +1,25 @@
 {
 {
     "accept": "Accept",
     "accept": "Accept",
+    "act-activity-notify": "[Wekan] Activity Notification",
+    "act-addAttachment": "attached __attachment__ to __card__",
+    "act-addComment": "commented on __card__: __comment__",
+    "act-createBoard": "created __board__",
+    "act-createCard": "added __card__ to __list__",
+    "act-createList": "added __list__ to __board__",
+    "act-addBoardMember": "added __member__ to __board__",
+    "act-archivedBoard": "archived __board__",
+    "act-archivedCard": "archived __card__",
+    "act-archivedList": "archived __list__",
+    "act-importBoard": "imported __board__",
+    "act-importCard": "imported __card__",
+    "act-importList": "imported __list__",
+    "act-joinMember": "added __member__ to __card__",
+    "act-moveCard": "moved __card__ from __oldList__ to __list__",
+    "act-removeBoardMember": "removed __member__ from __board__",
+    "act-restoredCard": "restored __card__ to __board__",
+    "act-unjoinMember": "removed __member__ from __card__",
+    "act-withBoardTitle": "[Wekan] __board__",
+    "act-withCardTitle": "[__board__] __card__",
     "actions": "Actions",
     "actions": "Actions",
     "activities": "Activities",
     "activities": "Activities",
     "activity": "Activity",
     "activity": "Activity",
@@ -46,6 +66,7 @@
     "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.",
     "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.",
     "attachmentDeletePopup-title": "Delete Attachment?",
     "attachmentDeletePopup-title": "Delete Attachment?",
     "attachments": "Attachments",
     "attachments": "Attachments",
+    "auto-watch": "Automatically watch boards when create it",
     "avatar-too-big": "The avatar is too large (70Kb max)",
     "avatar-too-big": "The avatar is too large (70Kb max)",
     "back": "Back",
     "back": "Back",
     "board-change-color": "Change color",
     "board-change-color": "Change color",
@@ -56,6 +77,7 @@
     "boardChangeColorPopup-title": "Change Board Background",
     "boardChangeColorPopup-title": "Change Board Background",
     "boardChangeTitlePopup-title": "Rename Board",
     "boardChangeTitlePopup-title": "Rename Board",
     "boardChangeVisibilityPopup-title": "Change Visibility",
     "boardChangeVisibilityPopup-title": "Change Visibility",
+    "boardChangeWatchPopup-title": "Change Watch",
     "boardImportBoardPopup-title": "Import board from Trello",
     "boardImportBoardPopup-title": "Import board from Trello",
     "boardMenuPopup-title": "Board Menu",
     "boardMenuPopup-title": "Board Menu",
     "boards": "Boards",
     "boards": "Boards",
@@ -121,9 +143,9 @@
     "download": "Download",
     "download": "Download",
     "edit": "Edit",
     "edit": "Edit",
     "edit-avatar": "Change Avatar",
     "edit-avatar": "Change Avatar",
-    "edit-profile": "Edit profile",
     "edit-profile": "Edit Profile",
     "edit-profile": "Edit Profile",
     "editLabelPopup-title": "Change Label",
     "editLabelPopup-title": "Change Label",
+    "editNotificationPopup-title": "Edit Notification",
     "editProfilePopup-title": "Edit Profile",
     "editProfilePopup-title": "Edit Profile",
     "email": "Email",
     "email": "Email",
     "email-enrollAccount-subject": "An account created for you on __siteName__",
     "email-enrollAccount-subject": "An account created for you on __siteName__",
@@ -198,6 +220,8 @@
     "moveSelectionPopup-title": "Move selection",
     "moveSelectionPopup-title": "Move selection",
     "multi-selection": "Multi-Selection",
     "multi-selection": "Multi-Selection",
     "multi-selection-on": "Multi-Selection is on",
     "multi-selection-on": "Multi-Selection is on",
+    "muted": "Muted",
+    "muted-info": "You will never be notified of any changes in this board",
     "my-boards": "My Boards",
     "my-boards": "My Boards",
     "name": "Name",
     "name": "Name",
     "name": "Name",
     "name": "Name",
@@ -207,12 +231,15 @@
     "normal": "Normal",
     "normal": "Normal",
     "normal-desc": "Can view and edit cards. Can't change settings.",
     "normal-desc": "Can view and edit cards. Can't change settings.",
     "not-accepted-yet": "Invitation not accepted yet",
     "not-accepted-yet": "Invitation not accepted yet",
+    "notify-participate": "Receive updates to any cards you participate as creater or member",
+    "notify-watch": "Receive updates to any boards, lists, or cards you’re watching",
     "optional": "optional",
     "optional": "optional",
     "or": "or",
     "or": "or",
     "page-maybe-private": "This page may be private. You may be able to view it by <a href='%s'>logging in</a>.",
     "page-maybe-private": "This page may be private. You may be able to view it by <a href='%s'>logging in</a>.",
     "page-not-found": "Page not found.",
     "page-not-found": "Page not found.",
     "password": "Password",
     "password": "Password",
     "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)",
     "paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)",
+    "participating": "Participating",
     "preview": "Preview",
     "preview": "Preview",
     "previewAttachedImagePopup-title": "Preview",
     "previewAttachedImagePopup-title": "Preview",
     "previewClipboardImagePopup-title": "Preview",
     "previewClipboardImagePopup-title": "Preview",
@@ -253,13 +280,19 @@
     "this-board": "this board",
     "this-board": "this board",
     "this-card": "this card",
     "this-card": "this card",
     "title": "Title",
     "title": "Title",
+    "tracking": "Tracking",
+    "tracking-info": "You will be notified of any changes to those cards you are involved as creator or member.",
     "unassign-member": "Unassign member",
     "unassign-member": "Unassign member",
     "unsaved-description": "You have an unsaved description.",
     "unsaved-description": "You have an unsaved description.",
+    "unwatch": "Unwatch",
     "upload": "Upload",
     "upload": "Upload",
     "upload-avatar": "Upload an avatar",
     "upload-avatar": "Upload an avatar",
     "uploaded-avatar": "Uploaded an avatar",
     "uploaded-avatar": "Uploaded an avatar",
     "username": "Username",
     "username": "Username",
     "view-it": "View it",
     "view-it": "View it",
     "warn-list-archived": "warning: this card is in an archived list",
     "warn-list-archived": "warning: this card is in an archived list",
+    "watch": "Watch",
+    "watching": "Watching",
+    "watching-info": "You will be notified of any change in this board",
     "what-to-do": "What do you want to do?"
     "what-to-do": "What do you want to do?"
 }
 }

+ 73 - 0
models/activities.js

@@ -48,4 +48,77 @@ if (Meteor.isServer) {
       createdAt: -1,
       createdAt: -1,
     });
     });
   });
   });
+
+  Activities.after.insert((userId, doc) => {
+    const activity = Activities._transform(doc);
+    let participants = [];
+    let watchers = [];
+    let title = 'act-activity-notify';
+    let board = null;
+    const description = `act-${activity.activityType}`;
+    const params = {
+      activityId: activity._id,
+    };
+    if (activity.userId) {
+      // No need send notification to user of activity
+      // participants = _.union(participants, [activity.userId]);
+      params.user = activity.user().getName();
+    }
+    if (activity.boardId) {
+      board = activity.board();
+      params.board = board.title;
+      title = 'act-withBoardTitle';
+      params.url = board.absoluteUrl();
+    }
+    if (activity.memberId) {
+      participants = _.union(participants, [activity.memberId]);
+      params.member = activity.member().getName();
+    }
+    if (activity.listId) {
+      const list = activity.list();
+      watchers = _.union(watchers, list.watchers || []);
+      params.list = list.title;
+    }
+    if (activity.oldListId) {
+      const oldList = activity.oldList();
+      watchers = _.union(watchers, oldList.watchers || []);
+      params.oldList = oldList.title;
+    }
+    if (activity.cardId) {
+      const card = activity.card();
+      participants = _.union(participants, [card.userId], card.members || []);
+      watchers = _.union(watchers, card.watchers || []);
+      params.card = card.title;
+      title = 'act-withCardTitle';
+      params.url = card.absoluteUrl();
+    }
+    if (activity.commentId) {
+      const comment = activity.comment();
+      params.comment = comment.text;
+    }
+    if (activity.attachmentId) {
+      const attachment = activity.attachment();
+      params.attachment = attachment._id;
+    }
+    if (board) {
+      const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId');
+      const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId');
+      const mutedUsers = _.pluck(_.where(board.watchers, {level: 'muted'}), 'userId');
+      switch(board.getWatchDefault()) {
+      case 'muted':
+        participants = _.intersection(participants, trackingUsers);
+        watchers = _.intersection(watchers, trackingUsers);
+        break;
+      case 'tracking':
+        participants = _.difference(participants, mutedUsers);
+        watchers = _.difference(watchers, mutedUsers);
+        break;
+      }
+      watchers = _.union(watchers, watchingUsers || []);
+    }
+
+    Notifications.getUsers(participants, watchers).forEach((user) => {
+      Notifications.notify(user, title, description, params);
+    });
+  });
 }
 }

+ 1 - 1
models/boards.js

@@ -151,7 +151,7 @@ Boards.helpers({
   },
   },
 
 
   absoluteUrl() {
   absoluteUrl() {
-    return FlowRouter.path('board', { id: this._id, slug: this.slug });
+    return FlowRouter.url('board', { id: this._id, slug: this.slug });
   },
   },
 
 
   colorClass() {
   colorClass() {

+ 1 - 5
models/cards.js

@@ -116,16 +116,12 @@ Cards.helpers({
 
 
   absoluteUrl() {
   absoluteUrl() {
     const board = this.board();
     const board = this.board();
-    return FlowRouter.path('card', {
+    return FlowRouter.url('card', {
       boardId: board._id,
       boardId: board._id,
       slug: board.slug,
       slug: board.slug,
       cardId: this._id,
       cardId: this._id,
     });
     });
   },
   },
-
-  rootUrl() {
-    return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
-  },
 });
 });
 
 
 Cards.mutations({
 Cards.mutations({

+ 74 - 7
models/users.js

@@ -47,6 +47,21 @@ Users.helpers({
     return _.contains(invitedBoards, boardId);
     return _.contains(invitedBoards, boardId);
   },
   },
 
 
+  hasTag(tag) {
+    const {tags = []} = this.profile;
+    return _.contains(tags, tag);
+  },
+
+  hasNotification(activityId) {
+    const {notifications = []} = this.profile;
+    return _.contains(notifications, activityId);
+  },
+
+  getEmailBuffer() {
+    const {emailBuffer = []} = this.profile;
+    return emailBuffer;
+  },
+
   getInitials() {
   getInitials() {
     const profile = this.profile || {};
     const profile = this.profile || {};
     if (profile.initials)
     if (profile.initials)
@@ -99,6 +114,61 @@ Users.mutations({
     };
     };
   },
   },
 
 
+  addTag(tag) {
+    return {
+      $addToSet: {
+        'profile.tags': tag,
+      },
+    };
+  },
+
+  removeTag(tag) {
+    return {
+      $pull: {
+        'profile.tags': tag,
+      },
+    };
+  },
+
+  toggleTag(tag) {
+    if (this.hasTag(tag))
+      this.removeTag(tag);
+    else
+      this.addTag(tag);
+  },
+
+  addNotification(activityId) {
+    return {
+      $addToSet: {
+        'profile.notifications': activityId,
+      },
+    };
+  },
+
+  removeNotification(activityId) {
+    return {
+      $pull: {
+        'profile.notifications': activityId,
+      },
+    };
+  },
+
+  addEmailBuffer(text) {
+    return {
+      $addToSet: {
+        'profile.emailBuffer': text,
+      },
+    };
+  },
+
+  clearEmailBuffer() {
+    return {
+      $set: {
+        'profile.emailBuffer': [],
+      },
+    };
+  },
+
   setAvatarUrl(avatarUrl) {
   setAvatarUrl(avatarUrl) {
     return { $set: { 'profile.avatarUrl': avatarUrl }};
     return { $set: { 'profile.avatarUrl': avatarUrl }};
   },
   },
@@ -167,21 +237,18 @@ if (Meteor.isServer) {
       user.addInvite(boardId);
       user.addInvite(boardId);
 
 
       try {
       try {
-        const { _id, slug } = board;
-        const boardUrl = FlowRouter.url('board', { id: _id, slug });
-
-        const vars = {
+        const params = {
           user: user.username,
           user: user.username,
           inviter: inviter.username,
           inviter: inviter.username,
           board: board.title,
           board: board.title,
-          url: boardUrl,
+          url: board.absoluteUrl(),
         };
         };
         const lang = user.getLanguage();
         const lang = user.getLanguage();
         Email.send({
         Email.send({
           to: user.emails[0].address,
           to: user.emails[0].address,
           from: Accounts.emailTemplates.from,
           from: Accounts.emailTemplates.from,
-          subject: TAPi18n.__('email-invite-subject', vars, lang),
-          text: TAPi18n.__('email-invite-text', vars, lang),
+          subject: TAPi18n.__('email-invite-subject', params, lang),
+          text: TAPi18n.__('email-invite-text', params, lang),
         });
         });
       } catch (e) {
       } catch (e) {
         throw new Meteor.Error('email-fail', e.message);
         throw new Meteor.Error('email-fail', e.message);

+ 89 - 0
models/watchable.js

@@ -0,0 +1,89 @@
+// simple version, only toggle watch / unwatch
+const simpleWatchable = (collection) => {
+  collection.attachSchema({
+    watchers: {
+      type: [String],
+      optional: true,
+    },
+  });
+
+  collection.helpers({
+    getWatchLevels() {
+      return [true, false];
+    },
+
+    watcherIndex(userId) {
+      return this.watchers.indexOf(userId);
+    },
+
+    findWatcher(userId) {
+      return _.contains(this.watchers, userId);
+    },
+  });
+
+  collection.mutations({
+    setWatcher(userId, level) {
+      // if level undefined or null or false, then remove
+      if (!level) return { $pull: { watchers: userId }};
+      return { $addToSet: { watchers: userId }};
+    },
+  });
+};
+
+// more complex version of same interface, with 3 watching levels
+const complexWatchOptions = ['watching', 'tracking', 'muted'];
+const complexWatchDefault = 'muted';
+
+const complexWatchable = (collection) => {
+  collection.attachSchema({
+    'watchers.$.userId': {
+      type: String,
+    },
+    'watchers.$.level': {
+      type: String,
+      allowedValues: complexWatchOptions,
+    },
+  });
+
+  collection.helpers({
+    getWatchOptions() {
+      return complexWatchOptions;
+    },
+
+    getWatchDefault() {
+      return complexWatchDefault;
+    },
+
+    watcherIndex(userId) {
+      return _.pluck(this.watchers, 'userId').indexOf(userId);
+    },
+
+    findWatcher(userId) {
+      return _.findWhere(this.watchers, { userId });
+    },
+
+    getWatchLevel(userId) {
+      const watcher = this.findWatcher(userId);
+      return watcher ? watcher.level : complexWatchDefault;
+    },
+  });
+
+  collection.mutations({
+    setWatcher(userId, level) {
+      // if level undefined or null or false, then remove
+      if (level === complexWatchDefault) level = null;
+      if (!level) return { $pull: { watchers: { userId }}};
+      const index = this.watcherIndex(userId);
+      if (index<0) return { $push: { watchers: { userId, level }}};
+      return {
+        $set: {
+          [`watchers.${index}.level`]: level,
+        },
+      };
+    },
+  });
+};
+
+complexWatchable(Boards);
+simpleWatchable(Lists);
+simpleWatchable(Cards);

+ 41 - 0
server/notifications/email.js

@@ -0,0 +1,41 @@
+// buffer each user's email text in a queue, then flush them in single email
+Meteor.startup(() => {
+  Notifications.subscribe('email', (user, title, description, params) => {
+    // add quote to make titles easier to read in email text
+    const quoteParams = _.clone(params);
+    ['card', 'list', 'oldList', 'board', 'comment'].forEach((key) => {
+      if (quoteParams[key]) quoteParams[key] = `"${params[key]}"`;
+    });
+
+    const text = `${params.user} ${TAPi18n.__(description, quoteParams, user.getLanguage())}\n${params.url}`;
+    user.addEmailBuffer(text);
+
+    // unlike setTimeout(func, delay, args),
+    // Meteor.setTimeout(func, delay) does not accept args :-(
+    // so we pass userId with closure
+    const userId = user._id;
+    Meteor.setTimeout(() => {
+      const user = Users.findOne(userId);
+
+      // for each user, in the timed period, only the first call will get the cached content,
+      // other calls will get nothing
+      const texts = user.getEmailBuffer();
+      if (texts.length === 0) return;
+
+      // merge the cached content into single email and flush
+      const text = texts.join('\n\n');
+      user.clearEmailBuffer();
+
+      try {
+        Email.send({
+          to: user.emails[0].address,
+          from: Accounts.emailTemplates.from,
+          subject: TAPi18n.__('act-activity-notify', {}, user.getLanguage()),
+          text,
+        });
+      } catch (e) {
+        return;
+      }
+    }, 30000);
+  });
+});

+ 48 - 0
server/notifications/notifications.js

@@ -0,0 +1,48 @@
+// a map of notification service, like email, web, IM, qq, etc.
+
+// serviceName -> callback(user, title, description, params)
+// expected arguments to callback:
+// - user: Meteor user object
+// - title: String, TAPi18n key
+// - description, String, TAPi18n key
+// - params: Object, values extracted from context, to used for above two TAPi18n keys
+//   see example call to Notifications.notify() in models/activities.js
+const notifyServices = {};
+
+Notifications = {
+  subscribe: (serviceName, callback) => {
+    notifyServices[serviceName] = callback;
+  },
+
+  unsubscribe: (serviceName) => {
+    if (typeof notifyServices[serviceName] === 'function')
+      delete notifyServices[serviceName];
+  },
+
+  // filter recipients according to user settings for notification
+  getUsers: (participants, watchers) => {
+    const userMap = {};
+    participants.forEach((userId) => {
+      if (userMap[userId]) return;
+      const user = Users.findOne(userId);
+      if (user && user.hasTag('notify-participate')) {
+        userMap[userId] = user;
+      }
+    });
+    watchers.forEach((userId) => {
+      if (userMap[userId]) return;
+      const user = Users.findOne(userId);
+      if (user && user.hasTag('notify-watch')) {
+        userMap[userId] = user;
+      }
+    });
+    return _.map(userMap, (v) => v);
+  },
+
+  notify: (user, title, description, params) => {
+    for(const k in notifyServices) {
+      const notifyImpl = notifyServices[k];
+      if (notifyImpl && typeof notifyImpl === 'function') notifyImpl(user, title, description, params);
+    }
+  },
+};

+ 9 - 0
server/notifications/profile.js

@@ -0,0 +1,9 @@
+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);
+  // });
+});

+ 36 - 0
server/notifications/watch.js

@@ -0,0 +1,36 @@
+Meteor.methods({
+  watch(watchableType, id, level) {
+    check(watchableType, String);
+    check(id, String);
+    check(level, Match.OneOf(String, null));
+
+    const userId = Meteor.userId();
+
+    let watchableObj = null;
+    let board = null;
+    if (watchableType === 'board') {
+      watchableObj = Boards.findOne(id);
+      if (!watchableObj) throw new Meteor.Error('error-board-doesNotExist');
+      board = watchableObj;
+
+    } else if (watchableType === 'list') {
+      watchableObj = Lists.findOne(id);
+      if (!watchableObj) throw new Meteor.Error('error-list-doesNotExist');
+      board = watchableObj.board();
+
+    } else if (watchableType === 'card') {
+      watchableObj = Cards.findOne(id);
+      if (!watchableObj) throw new Meteor.Error('error-card-doesNotExist');
+      board = watchableObj.board();
+
+    } else {
+      throw new Meteor.Error('error-json-schema');
+    }
+
+    if ((board.permission === 'private') && !board.hasMember(userId))
+      throw new Meteor.Error('error-board-notAMember');
+
+    watchableObj.setWatcher(userId, level);
+    return true;
+  },
+});