summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMaxime Quandalle <maxime@quandalle.com>2016-01-06 12:49:58 +0100
committerMaxime Quandalle <maxime@quandalle.com>2016-01-06 12:49:58 +0100
commit1e8368dea53977292a8f49d3bef9032ab068627b (patch)
treeac7f3f4be8db0c024012b0707b3e8f7b9ceb2b86
parent61e6e71f294f2a6117e53d5e0a4597b7bf9d80de (diff)
parent39e1cc02374b3a379de87bdcb95a7a343b698a05 (diff)
downloadwekan-1e8368dea53977292a8f49d3bef9032ab068627b.tar.gz
wekan-1e8368dea53977292a8f49d3bef9032ab068627b.tar.bz2
wekan-1e8368dea53977292a8f49d3bef9032ab068627b.zip
Merge pull request #454 from floatinghotpot/notification
Add notifications, allow watch boards / lists / cards
-rw-r--r--.eslintrc1
-rw-r--r--client/components/activities/activities.js2
-rw-r--r--client/components/boards/boardHeader.jade49
-rw-r--r--client/components/boards/boardHeader.js28
-rw-r--r--client/components/cards/cardDetails.jade5
-rw-r--r--client/components/cards/cardDetails.js18
-rw-r--r--client/components/cards/cardDetails.styl5
-rw-r--r--client/components/lists/list.styl4
-rw-r--r--client/components/lists/listHeader.jade5
-rw-r--r--client/components/lists/listHeader.js18
-rw-r--r--client/components/main/header.styl3
-rw-r--r--client/components/main/layouts.styl5
-rw-r--r--client/components/users/userHeader.jade18
-rw-r--r--client/components/users/userHeader.js20
-rw-r--r--i18n/en.i18n.json35
-rw-r--r--models/activities.js73
-rw-r--r--models/boards.js2
-rw-r--r--models/cards.js6
-rw-r--r--models/users.js81
-rw-r--r--models/watchable.js89
-rw-r--r--server/notifications/email.js41
-rw-r--r--server/notifications/notifications.js48
-rw-r--r--server/notifications/profile.js9
-rw-r--r--server/notifications/watch.js36
24 files changed, 585 insertions, 16 deletions
diff --git a/.eslintrc b/.eslintrc
index d3f81624..eafd3483 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -115,6 +115,7 @@ globals:
Utils: true
InlinedForm: true
UnsavedEdits: true
+ Notifications: true
# XXX Temp, we should remove these
allowIsBoardAdmin: true
diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js
index f6417380..0bd6a272 100644
--- a/client/components/activities/activities.js
+++ b/client/components/activities/activities.js
@@ -51,7 +51,7 @@ BlazeComponent.extendComponent({
cardLink() {
const card = this.currentData().card();
return card && Blaze.toHTML(HTML.A({
- href: FlowRouter.path(card.absoluteUrl()),
+ href: card.absoluteUrl(),
'class': 'action-card',
}, card.title));
},
diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade
index fe6b56e6..ce8999c3 100644
--- a/client/components/boards/boardHeader.jade
+++ b/client/components/boards/boardHeader.jade
@@ -19,6 +19,17 @@ template(name="boardHeaderBar")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
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
if isMiniScreen
unless isSandstorm
@@ -34,6 +45,17 @@ template(name="boardHeaderBar")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
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(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{/if}}"
class="{{#if Filter.isActive}}emphasis{{/if}}")
@@ -97,6 +119,33 @@ template(name="boardVisibilityList")
template(name="boardChangeVisibilityPopup")
+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")
.board-backgrounds-list.clearfix
each backgroundColors
diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js
index fb19d9c6..055b6f27 100644
--- a/client/components/boards/boardHeader.js
+++ b/client/components/boards/boardHeader.js
@@ -41,6 +41,11 @@ Template.boardChangeTitlePopup.events({
});
BlazeComponent.extendComponent({
+ watchLevel() {
+ const currentBoard = Boards.findOne(Session.get('currentBoard'));
+ return currentBoard.getWatchLevel(Meteor.userId());
+ },
+
isStarred() {
const boardId = Session.get('currentBoard');
const user = Meteor.user();
@@ -65,6 +70,7 @@ BlazeComponent.extendComponent({
},
'click .js-open-board-menu': Popup.open('boardMenu'),
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
+ 'click .js-watch-board': Popup.open('boardChangeWatch'),
'click .js-open-filter-view'() {
Sidebar.setView('filter');
},
@@ -176,3 +182,25 @@ BlazeComponent.extendComponent({
}];
},
}).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');
diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade
index 3f2ae34a..2ca032ef 100644
--- a/client/components/cards/cardDetails.jade
+++ b/client/components/cards/cardDetails.jade
@@ -10,6 +10,8 @@ template(name="cardDetails")
h2.card-details-title.js-card-title(
class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
= title
+ if isWatching
+ i.fa.fa-eye.card-details-watch
if archived
p.warning {{_ 'card-archived'}}
@@ -83,6 +85,9 @@ template(name="editCardTitleForm")
template(name="cardDetailsActionsPopup")
ul.pop-over-list
+ li: a.js-toggle-watch-card {{#if isWatching}}{{_ 'unwatch'}}{{else}}{{_ 'watch'}}{{/if}}
+ hr
+ ul.pop-over-list
li: a.js-members {{_ 'card-edit-members'}}
li: a.js-labels {{_ 'card-edit-labels'}}
li: a.js-attachments {{_ 'card-edit-attachments'}}
diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js
index 2b04adad..d8323393 100644
--- a/client/components/cards/cardDetails.js
+++ b/client/components/cards/cardDetails.js
@@ -23,6 +23,11 @@ BlazeComponent.extendComponent({
this.calculateNextPeak();
},
+ isWatching() {
+ const card = this.currentData();
+ return card.findWatcher(Meteor.userId());
+ },
+
scrollParentContainer() {
const cardPanelWidth = 510;
const bodyBoardComponent = this.parentComponent();
@@ -128,6 +133,12 @@ BlazeComponent.extendComponent({
}
}).register('inlinedCardDescription');
+Template.cardDetailsActionsPopup.helpers({
+ isWatching() {
+ return this.findWatcher(Meteor.userId());
+ },
+});
+
Template.cardDetailsActionsPopup.events({
'click .js-members': Popup.open('cardMembers'),
'click .js-labels': Popup.open('cardLabels'),
@@ -139,6 +150,13 @@ Template.cardDetailsActionsPopup.events({
Popup.close();
},
'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() {
diff --git a/client/components/cards/cardDetails.styl b/client/components/cards/cardDetails.styl
index 4ac0578b..d7d29551 100644
--- a/client/components/cards/cardDetails.styl
+++ b/client/components/cards/cardDetails.styl
@@ -36,6 +36,11 @@
font-size: 17px
padding: 10px
+ .card-details-watch
+ font-size: 17px
+ padding-left: 7px
+ color: #a6a6a6
+
.card-details-title
font-weight: bold
font-size: 1.33em
diff --git a/client/components/lists/list.styl b/client/components/lists/list.styl
index 1848459c..cf939a6e 100644
--- a/client/components/lists/list.styl
+++ b/client/components/lists/list.styl
@@ -65,6 +65,10 @@
text-overflow: ellipsis
word-wrap: break-word
+ .list-header-watch-icon
+ padding-left: 10px
+ color: #a6a6a6
+
.list-header-menu-icon
position: absolute
padding: 7px
diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade
index 34824085..f9fe065f 100644
--- a/client/components/lists/listHeader.jade
+++ b/client/components/lists/listHeader.jade
@@ -7,6 +7,8 @@ template(name="listHeader")
class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
= title
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
template(name="editListTitleForm")
@@ -18,6 +20,9 @@ template(name="editListTitleForm")
template(name="listActionPopup")
ul.pop-over-list
+ li: a.js-toggle-watch-list {{#if isWatching}}{{_ 'unwatch'}}{{else}}{{_ 'watch'}}{{/if}}
+ hr
+ ul.pop-over-list
li: a.js-add-card {{_ 'add-card'}}
if cards.count
li: a.js-select-cards {{_ 'list-select-cards'}}
diff --git a/client/components/lists/listHeader.js b/client/components/lists/listHeader.js
index c9eaee12..c7ae8e62 100644
--- a/client/components/lists/listHeader.js
+++ b/client/components/lists/listHeader.js
@@ -8,6 +8,11 @@ BlazeComponent.extendComponent({
}
},
+ isWatching() {
+ const list = this.currentData();
+ return list.findWatcher(Meteor.userId());
+ },
+
events() {
return [{
'click .js-open-list-menu': Popup.open('listAction'),
@@ -16,6 +21,12 @@ BlazeComponent.extendComponent({
},
}).register('listHeader');
+Template.listActionPopup.helpers({
+ isWatching() {
+ return this.findWatcher(Meteor.userId());
+ },
+});
+
Template.listActionPopup.events({
'click .js-add-card'() {
const listDom = document.getElementById(`js-list-${this._id}`);
@@ -29,6 +40,13 @@ Template.listActionPopup.events({
MultiSelection.add(cardIds);
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) {
evt.preventDefault();
this.archive();
diff --git a/client/components/main/header.styl b/client/components/main/header.styl
index fbf2992d..0b2ad860 100644
--- a/client/components/main/header.styl
+++ b/client/components/main/header.styl
@@ -18,6 +18,9 @@
float: left
border-radius: 3px
+ .board-header-watch-icon
+ padding-left: 7px
+
a.fa, a i.fa
color: white
diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl
index b364d91b..7ab4d8ce 100644
--- a/client/components/main/layouts.styl
+++ b/client/components/main/layouts.styl
@@ -279,9 +279,12 @@ kbd
.fa.fa-globe.colorful
color: #4caf50
-.fa.fa-lock.colorful
+.fa.fa-lock.colorful, .fa.fa-times-circle.colorful
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
.fa, .fa.colorful
color: white
diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade
index 8e988c8e..cae768fb 100644
--- a/client/components/users/userHeader.jade
+++ b/client/components/users/userHeader.jade
@@ -15,6 +15,7 @@ template(name="memberMenuPopup")
li: a.js-change-avatar {{_ 'edit-avatar'}}
li: a.js-change-password {{_ 'changePasswordPopup-title'}}
li: a.js-change-language {{_ 'changeLanguagePopup-title'}}
+ li: a.js-edit-notification {{_ 'editNotificationPopup-title'}}
hr
ul.pop-over-list
li: a.js-logout {{_ 'log-out'}}
@@ -32,6 +33,23 @@ template(name="editProfilePopup")
input.js-profile-initials(type="text" value=profile.initials)
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")
+atForm(state='changePwd')
diff --git a/client/components/users/userHeader.js b/client/components/users/userHeader.js
index a478da0c..10fdf699 100644
--- a/client/components/users/userHeader.js
+++ b/client/components/users/userHeader.js
@@ -8,6 +8,7 @@ Template.memberMenuPopup.events({
'click .js-change-avatar': Popup.open('changeAvatar'),
'click .js-change-password': Popup.open('changePassword'),
'click .js-change-language': Popup.open('changeLanguage'),
+ 'click .js-edit-notification': Popup.open('editNotification'),
'click .js-logout'(evt) {
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.
// See https://github.com/meteor-useraccounts/core/issues/384
Template.changePasswordPopup.onRendered(function() {
diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json
index 41e7ec88..109e3b57 100644
--- a/i18n/en.i18n.json
+++ b/i18n/en.i18n.json
@@ -1,5 +1,25 @@
{
"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",
"activities": "Activities",
"activity": "Activity",
@@ -46,6 +66,7 @@
"attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.",
"attachmentDeletePopup-title": "Delete Attachment?",
"attachments": "Attachments",
+ "auto-watch": "Automatically watch boards when create it",
"avatar-too-big": "The avatar is too large (70Kb max)",
"back": "Back",
"board-change-color": "Change color",
@@ -56,6 +77,7 @@
"boardChangeColorPopup-title": "Change Board Background",
"boardChangeTitlePopup-title": "Rename Board",
"boardChangeVisibilityPopup-title": "Change Visibility",
+ "boardChangeWatchPopup-title": "Change Watch",
"boardImportBoardPopup-title": "Import board from Trello",
"boardMenuPopup-title": "Board Menu",
"boards": "Boards",
@@ -121,9 +143,9 @@
"download": "Download",
"edit": "Edit",
"edit-avatar": "Change Avatar",
- "edit-profile": "Edit profile",
"edit-profile": "Edit Profile",
"editLabelPopup-title": "Change Label",
+ "editNotificationPopup-title": "Edit Notification",
"editProfilePopup-title": "Edit Profile",
"email": "Email",
"email-enrollAccount-subject": "An account created for you on __siteName__",
@@ -198,6 +220,8 @@
"moveSelectionPopup-title": "Move selection",
"multi-selection": "Multi-Selection",
"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",
"name": "Name",
"name": "Name",
@@ -207,12 +231,15 @@
"normal": "Normal",
"normal-desc": "Can view and edit cards. Can't change settings.",
"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",
"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-not-found": "Page not found.",
"password": "Password",
"paste-or-dragdrop": "to paste, or drag & drop image file to it (image only)",
+ "participating": "Participating",
"preview": "Preview",
"previewAttachedImagePopup-title": "Preview",
"previewClipboardImagePopup-title": "Preview",
@@ -253,13 +280,19 @@
"this-board": "this board",
"this-card": "this card",
"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",
"unsaved-description": "You have an unsaved description.",
+ "unwatch": "Unwatch",
"upload": "Upload",
"upload-avatar": "Upload an avatar",
"uploaded-avatar": "Uploaded an avatar",
"username": "Username",
"view-it": "View it",
"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?"
}
diff --git a/models/activities.js b/models/activities.js
index 5de07ee5..ad920149 100644
--- a/models/activities.js
+++ b/models/activities.js
@@ -48,4 +48,77 @@ if (Meteor.isServer) {
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);
+ });
+ });
}
diff --git a/models/boards.js b/models/boards.js
index e20ca8ce..64d6df62 100644
--- a/models/boards.js
+++ b/models/boards.js
@@ -151,7 +151,7 @@ Boards.helpers({
},
absoluteUrl() {
- return FlowRouter.path('board', { id: this._id, slug: this.slug });
+ return FlowRouter.url('board', { id: this._id, slug: this.slug });
},
colorClass() {
diff --git a/models/cards.js b/models/cards.js
index 1895fc69..09c86191 100644
--- a/models/cards.js
+++ b/models/cards.js
@@ -116,16 +116,12 @@ Cards.helpers({
absoluteUrl() {
const board = this.board();
- return FlowRouter.path('card', {
+ return FlowRouter.url('card', {
boardId: board._id,
slug: board.slug,
cardId: this._id,
});
},
-
- rootUrl() {
- return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
- },
});
Cards.mutations({
diff --git a/models/users.js b/models/users.js
index 5d9c218a..89220a11 100644
--- a/models/users.js
+++ b/models/users.js
@@ -47,6 +47,21 @@ Users.helpers({
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() {
const profile = this.profile || {};
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) {
return { $set: { 'profile.avatarUrl': avatarUrl }};
},
@@ -167,21 +237,18 @@ if (Meteor.isServer) {
user.addInvite(boardId);
try {
- const { _id, slug } = board;
- const boardUrl = FlowRouter.url('board', { id: _id, slug });
-
- const vars = {
+ const params = {
user: user.username,
inviter: inviter.username,
board: board.title,
- url: boardUrl,
+ url: board.absoluteUrl(),
};
const lang = user.getLanguage();
Email.send({
to: user.emails[0].address,
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) {
throw new Meteor.Error('email-fail', e.message);
diff --git a/models/watchable.js b/models/watchable.js
new file mode 100644
index 00000000..6821f847
--- /dev/null
+++ b/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);
diff --git a/server/notifications/email.js b/server/notifications/email.js
new file mode 100644
index 00000000..551d2923
--- /dev/null
+++ b/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);
+ });
+});
diff --git a/server/notifications/notifications.js b/server/notifications/notifications.js
new file mode 100644
index 00000000..bc5557e1
--- /dev/null
+++ b/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);
+ }
+ },
+};
diff --git a/server/notifications/profile.js b/server/notifications/profile.js
new file mode 100644
index 00000000..6d9c7018
--- /dev/null
+++ b/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);
+ // });
+});
diff --git a/server/notifications/watch.js b/server/notifications/watch.js
new file mode 100644
index 00000000..253e15ba
--- /dev/null
+++ b/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;
+ },
+});