implemented staff replies

This commit is contained in:
Wyatt Johnson
2018-02-28 15:30:18 -07:00
parent 4304cad787
commit 5afa4fa269
10 changed files with 331 additions and 24 deletions
+1
View File
@@ -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
@@ -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}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -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 (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-staff.toggle_description')}
</Toggle>
);
}
}
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);
@@ -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 });
},
}),
},
};
@@ -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,
};
@@ -0,0 +1,3 @@
en:
talk-plugin-notifications-category-staff:
toggle_description: A staff member replies to my comment
@@ -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],
};
@@ -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}"
@@ -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) {