Merge branch 'master' into scrape-assets

This commit is contained in:
Kim Gardner
2018-03-05 15:14:52 -05:00
committed by GitHub
34 changed files with 621 additions and 113 deletions
+32 -3
View File
@@ -1,5 +1,34 @@
### Expected behavior
<!--
### Actual behavior
Thank you for filing an issue on Coral Talk!
### Steps to reproduce behavior
Please fill out the questions below so we can take action on your issue as soon as we can.
If you're filing a feature request, you do not need to follow the outline below. Instead please include "Feature Idea" in your issue title and explain a specific example in which that feature would be useful.
-->
#### Do you want to request a **feature** or report a **bug**?
#### Intended outcome:
<!--
What you were trying to accomplish when the bug occurred?
-->
#### Actual outcome:
<!--
What happened instead?
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
-->
#### How to reproduce the issue:
<!--
Instructions for how the issue can be reproduced by someone from our team or by a contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be.
-->
#### Version and environment
<!--
List what version of Talk you're using, as well as any other relevant environment information, such as operating system or browser
-->
+5
View File
@@ -50,6 +50,11 @@
color: #00a291;
}
.input:disabled + .checkbox:before {
color: #e5e5e5;
cursor: default;
}
.input:focus + .checkbox:before {
color: #00a291;
}
+8 -2
View File
@@ -1,8 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Spinner.css';
import cn from 'classnames';
const Spinner = () => (
<div className={styles.container}>
const Spinner = ({ className }) => (
<div className={cn(styles.container, className)}>
<svg
className={styles.spinner}
width="40px"
@@ -23,4 +25,8 @@ const Spinner = () => (
</div>
);
Spinner.propTypes = {
className: PropTypes.string,
};
export default Spinner;
+7
View File
@@ -48,6 +48,13 @@ const CONFIG = {
// request all of the records. Otherwise, minimum limits of 0 are enforced.
ALLOW_NO_LIMIT_QUERIES: process.env.TALK_ALLOW_NO_LIMIT_QUERIES === 'TRUE',
// LOGGING_LEVEL specifies the logging level used by the bunyan logger.
LOGGING_LEVEL: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(
process.env.TALK_LOGGING_LEVEL
)
? process.env.TALK_LOGGING_LEVEL
: 'info',
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
@@ -563,3 +563,14 @@ This is a **Build Variable** and must be consumed during build. If using the
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
Specifies the initial replies to load for a comment. (Default `3`)
## TALK_LOGGING_LEVEL
Sets the logging level for the context logger (from [Bunyan](https://github.com/trentm/node-bunyan)) that will be phased in to replace most existing `debug()` calls. Supports the following values:
- `fatal`
- `error`
- `warn`
- `info`
- `debug`
- `trace`
+12
View File
@@ -142,6 +142,10 @@ anything. You need to enable one of the `talk-plugin-notifications-category-*` p
```
{:.no-copy}
Configuration:
- `DISABLE_REQUIRE_EMAIL_VERIFICATIONS` - When `TRUE`, it will disable the verification email check before sending notifications for those emails. **Note that organizations implementing a custom authentication system _must_ disable this feature, as they don't use our integrated auth**. (Default `FALSE`).
### talk-plugin-notifications-category-reply
{:.param}
@@ -157,3 +161,11 @@ Source: [plugins/talk-plugin-notifications-category-featured](https://github.com
When a comment is featured (via the `talk-plugin-featured-comments` plugin), the
user will receive a notification email.
### talk-plugin-notifications-category-staff
{:.param}
Source: [plugins/talk-plugin-notifications-category-staff](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-staff){:target="_blank"}
Replies made to each user by a staff member will trigger an email to be sent
with the notification details if enabled.
+40 -27
View File
@@ -12,12 +12,9 @@ const {
ADD_COMMENT_TAG,
EDIT_COMMENT,
} = require('../../perms/constants');
const debug = require('debug')('talk:graph:mutators:comment');
const resolveTagsForComment = async (
{ user, loaders: { Tags } },
{ asset_id, tags = [] }
) => {
const resolveTagsForComment = async (ctx, { asset_id, tags = [] }) => {
const { user, loaders: { Tags } } = ctx;
const item_type = 'COMMENTS';
// Handle Tags
@@ -46,6 +43,7 @@ const resolveTagsForComment = async (
// Add the staff tag for comments created as a staff member.
if (user.can(ADD_COMMENT_TAG)) {
ctx.log.info({ author_id: user.id }, 'Added staff tag to comment');
tags.push(
TagsService.newTagLink(user, {
name: 'STAFF',
@@ -61,7 +59,7 @@ const resolveTagsForComment = async (
* adjustKarma will adjust the affected user's karma depending on the moderators
* action.
*/
const adjustKarma = (Comments, id, status) => async () => {
const adjustKarma = (ctx, Comments, id, status) => async () => {
try {
// Use the dataloader to get the comment that was just moderated and
// get the flag user's id's so we can adjust their karma too.
@@ -86,17 +84,27 @@ const adjustKarma = (Comments, id, status) => async () => {
}),
]);
debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
ctx.log.info(
{
state: {
comment_id: id,
author_id: comment.author_id,
status: status,
},
},
'Processing karma modification'
);
switch (status) {
case 'REJECTED':
// Reduce the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma reduced`);
ctx.log.info('Comment author had their karma reduced');
// Decrease the flag user's karma, the moderator disagreed with this
// action.
debug(
`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`
ctx.log.info(
{ flagUserIDs },
'Flagging users had their karma increased'
);
await Promise.all([
KarmaService.modifyUser(comment.author_id, -1, 'comment'),
@@ -107,13 +115,11 @@ const adjustKarma = (Comments, id, status) => async () => {
case 'ACCEPTED':
// Increase the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma increased`);
ctx.log.info('Comment author had their karma increased');
// Increase the flag user's karma, the moderator agreed with this
// action.
debug(
`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`
);
ctx.log.info({ flagUserIDs }, `Flagging users had their karma reduced`);
await Promise.all([
KarmaService.modifyUser(comment.author_id, 1, 'comment'),
KarmaService.modifyUser(flagUserIDs, -1, 'flag', true),
@@ -140,7 +146,7 @@ const adjustKarma = (Comments, id, status) => async () => {
* @return {Promise} resolves to the created comment
*/
const createComment = async (
context,
ctx,
{
tags = [],
body,
@@ -150,10 +156,10 @@ const createComment = async (
metadata = {},
}
) => {
const { user, loaders: { Comments }, pubsub } = context;
const { user, loaders: { Comments }, pubsub } = ctx;
// Resolve the tags for the comment.
tags = await resolveTagsForComment(context, { asset_id, tags });
tags = await resolveTagsForComment(ctx, { asset_id, tags });
let comment = await CommentsService.publicCreate({
body,
@@ -165,6 +171,11 @@ const createComment = async (
metadata,
});
ctx.log.info(
{ comment_id: comment.id, comment_status: status },
'Created comment'
);
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
@@ -228,12 +239,14 @@ const createActions = async (item_id, actions = []) =>
/**
* Sets the status of a comment
* @param {Object} context graphql context
* @param {Object} ctx graphql context
* @param {String} comment comment in graphql context
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
const setStatus = async (ctx, { id, status }) => {
const { user, loaders: { Comments } } = ctx;
let comment = await CommentsService.pushStatus(
id,
status,
@@ -253,7 +266,7 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
// postSetCommentStatus will use the arguments from the mutation and
// adjust the affected user's karma in the next tick.
process.nextTick(adjustKarma(Comments, id, status));
process.nextTick(adjustKarma(ctx, Comments, id, status));
return comment;
};
@@ -292,7 +305,7 @@ const edit = async (ctx, { id, asset_id, edit: { body } }) => {
return comment;
};
module.exports = context => {
module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
@@ -301,16 +314,16 @@ module.exports = context => {
},
};
if (context.user && context.user.can(CREATE_COMMENT)) {
mutators.Comment.create = comment => createPublicComment(context, comment);
if (ctx.user && ctx.user.can(CREATE_COMMENT)) {
mutators.Comment.create = comment => createPublicComment(ctx, comment);
}
if (context.user && context.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = action => setStatus(context, action);
if (ctx.user && ctx.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = action => setStatus(ctx, action);
}
if (context.user && context.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = action => edit(context, action);
if (ctx.user && ctx.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = action => edit(ctx, action);
}
return mutators;
+6 -2
View File
@@ -15,6 +15,7 @@ const DontAgreeActionSummary = require('./dont_agree_action_summary');
const FlagAction = require('./flag_action');
const FlagActionSummary = require('./flag_action_summary');
const GenericUserError = require('./generic_user_error');
const LocalUserProfile = require('./local_user_profile');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
@@ -24,8 +25,9 @@ const Tag = require('./tag');
const TagLink = require('./tag_link');
const User = require('./user');
const UserError = require('./user_error');
const UserState = require('./user_state');
const UsernameStatusHistory = require('./username_status_history');
const UserProfile = require('./user_profile');
const UserState = require('./user_state');
const ValidationUserError = require('./validation_user_error');
const plugins = require('../../services/plugins');
@@ -46,6 +48,7 @@ let resolvers = {
FlagAction,
FlagActionSummary,
GenericUserError,
LocalUserProfile,
RootMutation,
RootQuery,
Settings,
@@ -55,8 +58,9 @@ let resolvers = {
TagLink,
User,
UserError,
UserState,
UsernameStatusHistory,
UserProfile,
UserState,
ValidationUserError,
};
+7
View File
@@ -0,0 +1,7 @@
const { property } = require('lodash');
const LocalUserProfile = {
confirmedAt: property('metadata.confirmed_at'),
};
module.exports = LocalUserProfile;
+12
View File
@@ -0,0 +1,12 @@
const UserProfile = {
__resolveType({ provider }) {
switch (provider) {
case 'local':
return 'LocalUserProfile';
default:
return undefined;
}
},
};
module.exports = UserProfile;
+26 -1
View File
@@ -62,7 +62,7 @@ type Token {
jwt: String
}
type UserProfile {
interface UserProfile {
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
@@ -70,6 +70,31 @@ type UserProfile {
provider: String!
}
# DefaultUserProfile is a fallback if the type of UserProfile can't be
# determined (like if it was from a plugin that was removed).
type DefaultUserProfile implements UserProfile {
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
# name of the provider attached to the authentication mode
provider: String!
}
# LocalUserProfile is for a User who has an authentication profile linked to
# their email address.
type LocalUserProfile implements UserProfile {
# id is the User's email address.
id: String!
# name of the provider attached to the authentication mode, in this case,
# 'local'.
provider: String!
# confirmedAt is the Date that the user had their email address confirmed,
# which is null if it has not been verified.
confirmedAt: Date
}
# USER_STATUS_USERNAME is the different states that a username can be in.
enum USER_STATUS_USERNAME {
# UNSET is used when the username can be changed, and does not necessarily
@@ -41,7 +41,7 @@ ResendEmailConfirmatonContainer.propTypes = {
success: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
resendEmailConfirmation: PropTypes.func.isRequired,
errorMessage: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
setView: PropTypes.func.isRequired,
email: PropTypes.string.isRequired,
};
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
<Toggle checked={this.getOnFeaturedSetting()} onChange={this.toggle}>
<Toggle
checked={this.getOnFeaturedSetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-featured.toggle_description')}
</Toggle>
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
@@ -1,20 +1,16 @@
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 commentID = get(comment, 'id', null);
if (commentID === null) {
ctx.log.debug('could not get comment id');
ctx.log.info('could not get comment id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
@@ -28,8 +24,6 @@ const handle = async (ctx, { comment }) => {
}
}
`,
{},
ctx,
{ comment_id: commentID }
);
if (reply.errors) {
@@ -49,7 +43,7 @@ const handle = async (ctx, { comment }) => {
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get comment user id');
ctx.log.info('could not get comment user id');
return;
}
@@ -59,10 +53,7 @@ const handle = async (ctx, { comment }) => {
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -74,8 +65,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
<Toggle
checked={this.getOnReplySetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-reply.toggle_description')}
</Toggle>
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
@@ -1,20 +1,16 @@
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');
ctx.log.info('could not get parent comment id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
@@ -28,8 +24,6 @@ const handle = async (ctx, comment) => {
}
}
`,
{},
ctx,
{ comment_id: parentID }
);
if (reply.errors) {
@@ -49,14 +43,14 @@ const handle = async (ctx, comment) => {
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get parent comment user id');
ctx.log.info('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')) {
ctx.log.debug('user id of parent comment is the same as the new comment');
ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
@@ -66,10 +60,7 @@ const handle = async (ctx, comment) => {
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -84,8 +75,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
<Toggle
checked={this.getOnReplySetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-staff.toggle_description')}
</Toggle>
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
@@ -1,14 +1,11 @@
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');
ctx.log.info('could not get parent comment id');
return;
}
@@ -19,8 +16,7 @@ const handle = async (ctx, comment) => {
}
// Execute the graph request.
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) {
author: user(id: $author_id) {
@@ -37,8 +33,6 @@ const handle = async (ctx, comment) => {
}
}
`,
{},
ctx,
{ comment_id: parentID, author_id: authorID }
);
if (reply.errors) {
@@ -53,27 +47,27 @@ const handle = async (ctx, comment) => {
false
);
if (!enabled) {
ctx.log.debug('onStaffReply is false, will not send the notification');
ctx.log.info('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');
ctx.log.info('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');
ctx.log.info('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');
ctx.log.info({ role }, 'reply author is not a staff member');
return;
}
@@ -83,10 +77,7 @@ const handle = async (ctx, comment) => {
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -104,8 +95,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
@@ -0,0 +1,42 @@
.root {
display: flex;
border: 1px solid #a8afb3;
border-radius: 2px;
margin: 16px 0;
p:first-of-type {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
}
.leftColumn {
width: 32px;
background-color: #787d80;
text-align: center;
padding-top: 6px;
font-size: 18px;
flex-shrink: 0;
&.error {
background-color: #fa6265;
}
&.success {
background-color: #00cd7e;
}
}
.icon {
color: white;
}
.rightColumn {
padding: 8px 12px 10px 12px;
font-size: 14px;
}
.title {
font-size: 14px;
margin: 0;
margin-bottom: 4px;
}
@@ -0,0 +1,50 @@
import React from 'react';
import styles from './Banner.css';
import { Icon } from 'coral-ui';
import PropTypes from 'prop-types';
import cn from 'classnames';
function getIcon(icon, error, success) {
if (icon) {
return icon;
}
if (error) {
return 'warning';
}
if (success) {
return 'done';
}
return 'info';
}
const Banner = ({ title, icon, error, success, children }) => (
<section className={styles.root}>
<div
className={cn(styles.leftColumn, {
[styles.error]: error,
[styles.success]: success,
})}
>
<Icon name={getIcon(icon, error, success)} className={styles.icon} />
</div>
<div className={styles.rightColumn}>
<h1 className={styles.title}>{title}</h1>
{children}
</div>
</section>
);
Banner.propTypes = {
title: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.node,
error: PropTypes.bool,
success: PropTypes.bool,
};
Banner.defaultProps = {
title: 'Title',
children: 'Lorem Ipsum Dolot Sit Ahmet',
};
export default Banner;
@@ -0,0 +1,11 @@
.spinner {
display: inline-block;
}
.link {
display: inline-block;
cursor: pointer;
margin: 2px;
color: #2099d6;
border-bottom: 1px solid #2099d6;
}
@@ -0,0 +1,75 @@
import React from 'react';
import Banner from './Banner';
import PropTypes from 'prop-types';
import { Spinner } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
import styles from './EmailVerificationBanner.css';
const EmailVerificationBannerInfo = ({ onResendEmailVerification }) => (
<Banner icon="email" title={t('talk-plugin-notifications.banner_info.title')}>
<p>
{t('talk-plugin-notifications.banner_info.text')}
<a
className={styles.link}
onClick={() => {
onResendEmailVerification();
return false;
}}
>
{t('talk-plugin-notifications.banner_info.verify_now')}
</a>
</p>
</Banner>
);
const EmailVerificationBannerLoading = () => (
<Banner icon="email" title={t('talk-plugin-notifications.banner_info.title')}>
<Spinner className={styles.spinner} />
</Banner>
);
const EmailVerificationBannerError = ({ errorMessage }) => (
<Banner title={t('talk-plugin-notifications.banner_error.title')} error>
<p>{t('talk-plugin-notifications.banner_error.text')}</p>
<p>{errorMessage}</p>
</Banner>
);
const EmailVerificationBannerSuccess = ({ email }) => (
<Banner title={t('talk-plugin-notifications.banner_success.title')} success>
<p>{t('talk-plugin-notifications.banner_success.text', email)}</p>
</Banner>
);
const EmailVerificationBanner = ({
onResendEmailVerification,
email,
success,
loading,
errorMessage,
}) => (
<div>
{success && <EmailVerificationBannerSuccess email={email} />}
{errorMessage && (
<EmailVerificationBannerError errorMessage={errorMessage} />
)}
{loading && <EmailVerificationBannerLoading />}
{!success &&
!errorMessage &&
!loading && (
<EmailVerificationBannerInfo
onResendEmailVerification={onResendEmailVerification}
/>
)}
</div>
);
EmailVerificationBanner.propTypes = {
onResendEmailVerification: PropTypes.func.isRequired,
success: PropTypes.bool.isRequired,
errorMessage: PropTypes.string,
loading: PropTypes.bool.isRequired,
email: PropTypes.string.isRequired,
};
export default EmailVerificationBanner;
@@ -1,5 +1,6 @@
.root {
margin-bottom: 20px;
width: 380px;
}
.innerSettings {
@@ -25,3 +26,7 @@
.notifcationSettingsSlot {
margin-bottom: 3px;
}
.disabled {
color: #e5e5e5;
}
@@ -5,6 +5,8 @@ import { Slot } from 'plugin-api/beta/client/components';
import { t } from 'plugin-api/beta/client/services';
import styles from './Settings.css';
import { BareButton } from 'plugin-api/beta/client/components/ui';
import EmailVerificationBanner from '../containers/EmailVerificationBanner';
import cn from 'classnames';
class Settings extends React.Component {
childFactory = el => {
@@ -23,13 +25,20 @@ class Settings extends React.Component {
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
needEmailVerification,
email,
} = this.props;
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
<h4 className={styles.subtitle}>
{needEmailVerification && <EmailVerificationBanner email={email} />}
<h4
className={cn(styles.subtitle, {
[styles.disabled]: needEmailVerification,
})}
>
{t('talk-plugin-notifications.settings_subtitle')}
</h4>
<div className={styles.innerSettings}>
@@ -40,6 +49,7 @@ class Settings extends React.Component {
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
disabled={needEmailVerification}
/>
<BareButton
className={styles.turnOffButton}
@@ -63,6 +73,8 @@ Settings.propTypes = {
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
needEmailVerification: PropTypes.bool.isRequired,
email: PropTypes.string,
};
export default Settings;
@@ -1,6 +1,20 @@
.title {
display: inline-block;
width: 270px;
width: 100%;
cursor: pointer;
user-select: none;
&.disabled {
color: #e5e5e5;
cursor: default;
}
}
.toggle {
display: flex;
align-items: center;
}
.checkBox {
text-align: right;
}
@@ -3,24 +3,36 @@ import PropTypes from 'prop-types';
import { Checkbox } from 'plugin-api/beta/client/components/ui';
import styles from './Toggle.css';
import uuid from 'uuid/v4';
import cn from 'classnames';
class Toggle extends React.Component {
id = uuid();
render() {
const { checked, onChange, children } = this.props;
const { checked, onChange, children, disabled } = this.props;
return (
<div className={styles.toggle}>
<label htmlFor={this.id} className={styles.title}>
<label
htmlFor={this.id}
className={cn(styles.title, { [styles.disabled]: disabled })}
>
{children}
</label>
<Checkbox checked={checked} onChange={onChange} id={this.id} />
<div className={styles.checkBox}>
<Checkbox
checked={checked}
onChange={onChange}
id={this.id}
disabled={disabled}
/>
</div>
</div>
);
}
}
Toggle.propTypes = {
disabled: PropTypes.bool,
checked: PropTypes.bool,
onChange: PropTypes.func,
children: PropTypes.node,
@@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withResendEmailConfirmation } from 'plugin-api/beta/client/hocs';
import { compose } from 'recompose';
import EmailVerificationBanner from '../components/EmailVerificationBanner';
class EmailVerificationBannerContainer extends Component {
handleResendEmailVerification = () => {
this.props.resendEmailConfirmation(this.props.email);
};
render() {
return (
<EmailVerificationBanner
onResendEmailVerification={this.handleResendEmailVerification}
errorMessage={this.props.errorMessage}
success={this.props.success}
loading={this.props.loading}
email={this.props.email}
/>
);
}
}
EmailVerificationBannerContainer.propTypes = {
success: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
resendEmailConfirmation: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
email: PropTypes.string.isRequired,
};
export default compose(withResendEmailConfirmation)(
EmailVerificationBannerContainer
);
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
@@ -15,13 +15,15 @@ class SettingsContainer extends React.Component {
};
indicateOn = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.concat(plugin),
});
this.setState(state => ({
hasNotifications: state.hasNotifications.concat(plugin),
}));
indicateOff = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.filter(i => i !== plugin),
});
this.setState(state => ({
hasNotifications: state.hasNotifications.filter(i => i !== plugin),
}));
setTurnOffInputFragment = fragment =>
this.setState(state => ({
turnOffInput: { ...state.turnOffInput, ...fragment },
@@ -31,6 +33,12 @@ class SettingsContainer extends React.Component {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
);
}
render() {
return (
<Settings
@@ -42,6 +50,8 @@ class SettingsContainer extends React.Component {
updateNotificationSettings={this.props.updateNotificationSettings}
turnOffAll={this.turnOffAll}
turnOffButtonDisabled={this.state.hasNotifications.length === 0}
needEmailVerification={this.getNeedEmailVerification()}
email={this.props.root.me.email}
/>
);
}
@@ -59,9 +69,22 @@ const enhance = compose(
fragment TalkNotifications_Settings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
me {
email
profiles {
provider
... on LocalUserProfile {
confirmedAt
}
}
}
}
`,
}),
excludeIf(
props =>
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
withUpdateNotificationSettings
);
@@ -3,3 +3,13 @@ en:
settings_title: Notifications
settings_subtitle: Receive notifications when
turn_off_all: I do not want to receive notifications
banner_info:
title: Email Verification Required
text: To receive email notifications you must have a verified email address.
verify_now: Verify your email now
banner_success:
title: Email Verification Sent
text: An email has been sent to {0} containing a verification link.
banner_error:
title: Error
text: There has been an error sending your verification email. Please try again later.
@@ -1,7 +1,10 @@
const { groupBy, forEach, property } = require('lodash');
const { get, find, groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
const {
UNSUBSCRIBE_SUBJECT,
DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
} = require('./config');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
@@ -15,12 +18,12 @@ const handleHandlers = (ctx, handlers, ...args) =>
// 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');
ctx.log.info('no notification deemed by event handler');
return;
}
// Send the notification back.
ctx.log.debug({ category, event }, 'notification detected for event');
ctx.log.info({ category, event }, 'notification detected for event');
return { handler, notification };
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
@@ -31,13 +34,91 @@ const handleHandlers = (ctx, handlers, ...args) =>
// 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
)
const filterSuperseded = (
{ handler: { category }, notification: { userID: destinationUserID } },
index,
notifications
) =>
!notifications.some(
({
handler: { supersedesCategories = [] },
notification: { userID: notificationUserID },
}) =>
// Only allow notifications to supersede another notification if that
// notification is also destined for the same user.
notificationUserID === destinationUserID &&
// If another notification that is destined for the same user also exists
// and declares that it supersedes this one, return true so we can filter
// this one from the list.
supersedesCategories.some(
supersededCategory => supersededCategory === category
)
);
const USER_CONFIRMATION_QUERY = `
query CheckUserConfirmation($userID: ID!) {
user(id: $userID) {
profiles {
provider
... on LocalUserProfile {
confirmedAt
}
}
}
}
`;
// filterVerifiedNotification checks to see if a user has a verified email
// address, and if they do, returns the notification payload again, otherwise,
// returns undefined.
const filterVerifiedNotification = ctx => async notification => {
// Grab the user that we're supposed to be sending the notification to.
const { notification: { userID } } = notification;
// Check their confirmed status. This should have already been hit by the
// loaders, so we shouldn't make any more database requests.
const { errors, data } = await ctx.graphql(USER_CONFIRMATION_QUERY, {
userID,
});
if (errors) {
ctx.log.error(
{ err: errors },
'could not query for user confirmation status'
);
return;
}
// Get the first local profile from the user.
const profile = find(get(data, 'user.profiles', []), ['provider', 'local']);
if (!profile) {
ctx.log.warn({ user_id: userID }, 'user did not have a local profile');
return;
}
// Pull out the confirmed status from the profile.
const confirmed = get(profile, 'confirmedAt', null) !== null;
if (!confirmed) {
ctx.log.info(
{ user_id: userID },
'user did not have their local profile confirmed, but had settings enabled, not mailing'
);
return;
}
return notification;
};
// filterVerified performs filtering in a complicated way because we can't use
// Promise.all on a Array.prototype.filter call.
const filterVerified = async (ctx, notifications) => {
notifications = await Promise.all(
notifications.map(filterVerifiedNotification(ctx))
);
// This acts as a poor-mans identity filter to remove all falsy values.
return notifications.filter(property('notification'));
};
class NotificationManager {
constructor(context) {
this.context = context;
@@ -93,6 +174,12 @@ class NotificationManager {
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// Only let notifications through for users who have their email addresses
// verified if we are configured to do so.
if (!DISABLE_REQUIRE_EMAIL_VERIFICATIONS) {
notifications = await filterVerified(ctx, notifications);
}
// Send the remaining notifications.
return Promise.all(
notifications.map(
@@ -120,7 +207,7 @@ class NotificationManager {
'organizationName'
);
if (organizationName === null) {
ctx.log.debug(
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
@@ -153,7 +240,7 @@ class NotificationManager {
user: userID,
});
ctx.log.debug(`Sent the notification for Job.ID[${task.id}]`);
ctx.log.info(`Sent the notification for Job.ID[${task.id}]`);
} catch (err) {
ctx.log.error(
{ err, message: err.message },
@@ -172,10 +259,10 @@ class NotificationManager {
*/
async getBody(ctx, handler, context) {
const { connectors: { services: { I18n: { t } } } } = ctx;
const { category } = handler;
const { category, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
const replacements = await handler.hydrate(ctx, category, context);
const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
@@ -1,3 +1,8 @@
module.exports = {
UNSUBSCRIBE_SUBJECT: 'nunsub',
// TODO: replace this with a config option in the plugin config when we get there..
DISABLE_REQUIRE_EMAIL_VERIFICATIONS:
process.env.TALK_DISABLE_REQUIRE_EMAIL_VERIFICATIONS_NOTIFICATIONS ===
'TRUE',
};
@@ -1,4 +1,5 @@
const { get } = require('lodash');
const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
module.exports = {
User: {
@@ -16,4 +17,8 @@ module.exports = {
await User.updateNotificationSettings(input);
},
},
Settings: {
notificationsRequireConfirmation: () =>
Boolean(!DISABLE_REQUIRE_EMAIL_VERIFICATIONS),
},
};
@@ -5,6 +5,13 @@ type User {
notificationSettings: NotificationSettings
}
type Settings {
# notificationsRequireConfirmation when true indicates that User's must have
# their email address confirmed/verified before they can receive
# notifications.
notificationsRequireConfirmation: Boolean
}
type UpdateNotificationSettingsResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
+2
View File
@@ -1,6 +1,7 @@
const { version } = require('../package.json');
const Logger = require('bunyan');
const uuid = require('uuid/v1');
const { LOGGING_LEVEL } = require('../config');
// Create the logging instance that all logger's are branched from.
function createLogger(name, id = uuid()) {
@@ -9,6 +10,7 @@ function createLogger(name, id = uuid()) {
name,
id,
version,
level: LOGGING_LEVEL,
serializers: { req: Logger.stdSerializers.req },
});
}