mirror of
https://github.com/wassname/talk.git
synced 2026-07-28 11:27:05 +08:00
initial notification draft
- implements new notification manager using connect api - first use case of plugins using the plugin api
This commit is contained in:
@@ -28,5 +28,7 @@ plugins/*
|
||||
!plugins/talk-plugin-deep-reply-count
|
||||
!plugins/talk-plugin-subscriber
|
||||
!plugins/talk-plugin-flag-details
|
||||
!plugins/talk-plugin-notifications
|
||||
!plugins/talk-plugin-notifications-reply
|
||||
public
|
||||
node_modules
|
||||
|
||||
@@ -51,6 +51,8 @@ plugins/*
|
||||
!plugins/talk-plugin-subscriber
|
||||
!plugins/talk-plugin-flag-details
|
||||
!plugins/talk-plugin-slack-notifications
|
||||
!plugins/talk-plugin-notifications
|
||||
!plugins/talk-plugin-notifications-reply
|
||||
|
||||
**/node_modules/*
|
||||
yarn-error.log
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk"
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const debug = require('debug')('talk-plugin-notifications-reply');
|
||||
const { graphql } = require('graphql');
|
||||
const { get } = require('lodash');
|
||||
const path = require('path');
|
||||
|
||||
const handle = async (ctx, comment) => {
|
||||
const { connectors: { graph: { schema } } } = ctx;
|
||||
|
||||
// Check to see if this is a reply to an existing comment.
|
||||
const parentID = get(comment, 'parent_id', null);
|
||||
if (parentID === null) {
|
||||
debug('could not get parent comment id');
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the graph request.
|
||||
const reply = await graphql(
|
||||
schema,
|
||||
`
|
||||
query GetAuthorUserMetadata($comment_id: ID!) {
|
||||
comment(id: $comment_id) {
|
||||
id
|
||||
user {
|
||||
id
|
||||
notificationSettings {
|
||||
onReply
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
ctx,
|
||||
{ comment_id: parentID }
|
||||
);
|
||||
if (reply.errors) {
|
||||
console.error(reply.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: re-enable.
|
||||
// // Check if the user has notifications enabled.
|
||||
// const enabled = get(
|
||||
// reply,
|
||||
// 'data.comment.user.notificationSettings.onReply',
|
||||
// false
|
||||
// );
|
||||
// if (!enabled) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const userID = get(reply, 'data.comment.user.id', null);
|
||||
if (!userID) {
|
||||
debug('could not get parent comment user id');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check to see if this is yourself replying to yourself, if that's the case
|
||||
// don't send a notification.
|
||||
if (userID === get(comment, 'author_id')) {
|
||||
debug('user id of parent comment is the same as the new comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// The user does have notifications for replied comments enabled, queue the
|
||||
// notification to be sent.
|
||||
return { userID, date: comment.created_at, context: comment.id };
|
||||
};
|
||||
|
||||
const hydrate = async (ctx, category, context) => {
|
||||
const { connectors: { graph: { schema } } } = ctx;
|
||||
|
||||
const reply = await graphql(
|
||||
schema,
|
||||
`
|
||||
query GetNotificationData($context: ID!) {
|
||||
comment(id: $context) {
|
||||
id
|
||||
asset {
|
||||
title
|
||||
url
|
||||
}
|
||||
user {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
ctx,
|
||||
{ context }
|
||||
);
|
||||
if (reply.errors) {
|
||||
throw reply.errors;
|
||||
}
|
||||
|
||||
const comment = get(reply, 'data.comment');
|
||||
const headline = get(comment, 'asset.title', null);
|
||||
const replier = get(comment, 'user.username', null);
|
||||
const assetURL = get(comment, 'asset.url', null);
|
||||
const permalink = `${assetURL}?commentId=${comment.id}`;
|
||||
|
||||
return [headline, replier, permalink];
|
||||
};
|
||||
|
||||
const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
|
||||
|
||||
module.exports = {
|
||||
translations: path.join(__dirname, 'translations.yml'),
|
||||
notifications: [handler],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
en:
|
||||
talk-plugin-notifications:
|
||||
categories:
|
||||
reply:
|
||||
subject: "Some has replied to your comment on [{0}]"
|
||||
body: "{0}\n{1} replied to your comment: {2}"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./server');
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Adds notification support for Talk",
|
||||
"main": "index.js",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"dependencies": {
|
||||
"linkifyjs": "^2.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
const { get, groupBy, forEach } = require('lodash');
|
||||
const debug = require('debug')('talk-plugin-notifications');
|
||||
const { graphql } = require('graphql');
|
||||
|
||||
class NotificationManager {
|
||||
constructor(context) {
|
||||
this.context = context;
|
||||
this.registry = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* register will include the notification handlers on the manager.
|
||||
*
|
||||
* @param {Array<Object>} handlers notification handlers to register
|
||||
*/
|
||||
register(...handlers) {
|
||||
this.registry.push(...handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* attach will setup the notifications by walking the registry and loading all
|
||||
* the notification types onto the handler.
|
||||
*
|
||||
* @param {Object} broker the event emitter for the Talk events
|
||||
*/
|
||||
attach(broker) {
|
||||
const events = groupBy(this.registry, 'event');
|
||||
|
||||
forEach(events, (handlers, event) => {
|
||||
debug(
|
||||
`will now notify the [${handlers
|
||||
.map(({ category }) => category)
|
||||
.join(', ')}] handlers when the '${event}' event is emitted`
|
||||
);
|
||||
broker.on(event, this.handle(handlers));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* handle will wrap a notification handler and attach it to the notification
|
||||
* stream system.
|
||||
*
|
||||
* @param {Object} handler a notification handler
|
||||
*/
|
||||
handle(handlers) {
|
||||
return async (...args) =>
|
||||
Promise.all(
|
||||
handlers.map(async handler => {
|
||||
// Grab the handler reference.
|
||||
const { handle } = handler;
|
||||
|
||||
// Create a system context to send down.
|
||||
const ctx = this.context.forSystem();
|
||||
|
||||
try {
|
||||
// Attempt to create a notification out of it.
|
||||
const notification = await handle(ctx, ...args);
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the notification details.
|
||||
const { userID, date, context } = notification;
|
||||
|
||||
// Send the notification.
|
||||
return this.send(ctx, userID, date, handler, context);
|
||||
} catch (err) {
|
||||
// TODO: handle error.
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} ctx graph context
|
||||
* @param {String} userID the user id for the user being sent the email
|
||||
*/
|
||||
async getEmail(ctx, userID) {
|
||||
const { connectors: { graph: { schema } } } = ctx;
|
||||
|
||||
// Get the email for the user.
|
||||
const reply = await graphql(
|
||||
schema,
|
||||
`
|
||||
query GetUserEmail($userID: ID!) {
|
||||
user(id: $userID) {
|
||||
email
|
||||
}
|
||||
}
|
||||
`,
|
||||
{},
|
||||
ctx,
|
||||
{ userID }
|
||||
);
|
||||
if (reply.errors) {
|
||||
throw reply.errors;
|
||||
}
|
||||
|
||||
return get(reply, 'data.user.email', null);
|
||||
}
|
||||
|
||||
async send(ctx, userID, date, handler, context) {
|
||||
const {
|
||||
connectors: { services: { Mailer, I18n: { t } } },
|
||||
loaders: { Settings },
|
||||
} = ctx;
|
||||
const { category } = handler;
|
||||
|
||||
try {
|
||||
// Get the settings.
|
||||
const { organizationName = null } = await Settings.load(
|
||||
'organizationName'
|
||||
);
|
||||
if (organizationName === null) {
|
||||
// TODO: handle error
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the User's email.
|
||||
const to = await this.getEmail(ctx, userID);
|
||||
if (!to) {
|
||||
// TODO: handle error
|
||||
return;
|
||||
}
|
||||
|
||||
// Compose the subject for the email.
|
||||
const subject = t(
|
||||
`talk-plugin-notifications.categories.${category}.subject`,
|
||||
organizationName
|
||||
);
|
||||
|
||||
// Load the content into the comment.
|
||||
const body = await this.getBody(ctx, handler, context);
|
||||
|
||||
// Send the notification to the user.
|
||||
const task = await Mailer.send({
|
||||
template: 'notification',
|
||||
locals: { body, organizationName },
|
||||
subject,
|
||||
to,
|
||||
});
|
||||
|
||||
debug(`Sent the notification for Job.ID[${task.id}]`);
|
||||
} catch (err) {
|
||||
// TODO: print out the error.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getBody will return the body for the notification payload.
|
||||
*
|
||||
* @param {Object} ctx the graph context
|
||||
* @param {Object} handler the notification handler
|
||||
* @param {Mixed} context the notification context
|
||||
*/
|
||||
async getBody(ctx, handler, context) {
|
||||
const { connectors: { services: { I18n: { t } } } } = ctx;
|
||||
const { category } = handler;
|
||||
|
||||
// Get the body replacement variables for the translation key.
|
||||
const replacements = await handler.hydrate(ctx, category, context);
|
||||
|
||||
// Generate the body.
|
||||
return t(
|
||||
`talk-plugin-notifications.categories.${category}.body`,
|
||||
...replacements
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NotificationManager;
|
||||
@@ -0,0 +1,72 @@
|
||||
const debug = require('debug')('talk-plugin-notifications');
|
||||
const path = require('path');
|
||||
const linkify = require('linkifyjs/html');
|
||||
const NotificationManager = require('./NotificationManager');
|
||||
|
||||
module.exports = connectors => {
|
||||
const {
|
||||
graph: { subscriptions: { getBroker }, Context },
|
||||
services: { Mailer, Plugins },
|
||||
} = connectors;
|
||||
|
||||
// Setup the mailer. Other plugins registered before this one can replace the
|
||||
// notification template by passing the same name + format for the template
|
||||
// registration.
|
||||
Mailer.templates.register(
|
||||
path.join(__dirname, 'templates', 'notification.html.ejs'),
|
||||
'notification',
|
||||
'html'
|
||||
);
|
||||
Mailer.templates.register(
|
||||
path.join(__dirname, 'templates', 'notification.txt.ejs'),
|
||||
'notification',
|
||||
'txt'
|
||||
);
|
||||
|
||||
// Register the mail helpers. You can register your own helpers by calling
|
||||
// this function in another plugin.
|
||||
Mailer.registerHelpers({ linkify });
|
||||
|
||||
// Get the handle for the broker to attach to notifications.
|
||||
const broker = getBroker();
|
||||
|
||||
// Create a NotificationManager to handle notifications.
|
||||
const manager = new NotificationManager(Context);
|
||||
|
||||
// Get all the notification handlers. Additional plugins registered before
|
||||
// this one can expose a `notifications` hook, that contains an array (or a
|
||||
// single) notification handlers.
|
||||
//
|
||||
// A notification handler has the following form:
|
||||
//
|
||||
// {
|
||||
// event // the graph event to listen for
|
||||
// handle // the function called when the event is fired. It is called with
|
||||
// // the (ctx, arg1, arg2, ...) where arg1, arg2 are args from the
|
||||
// // event.
|
||||
// category // the name representing the notification type (like 'reply')
|
||||
// hydrate // returns the replacement parameters (in order!) to be used
|
||||
// // in the translation.
|
||||
// }
|
||||
//
|
||||
const notificationHandlers = Plugins.get('server', 'notifications').reduce(
|
||||
(notificationHandlers, { plugin, notifications }) => {
|
||||
debug(
|
||||
`registered the ${
|
||||
plugin.name
|
||||
} plugin for notifications ${notifications.map(
|
||||
({ category }) => category
|
||||
)}`
|
||||
);
|
||||
notificationHandlers.push(...notifications);
|
||||
return notificationHandlers;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Attach all the notification handlers.
|
||||
manager.register(...notificationHandlers);
|
||||
|
||||
// Attach the broker to the manager so it can listen for the events.
|
||||
manager.attach(broker);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const path = require('path');
|
||||
const connect = require('./connect');
|
||||
const typeDefs = require('./typeDefs');
|
||||
const resolvers = require('./resolvers');
|
||||
const router = require('./router');
|
||||
const translations = path.join(__dirname, 'translations.yml');
|
||||
|
||||
module.exports = {
|
||||
translations,
|
||||
typeDefs,
|
||||
resolvers,
|
||||
connect,
|
||||
router,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const { get } = require('lodash');
|
||||
|
||||
module.exports = {
|
||||
User: {
|
||||
notificationSettings(user, args, { user: currentUser }) {
|
||||
if (
|
||||
currentUser &&
|
||||
(currentUser.id === user.id || currentUser.can('VIEW_USER_STATUS'))
|
||||
) {
|
||||
return get(user, 'metadata.notifications.settings');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = router => {
|
||||
router.get('/account/unsubscribe-notifications', async (req, res) => {
|
||||
// TODO: implement
|
||||
res.json({ ok: true });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<p><%= linkify(body, {nl2br: true}) %></p>
|
||||
<p><%= t('talk-plugin-notifications.templates.footer', organizationName) %></p>
|
||||
<p><a href="<%= BASE_URL %>account/unsubscribe-notifications" target="_blank"><%= t('talk-plugin-notifications.templates.links.unsubscribe') %></a></p>
|
||||
@@ -0,0 +1,7 @@
|
||||
<%= body %>
|
||||
|
||||
<%= t('talk-plugin-notifications.templates.footer', organizationName) %>
|
||||
|
||||
<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>
|
||||
|
||||
<%= BASE_URL %>account/unsubscribe-notifications
|
||||
@@ -0,0 +1,6 @@
|
||||
en:
|
||||
talk-plugin-notifications:
|
||||
templates:
|
||||
footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications."
|
||||
links:
|
||||
unsubscribe: "Unsubscribe from comment notifications"
|
||||
@@ -0,0 +1,7 @@
|
||||
type NotificationSettings {
|
||||
onReply: Boolean!
|
||||
}
|
||||
|
||||
type User {
|
||||
notificationSettings: NotificationSettings
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = fs.readFileSync(
|
||||
path.join(__dirname, 'typeDefs.graphql'),
|
||||
'utf8'
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
asap@~2.0.3:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
|
||||
|
||||
core-js@^1.0.0:
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
|
||||
|
||||
encoding@^0.1.11:
|
||||
version "0.1.12"
|
||||
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
|
||||
dependencies:
|
||||
iconv-lite "~0.4.13"
|
||||
|
||||
fbjs@^0.8.16:
|
||||
version "0.8.16"
|
||||
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
|
||||
dependencies:
|
||||
core-js "^1.0.0"
|
||||
isomorphic-fetch "^2.1.1"
|
||||
loose-envify "^1.0.0"
|
||||
object-assign "^4.1.0"
|
||||
promise "^7.1.1"
|
||||
setimmediate "^1.0.5"
|
||||
ua-parser-js "^0.7.9"
|
||||
|
||||
iconv-lite@~0.4.13:
|
||||
version "0.4.19"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
|
||||
|
||||
is-stream@^1.0.1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||
|
||||
isomorphic-fetch@^2.1.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
|
||||
dependencies:
|
||||
node-fetch "^1.0.1"
|
||||
whatwg-fetch ">=0.10.0"
|
||||
|
||||
jquery@>=1.9.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
|
||||
|
||||
js-tokens@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
|
||||
|
||||
linkifyjs@^2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.5.tgz#effc9f01e4aeafbbdbef21a45feab38b9516f93e"
|
||||
optionalDependencies:
|
||||
jquery ">=1.9.0"
|
||||
react ">=0.14.0"
|
||||
react-dom ">=0.14.0"
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
|
||||
dependencies:
|
||||
js-tokens "^3.0.0"
|
||||
|
||||
node-fetch@^1.0.1:
|
||||
version "1.7.3"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
|
||||
dependencies:
|
||||
encoding "^0.1.11"
|
||||
is-stream "^1.0.1"
|
||||
|
||||
object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
|
||||
promise@^7.1.1:
|
||||
version "7.3.1"
|
||||
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
|
||||
dependencies:
|
||||
asap "~2.0.3"
|
||||
|
||||
prop-types@^15.6.0:
|
||||
version "15.6.0"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
|
||||
dependencies:
|
||||
fbjs "^0.8.16"
|
||||
loose-envify "^1.3.1"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
react-dom@>=0.14.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
|
||||
dependencies:
|
||||
fbjs "^0.8.16"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react@>=0.14.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
|
||||
dependencies:
|
||||
fbjs "^0.8.16"
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
setimmediate@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
||||
|
||||
ua-parser-js@^0.7.9:
|
||||
version "0.7.17"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
|
||||
|
||||
whatwg-fetch@>=0.10.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
|
||||
Reference in New Issue
Block a user