Merge branch 'master' into rte-plugin

This commit is contained in:
Kiwi
2018-03-05 22:25:08 +01:00
committed by GitHub
33 changed files with 589 additions and 110 deletions
+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;
};
@@ -293,7 +306,7 @@ const edit = async (ctx, { id, asset_id, edit: { body, metadata = {} } }) => {
return comment;
};
module.exports = context => {
module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
@@ -302,16 +315,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 },
});
}