From 9bbdacc79a89667e0d6f1ed30c415e5350ad468b Mon Sep 17 00:00:00 2001 From: Liming Xie Date: Tue, 5 Jan 2016 23:26:02 +0800 Subject: Add notification, allow watch boards / lists / cards --- .eslintrc | 1 + client/components/activities/activities.js | 2 +- client/components/boards/boardHeader.jade | 49 ++++++++++++++++ client/components/boards/boardHeader.js | 28 ++++++++++ client/components/cards/cardDetails.jade | 5 ++ client/components/cards/cardDetails.js | 18 ++++++ client/components/cards/cardDetails.styl | 5 ++ client/components/lists/list.styl | 4 ++ client/components/lists/listHeader.jade | 5 ++ client/components/lists/listHeader.js | 18 ++++++ client/components/main/header.styl | 3 + client/components/main/layouts.styl | 5 +- client/components/users/userHeader.jade | 18 ++++++ client/components/users/userHeader.js | 20 +++++++ i18n/en.i18n.json | 35 +++++++++++- models/activities.js | 73 ++++++++++++++++++++++++ models/boards.js | 2 +- models/cards.js | 6 +- models/users.js | 81 ++++++++++++++++++++++++--- models/watchable.js | 89 ++++++++++++++++++++++++++++++ server/notifications/email.js | 35 ++++++++++++ server/notifications/notifications.js | 48 ++++++++++++++++ server/notifications/profile.js | 9 +++ server/notifications/watch.js | 36 ++++++++++++ 24 files changed, 579 insertions(+), 16 deletions(-) create mode 100644 models/watchable.js create mode 100644 server/notifications/email.js create mode 100644 server/notifications/notifications.js create mode 100644 server/notifications/profile.js create mode 100644 server/notifications/watch.js 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 acf40f44..567f14ac 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'}} @@ -82,6 +84,9 @@ template(name="editCardTitleForm") a.fa.fa-times-thin.js-close-inlined-form 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'}} 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") @@ -17,6 +19,9 @@ template(name="editListTitleForm") a.fa.fa-times-thin.js-close-inlined-form 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 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 logging in.", "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..0aa4fa54 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.findOne(doc._id); + let participants = []; + let watchers = []; + let title = 'Wekan Notification'; + 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 boardWatching = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId'); + const boardTracking = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId'); + const boardMuted = _.pluck(_.where(board.watchers, {level: 'muted'}), 'userId'); + switch(board.getWatchDefault()) { + case 'muted': + participants = _.intersection(participants, boardTracking); + watchers = _.intersection(watchers, boardTracking); + break; + case 'tracking': + participants = _.difference(participants, boardMuted); + watchers = _.difference(watchers, boardMuted); + break; + } + watchers = _.union(watchers, boardWatching || []); + } + + 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..3bb7324f 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); + }, + + getEmailCache() { + const {emailCache = []} = this.profile; + return emailCache; + }, + 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, + }, + }; + }, + + addEmailCache(text) { + return { + $addToSet: { + 'profile.emailCache': text, + }, + }; + }, + + clearEmailCache() { + return { + $set: { + 'profile.emailCache': [], + }, + }; + }, + 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..40968329 --- /dev/null +++ b/server/notifications/email.js @@ -0,0 +1,35 @@ +// cache the email text in a queue, and send them in a batch +Meteor.startup(() => { + Notifications.subscribe('cachedEmail', (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.addEmailCache(text); + + const userId = user._id; + Meteor.setTimeout(() => { + const user = Users.findOne(userId); + + const emailCache = user.getEmailCache(); + if (emailCache.length === 0) return; + + const text = emailCache.join('\n\n'); + user.clearEmailCache(); + + try { + Email.send({ + to: user.emails[0].address, + from: Accounts.emailTemplates.from, + subject : TAPi18n.__('act-activity-notify', {}, user.getLanguage()), + text, + }); + } catch (e) { + return; + } + }, 30000, user._id); + }); +}); 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; + }, +}); -- cgit v1.2.3-1-g7c22 From 39e1cc02374b3a379de87bdcb95a7a343b698a05 Mon Sep 17 00:00:00 2001 From: floatinghotpot Date: Wed, 6 Jan 2016 16:59:25 +0800 Subject: Improve PR, adding more comments --- models/activities.js | 20 ++++++++++---------- models/users.js | 14 +++++++------- server/notifications/email.js | 24 +++++++++++++++--------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/models/activities.js b/models/activities.js index 0aa4fa54..ad920149 100644 --- a/models/activities.js +++ b/models/activities.js @@ -50,10 +50,10 @@ if (Meteor.isServer) { }); Activities.after.insert((userId, doc) => { - const activity = Activities.findOne(doc._id); + const activity = Activities._transform(doc); let participants = []; let watchers = []; - let title = 'Wekan Notification'; + let title = 'act-activity-notify'; let board = null; const description = `act-${activity.activityType}`; const params = { @@ -101,20 +101,20 @@ if (Meteor.isServer) { params.attachment = attachment._id; } if (board) { - const boardWatching = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId'); - const boardTracking = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId'); - const boardMuted = _.pluck(_.where(board.watchers, {level: 'muted'}), 'userId'); + 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, boardTracking); - watchers = _.intersection(watchers, boardTracking); + participants = _.intersection(participants, trackingUsers); + watchers = _.intersection(watchers, trackingUsers); break; case 'tracking': - participants = _.difference(participants, boardMuted); - watchers = _.difference(watchers, boardMuted); + participants = _.difference(participants, mutedUsers); + watchers = _.difference(watchers, mutedUsers); break; } - watchers = _.union(watchers, boardWatching || []); + watchers = _.union(watchers, watchingUsers || []); } Notifications.getUsers(participants, watchers).forEach((user) => { diff --git a/models/users.js b/models/users.js index 3bb7324f..89220a11 100644 --- a/models/users.js +++ b/models/users.js @@ -57,9 +57,9 @@ Users.helpers({ return _.contains(notifications, activityId); }, - getEmailCache() { - const {emailCache = []} = this.profile; - return emailCache; + getEmailBuffer() { + const {emailBuffer = []} = this.profile; + return emailBuffer; }, getInitials() { @@ -153,18 +153,18 @@ Users.mutations({ }; }, - addEmailCache(text) { + addEmailBuffer(text) { return { $addToSet: { - 'profile.emailCache': text, + 'profile.emailBuffer': text, }, }; }, - clearEmailCache() { + clearEmailBuffer() { return { $set: { - 'profile.emailCache': [], + 'profile.emailBuffer': [], }, }; }, diff --git a/server/notifications/email.js b/server/notifications/email.js index 40968329..551d2923 100644 --- a/server/notifications/email.js +++ b/server/notifications/email.js @@ -1,6 +1,6 @@ -// cache the email text in a queue, and send them in a batch +// buffer each user's email text in a queue, then flush them in single email Meteor.startup(() => { - Notifications.subscribe('cachedEmail', (user, title, description, params) => { + 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) => { @@ -8,28 +8,34 @@ Meteor.startup(() => { }); const text = `${params.user} ${TAPi18n.__(description, quoteParams, user.getLanguage())}\n${params.url}`; - user.addEmailCache(text); + 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); - const emailCache = user.getEmailCache(); - if (emailCache.length === 0) return; + // 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; - const text = emailCache.join('\n\n'); - user.clearEmailCache(); + // 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()), + subject: TAPi18n.__('act-activity-notify', {}, user.getLanguage()), text, }); } catch (e) { return; } - }, 30000, user._id); + }, 30000); }); }); -- cgit v1.2.3-1-g7c22