From 5afa4fa26946501d53493d0b9362cd3cbb2cc74d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 28 Feb 2018 15:30:18 -0700 Subject: [PATCH] implemented staff replies --- .gitignore | 1 + .../translations.yml | 2 +- .../client/.eslintrc.json | 3 + .../client/containers/Toggle.js | 69 ++++++++ .../client/graphql.js | 33 ++++ .../client/index.js | 11 ++ .../client/translations.yml | 3 + .../index.js | 151 ++++++++++++++++++ .../translations.yml | 6 + .../server/NotificationManager.js | 76 ++++++--- 10 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 plugins/talk-plugin-notifications-category-staff/client/.eslintrc.json create mode 100644 plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js create mode 100644 plugins/talk-plugin-notifications-category-staff/client/graphql.js create mode 100644 plugins/talk-plugin-notifications-category-staff/client/index.js create mode 100644 plugins/talk-plugin-notifications-category-staff/client/translations.yml create mode 100644 plugins/talk-plugin-notifications-category-staff/index.js create mode 100644 plugins/talk-plugin-notifications-category-staff/translations.yml diff --git a/.gitignore b/.gitignore index 11b6af551..84d6850ad 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ plugins/* !plugins/talk-plugin-moderation-actions !plugins/talk-plugin-notifications !plugins/talk-plugin-notifications-category-reply +!plugins/talk-plugin-notifications-category-staff !plugins/talk-plugin-offtopic !plugins/talk-plugin-permalink !plugins/talk-plugin-profile-settings diff --git a/plugins/talk-plugin-notifications-category-reply/translations.yml b/plugins/talk-plugin-notifications-category-reply/translations.yml index b3421a1a3..0f28ca105 100644 --- a/plugins/talk-plugin-notifications-category-reply/translations.yml +++ b/plugins/talk-plugin-notifications-category-reply/translations.yml @@ -2,5 +2,5 @@ en: talk-plugin-notifications: categories: reply: - subject: "Some has replied to your comment on [{0}]" + subject: "Someone 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-category-staff/client/.eslintrc.json b/plugins/talk-plugin-notifications-category-staff/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js b/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js new file mode 100644 index 000000000..44d6d5b3c --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, gql } from 'react-apollo'; +import Toggle from 'talk-plugin-notifications/client/components/Toggle'; +import { t } from 'plugin-api/beta/client/services'; +import { withFragments } from 'plugin-api/beta/client/hocs'; + +class ToggleContainer extends React.Component { + constructor(props) { + super(props); + props.setTurnOffInputFragment({ onStaffReply: false }); + + if (this.getOnReplySetting()) { + props.indicateOn(); + } + } + + componentWillReceiveProps(nextProps) { + const prevSetting = this.getOnReplySetting(this.props); + const nextSetting = this.getOnReplySetting(nextProps); + if (prevSetting && !nextSetting) { + nextProps.indicateOff(); + } else if (!prevSetting && nextSetting) { + nextProps.indicateOn(); + } + } + + getOnReplySetting = (props = this.props) => + props.root.me.notificationSettings.onStaffReply; + + toggle = () => { + this.props.updateNotificationSettings({ + onStaffReply: !this.getOnReplySetting(), + }); + }; + + render() { + return ( + + {t('talk-plugin-notifications-category-staff.toggle_description')} + + ); + } +} + +ToggleContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + indicateOn: PropTypes.func.isRequired, + indicateOff: PropTypes.func.isRequired, + setTurnOffInputFragment: PropTypes.func.isRequired, + updateNotificationSettings: PropTypes.func.isRequired, +}; + +const enhance = compose( + withFragments({ + root: gql` + fragment TalkNotificationsCategoryStaffReply_User_Fragment on RootQuery { + me { + notificationSettings { + onStaffReply + } + } + } + `, + }) +); + +export default enhance(ToggleContainer); diff --git a/plugins/talk-plugin-notifications-category-staff/client/graphql.js b/plugins/talk-plugin-notifications-category-staff/client/graphql.js new file mode 100644 index 000000000..2e8007d4f --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/client/graphql.js @@ -0,0 +1,33 @@ +import { gql } from 'react-apollo'; + +export default { + mutations: { + UpdateNotificationSettings: ({ + variables: { input }, + state: { auth: { user: { id } } }, + }) => ({ + update: proxy => { + if (input.onStaffReply === undefined) { + return; + } + + const fragment = gql` + fragment TalkNotificationsCategoryStaffReply_User_Fragment on User { + notificationSettings { + onStaffReply + } + } + `; + const fragmentId = `User_${id}`; + const data = { + __typename: 'User', + notificationSettings: { + __typename: 'NotificationSettings', + onStaffReply: input.onStaffReply, + }, + }; + proxy.writeFragment({ fragment, id: fragmentId, data }); + }, + }), + }, +}; diff --git a/plugins/talk-plugin-notifications-category-staff/client/index.js b/plugins/talk-plugin-notifications-category-staff/client/index.js new file mode 100644 index 000000000..1e22c3a93 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/client/index.js @@ -0,0 +1,11 @@ +import Toggle from './containers/Toggle'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + notificationSettings: [Toggle], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-notifications-category-staff/client/translations.yml b/plugins/talk-plugin-notifications-category-staff/client/translations.yml new file mode 100644 index 000000000..ba831cf37 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/client/translations.yml @@ -0,0 +1,3 @@ +en: + talk-plugin-notifications-category-staff: + toggle_description: A staff member replies to my comment diff --git a/plugins/talk-plugin-notifications-category-staff/index.js b/plugins/talk-plugin-notifications-category-staff/index.js new file mode 100644 index 000000000..c5a59253d --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/index.js @@ -0,0 +1,151 @@ +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) { + ctx.log.debug('could not get parent comment id'); + return; + } + + const authorID = get(comment, 'author_id', null); + if (authorID === null) { + ctx.log.error('could not get author id'); + return; + } + + // Execute the graph request. + const reply = await graphql( + schema, + ` + query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) { + author: user(id: $author_id) { + role + } + comment(id: $comment_id) { + id + user { + id + notificationSettings { + onStaffReply + } + } + } + } + `, + {}, + ctx, + { comment_id: parentID, author_id: authorID } + ); + if (reply.errors) { + ctx.log.error({ err: reply.errors }, 'could not query for author metadata'); + return; + } + + // Check if the user has notifications enabled. + const enabled = get( + reply, + 'data.comment.user.notificationSettings.onStaffReply', + false + ); + if (!enabled) { + ctx.log.debug('onStaffReply is false, will not send the notification'); + return; + } + + const userID = get(reply, 'data.comment.user.id', null); + if (!userID) { + ctx.log.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 === authorID) { + ctx.log.debug('user id of parent comment is the same as the new comment'); + return; + } + + // Check to see that this comment was indeed from a staff member. + const role = get(reply, 'data.author.role'); + if (!['ADMIN', 'MODERATOR', 'STAFF'].includes(role)) { + ctx.log.debug({ role }, 'reply author is not a staff member'); + 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 + } + } + settings { + organizationName + } + } + `, + {}, + 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}`; + const organizationName = get(reply, 'data.settings.organizationName', null); + + return [headline, replier, organizationName, permalink]; +}; + +const handler = { + handle, + category: 'staff', + event: 'commentAdded', + hydrate, + supersedesCategories: ['reply'], +}; + +module.exports = { + typeDefs: ` + type NotificationSettings { + onStaffReply: Boolean! + } + + input NotificationSettingsInput { + onStaffReply: Boolean + } + `, + resolvers: { + NotificationSettings: { + // onStaffReply returns false by default if not specified. + onStaffReply: settings => get(settings, 'onStaffReply', false), + }, + }, + translations: path.join(__dirname, 'translations.yml'), + notifications: [handler], +}; diff --git a/plugins/talk-plugin-notifications-category-staff/translations.yml b/plugins/talk-plugin-notifications-category-staff/translations.yml new file mode 100644 index 000000000..38b534e1d --- /dev/null +++ b/plugins/talk-plugin-notifications-category-staff/translations.yml @@ -0,0 +1,6 @@ +en: + talk-plugin-notifications: + categories: + staff: + subject: "Someone at {0} has replied to your comment" + body: "{0}\n{1} works for {2} and has replied to your comment: {3}" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js index 13a829cb1..6c1d2556d 100644 --- a/plugins/talk-plugin-notifications/server/NotificationManager.js +++ b/plugins/talk-plugin-notifications/server/NotificationManager.js @@ -1,8 +1,43 @@ -const { groupBy, forEach } = require('lodash'); +const { groupBy, forEach, property } = require('lodash'); const debug = require('debug')('talk-plugin-notifications'); const uuid = require('uuid/v4'); const { UNSUBSCRIBE_SUBJECT } = require('./config'); +// handleHandlers will call the handle method on each handler to determine if a +// notification should be sent for it. +const handleHandlers = (ctx, handlers, ...args) => + Promise.all( + handlers.map(async handler => { + // Grab the handler reference. + const { handle, category, event } = handler; + + try { + // Attempt to create a notification out of it. + const notification = await handle(ctx, ...args); + if (!notification) { + ctx.log.debug('no notification deemed by event handler'); + return; + } + + // Send the notification back. + ctx.log.debug({ category, event }, 'notification detected for event'); + return { handler, notification }; + } catch (err) { + ctx.log.error({ err }, 'could not handle the event'); + return; + } + }) + ); + +// filterSuperseded will filter all the possible notifications and only send +// those notifications that are not superseded by another type of notification. +const filterSuperseded = ({ handler: { category } }, index, notifications) => + !notifications.some(({ handler: { supersedesCategories = [] } }) => + supersedesCategories.some( + supersededCategory => supersededCategory === category + ) + ); + class NotificationManager { constructor(context) { this.context = context; @@ -44,33 +79,28 @@ class NotificationManager { * @param {Object} handler a notification handler */ handle(handlers) { - return async (...args) => - Promise.all( - handlers.map(async handler => { - // Grab the handler reference. - const { handle } = handler; + return async (...args) => { + // Create a system context to send down. + const ctx = this.context.forSystem(); - // Create a system context to send down. - const ctx = this.context.forSystem(); + // Get all the notifications to load. + let notifications = await handleHandlers(ctx, handlers, ...args); - try { - // Attempt to create a notification out of it. - const notification = await handle(ctx, ...args); - if (!notification) { - return; - } + // Only let handlers past that have a notification to send. + notifications = notifications.filter(property('notification')); - // Extract the notification details. - const { userID, date, context } = notification; + // Check to see if some of the other notifications that are queued + // had this notification superseded. + notifications = notifications.filter(filterSuperseded); - // Send the notification. - return this.send(ctx, userID, date, handler, context); - } catch (err) { - ctx.log.error({ err }, 'could not handle the event'); - return; - } - }) + // Send the remaining notifications. + return Promise.all( + notifications.map( + ({ handler, notification: { userID, date, context } }) => + this.send(ctx, userID, date, handler, context) + ) ); + }; } async send(ctx, userID, date, handler, context) {