mirror of https://github.com/wekan/wekan
Merge pull request #454 from floatinghotpot/notification
Add notifications, allow watch boards / lists / cardsreviewable/pr477/r1
commit
1e8368dea5
@ -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); |
@ -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); |
||||
}); |
||||
}); |
@ -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); |
||||
} |
||||
}, |
||||
}; |
@ -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);
|
||||
// });
|
||||
}); |
@ -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; |
||||
}, |
||||
}); |
Loading…
Reference in new issue