diff --git a/.eslintignore b/.eslintignore index 7ee579147..588708bfc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 70b6d482e..0aeffcaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/plugins/talk-plugin-notifications-reply/.eslintrc.json b/plugins/talk-plugin-notifications-reply/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-notifications-reply/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-notifications-reply/index.js b/plugins/talk-plugin-notifications-reply/index.js new file mode 100644 index 000000000..6d9f20fd7 --- /dev/null +++ b/plugins/talk-plugin-notifications-reply/index.js @@ -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], +}; diff --git a/plugins/talk-plugin-notifications-reply/translations.yml b/plugins/talk-plugin-notifications-reply/translations.yml new file mode 100644 index 000000000..b3421a1a3 --- /dev/null +++ b/plugins/talk-plugin-notifications-reply/translations.yml @@ -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}" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/.eslintrc.json b/plugins/talk-plugin-notifications/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-notifications/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-notifications/index.js b/plugins/talk-plugin-notifications/index.js new file mode 100644 index 000000000..b84647777 --- /dev/null +++ b/plugins/talk-plugin-notifications/index.js @@ -0,0 +1 @@ +module.exports = require('./server'); diff --git a/plugins/talk-plugin-notifications/package.json b/plugins/talk-plugin-notifications/package.json new file mode 100644 index 000000000..fbf9657fb --- /dev/null +++ b/plugins/talk-plugin-notifications/package.json @@ -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" + } +} diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js new file mode 100644 index 000000000..556a97d85 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/NotificationManager.js @@ -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} 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; diff --git a/plugins/talk-plugin-notifications/server/connect.js b/plugins/talk-plugin-notifications/server/connect.js new file mode 100644 index 000000000..42cd4f552 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/connect.js @@ -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); +}; diff --git a/plugins/talk-plugin-notifications/server/index.js b/plugins/talk-plugin-notifications/server/index.js new file mode 100644 index 000000000..c1f17e7c8 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/index.js @@ -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, +}; diff --git a/plugins/talk-plugin-notifications/server/resolvers.js b/plugins/talk-plugin-notifications/server/resolvers.js new file mode 100644 index 000000000..c03c59e88 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/resolvers.js @@ -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'); + } + }, + }, +}; diff --git a/plugins/talk-plugin-notifications/server/router.js b/plugins/talk-plugin-notifications/server/router.js new file mode 100644 index 000000000..724c3755e --- /dev/null +++ b/plugins/talk-plugin-notifications/server/router.js @@ -0,0 +1,6 @@ +module.exports = router => { + router.get('/account/unsubscribe-notifications', async (req, res) => { + // TODO: implement + res.json({ ok: true }); + }); +}; diff --git a/plugins/talk-plugin-notifications/server/templates/notification.html.ejs b/plugins/talk-plugin-notifications/server/templates/notification.html.ejs new file mode 100644 index 000000000..608ee11d4 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/templates/notification.html.ejs @@ -0,0 +1,3 @@ +

<%= linkify(body, {nl2br: true}) %>

+

<%= t('talk-plugin-notifications.templates.footer', organizationName) %>

+

<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>

\ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/templates/notification.txt.ejs b/plugins/talk-plugin-notifications/server/templates/notification.txt.ejs new file mode 100644 index 000000000..8d34e507a --- /dev/null +++ b/plugins/talk-plugin-notifications/server/templates/notification.txt.ejs @@ -0,0 +1,7 @@ +<%= body %> + +<%= t('talk-plugin-notifications.templates.footer', organizationName) %> + +<%= t('talk-plugin-notifications.templates.links.unsubscribe') %> + + <%= BASE_URL %>account/unsubscribe-notifications \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/translations.yml b/plugins/talk-plugin-notifications/server/translations.yml new file mode 100644 index 000000000..4500eda4e --- /dev/null +++ b/plugins/talk-plugin-notifications/server/translations.yml @@ -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" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/typeDefs.graphql b/plugins/talk-plugin-notifications/server/typeDefs.graphql new file mode 100644 index 000000000..3d53ec1c1 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/typeDefs.graphql @@ -0,0 +1,7 @@ +type NotificationSettings { + onReply: Boolean! +} + +type User { + notificationSettings: NotificationSettings +} \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/typeDefs.js b/plugins/talk-plugin-notifications/server/typeDefs.js new file mode 100644 index 000000000..7ab1954e1 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/typeDefs.js @@ -0,0 +1,7 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +); diff --git a/plugins/talk-plugin-notifications/yarn.lock b/plugins/talk-plugin-notifications/yarn.lock new file mode 100644 index 000000000..1dc8aca41 --- /dev/null +++ b/plugins/talk-plugin-notifications/yarn.lock @@ -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"