Merge branch 'master' of github.com:coralproject/talk into rte-plugin

* 'master' of github.com:coralproject/talk: (103 commits)
  Implement new permlink view for real
  fixes to ci for deploy
  fixed deploy
  changed max retries on unreliable tests
  always save test results
  fixed workspace error
  implemented staff replies
  adjusted client tests
  seperated tests
  more fixes to translation
  fixed translation
  review
  object propery typo
  added safari to integration jobs
  added develop filter
  added deeper cache support
  added dist cache support
  fixed firefox support
  removed headless
  ammended browserstack test
  ...
This commit is contained in:
okbel
2018-03-02 12:30:22 -03:00
160 changed files with 4073 additions and 1759 deletions
@@ -4,10 +4,6 @@ import Main from '../components/Main';
import { connect } from 'plugin-api/beta/client/hocs';
import { bindActionCreators } from 'redux';
import { setView } from '../actions';
import {
setAuthToken,
handleSuccessfulLogin,
} from 'plugin-api/beta/client/actions/auth';
import * as views from '../enums/views';
class MainContainer extends React.Component {
@@ -24,7 +20,6 @@ class MainContainer extends React.Component {
componentDidMount() {
this.resizeHeight();
this.listenToStorageChanges();
}
componentDidUpdate(prevProps) {
@@ -33,40 +28,6 @@ class MainContainer extends React.Component {
}
}
componentWillUnmount() {
this.unlisten();
}
listenToStorageChanges() {
window.addEventListener('storage', this.handleAuth);
}
unlisten() {
window.removeEventListener('storage', this.handleAuth);
}
// External logins store auth data into `auth`, we use it to detect
// a successful sign in.
handleAuth = e => {
if (e.key === 'auth') {
const { err, data } = JSON.parse(e.newValue);
if (err) {
console.error(err);
} else if (data && data.token) {
if (data.user) {
this.props.handleSuccessfulLogin(data.user, data.token);
} else {
this.props.setAuthToken(data.token);
}
this.unlisten();
localStorage.removeItem('auth');
window.close();
} else {
console.error('auth was set, but did not contain a token');
}
}
};
render() {
return <Main onResetView={this.resetView} view={this.props.view} />;
}
@@ -75,8 +36,6 @@ class MainContainer extends React.Component {
MainContainer.propTypes = {
view: PropTypes.string.isRequired,
setView: PropTypes.func.isRequired,
handleSuccessfulLogin: PropTypes.func.isRequired,
setAuthToken: PropTypes.func.isRequired,
};
const mapStateToProps = ({ talkPluginAuth: state }) => ({
@@ -87,8 +46,6 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setView,
handleSuccessfulLogin,
setAuthToken,
},
dispatch
);
@@ -1,7 +1,3 @@
export const loginWithFacebook = () => (dispatch, _, { rest }) => {
window.open(
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
window.location = `${rest.uri}/auth/facebook`;
};
@@ -1,24 +1,31 @@
module.exports = router => {
const { passport, HandleAuthPopupCallback } = require('services/passport');
/**
* Facebook auth endpoint, this will redirect the user immediately to Facebook
* for authorization.
*/
router.get(
'/api/v1/auth/facebook',
passport.authenticate('facebook', {
router.get('/api/v1/auth/facebook', (req, res, next) => {
const {
connectors: { services: { Passport: { passport } } },
} = req.context;
return passport.authenticate('facebook', {
display: 'popup',
authType: 'rerequest',
scope: ['public_profile'],
})
);
})(req, res, next);
});
/**
* Facebook callback endpoint, this will send the user a HTML page designed to
* send back the user credentials upon successful login.
*/
router.get('/api/v1/auth/facebook/callback', (req, res, next) => {
const {
connectors: {
services: { Passport: { passport, HandleAuthPopupCallback } },
},
} = req.context;
// Perform the facebook login flow and pass the data back through the opener.
passport.authenticate(
'facebook',
@@ -1,7 +1,3 @@
export const loginWithGoogle = () => (dispatch, _, { rest }) => {
window.open(
`${rest.uri}/auth/google`,
'Continue with Google',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
window.location = `${rest.uri}/auth/google`;
};
@@ -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({ onFeatured: false });
if (this.getOnFeaturedSetting()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.getOnFeaturedSetting(this.props);
const nextSetting = this.getOnFeaturedSetting(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
getOnFeaturedSetting = (props = this.props) =>
props.root.me.notificationSettings.onFeatured;
toggle = () => {
this.props.updateNotificationSettings({
onFeatured: !this.getOnFeaturedSetting(),
});
};
render() {
return (
<Toggle checked={this.getOnFeaturedSetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-featured.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 TalkNotificationsCategoryFeatured_Toggle_root on RootQuery {
me {
notificationSettings {
onFeatured
}
}
}
`,
})
);
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.onFeatured === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryFeatured_User_Fragment on User {
notificationSettings {
onFeatured
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onFeatured: input.onFeatured,
},
};
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-featured:
toggle_description: My comment is featured
@@ -0,0 +1,118 @@
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');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onFeatured
}
}
}
}
`,
{},
ctx,
{ comment_id: commentID }
);
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.onFeatured',
false
);
if (!enabled) {
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get comment user id');
return;
}
// The user does have notifications for featured 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
}
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
return [headline, permalink];
};
const handler = {
handle,
category: 'featured',
event: 'commentFeatured',
hydrate,
};
module.exports = {
typeDefs: `
type NotificationSettings {
onFeatured: Boolean!
}
input NotificationSettingsInput {
onFeatured: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onFeatured returns false by default if not specified.
onFeatured: settings => get(settings, 'onFeatured', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
featured:
subject: "One of your comments was featured on [{0}]"
body: "{0}\nA member of our team has selected this comment to be featured for other readers: {1}"
@@ -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({ onReply: 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.onReply;
toggle = () => {
this.props.updateNotificationSettings({
onReply: !this.getOnReplySetting(),
});
};
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-reply.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 TalkNotificationsCategoryReply_Toggle_root on RootQuery {
me {
notificationSettings {
onReply
}
}
}
`,
})
);
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.onReply === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryReply_User_Fragment on User {
notificationSettings {
onReply
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onReply: input.onReply,
},
};
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-reply:
toggle_description: My comment receives a reply
@@ -0,0 +1,124 @@
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;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onReply
}
}
}
}
`,
{},
ctx,
{ comment_id: parentID }
);
if (reply.errors) {
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.onReply',
false
);
if (!enabled) {
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 === get(comment, 'author_id')) {
ctx.log.debug('user id of parent comment is the same as the new comment');
return;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
user {
username
}
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const replier = get(comment, 'user.username', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
return [headline, replier, permalink];
};
const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
module.exports = {
typeDefs: `
type NotificationSettings {
onReply: Boolean!
}
input NotificationSettingsInput {
onReply: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onReply returns false by default if not specified.
onReply: settings => get(settings, 'onReply', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
reply:
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}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,27 @@
.root {
margin-bottom: 20px;
}
.innerSettings {
padding-left: 12px;
}
.subtitle {
margin: 0;
margin-bottom: 8px;
}
.turnOffButton {
padding: 2px 0;
color: #2099d6;
border-bottom: 1px solid #2099d6;
&:disabled {
color: #e5e5e5;
border-bottom: 1px solid #e5e5e5;
}
}
.notifcationSettingsSlot {
margin-bottom: 3px;
}
@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IfSlotIsNotEmpty } from 'plugin-api/beta/client/components';
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';
class Settings extends React.Component {
childFactory = el => {
const pluginName = el.type.talkPluginName;
const props = {
indicateOn: () => this.props.indicateOn(pluginName),
indicateOff: () => this.props.indicateOff(pluginName),
};
return React.cloneElement(el, props);
};
render() {
const {
root,
setTurnOffInputFragment,
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
} = this.props;
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
<h4 className={styles.subtitle}>
{t('talk-plugin-notifications.settings_subtitle')}
</h4>
<div className={styles.innerSettings}>
<Slot
className={styles.notifcationSettingsSlot}
fill="notificationSettings"
queryData={{ root }}
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
/>
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={turnOffButtonDisabled}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
</div>
</IfSlotIsNotEmpty>
);
}
}
Settings.propTypes = {
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
};
export default Settings;
@@ -0,0 +1,6 @@
.title {
display: inline-block;
width: 270px;
cursor: pointer;
user-select: none;
}
@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from 'plugin-api/beta/client/components/ui';
import styles from './Toggle.css';
import uuid from 'uuid/v4';
class Toggle extends React.Component {
id = uuid();
render() {
const { checked, onChange, children } = this.props;
return (
<div className={styles.toggle}>
<label htmlFor={this.id} className={styles.title}>
{children}
</label>
<Checkbox checked={checked} onChange={onChange} id={this.id} />
</div>
);
}
}
Toggle.propTypes = {
checked: PropTypes.bool,
onChange: PropTypes.func,
children: PropTypes.node,
};
export default Toggle;
@@ -0,0 +1,68 @@
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 { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
const slots = ['notificationSettings'];
class SettingsContainer extends React.Component {
state = {
hasNotifications: [],
turnOffInput: {},
};
indicateOn = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.concat(plugin),
});
indicateOff = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.filter(i => i !== plugin),
});
setTurnOffInputFragment = fragment =>
this.setState(state => ({
turnOffInput: { ...state.turnOffInput, ...fragment },
}));
turnOffAll = () => {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
render() {
return (
<Settings
data={this.props.data}
root={this.props.root}
indicateOn={this.indicateOn}
indicateOff={this.indicateOff}
setTurnOffInputFragment={this.setTurnOffInputFragment}
updateNotificationSettings={this.props.updateNotificationSettings}
turnOffAll={this.turnOffAll}
turnOffButtonDisabled={this.state.hasNotifications.length === 0}
/>
);
}
}
SettingsContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
updateNotificationSettings: PropTypes.func.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotifications_Settings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
}),
withUpdateNotificationSettings
);
export default enhance(SettingsContainer);
@@ -0,0 +1,23 @@
import { gql } from 'react-apollo';
export default {
fragments: {
UpdateNotificationSettingsResponse: gql`
fragment Talk_UpdateNotificationSettingsResponse on UpdateNotificationSettingsResponse {
errors {
translation_key
}
}
`,
},
mutations: {
UpdateNotificationSettings: () => ({
optimisticResponse: {
updateNotificationSettings: {
__typename: 'UpdateNotificationSettingsResponse',
errors: null,
},
},
}),
},
};
@@ -0,0 +1,11 @@
import Settings from './containers/Settings';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
profileSettings: [Settings],
},
translations,
...graphql,
};
@@ -0,0 +1,23 @@
import { withMutation } from 'plugin-api/beta/client/hocs';
import { gql } from 'react-apollo';
export const withUpdateNotificationSettings = withMutation(
gql`
mutation UpdateNotificationSettings($input: NotificationSettingsInput!) {
updateNotificationSettings(input: $input) {
...UpdateNotificationSettingsResponse
}
}
`,
{
props: ({ mutate }) => ({
updateNotificationSettings: input => {
return mutate({
variables: {
input,
},
});
},
}),
}
);
@@ -0,0 +1,5 @@
en:
talk-plugin-notifications:
settings_title: Notifications
settings_subtitle: Receive notifications when
turn_off_all: I do not want to receive notifications
@@ -0,0 +1 @@
module.exports = require('./server');
@@ -0,0 +1,11 @@
{
"name": "@coralproject/talk-plugin-notifications",
"version": "1.0.0",
"description": "Adds notification support for Talk",
"main": "index.js",
"license": "Apache-2.0",
"private": false,
"dependencies": {
"linkifyjs": "^2.1.5"
}
}
@@ -0,0 +1,188 @@
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;
this.registry = [];
}
/**
* register will include the notification handlers on the manager.
*
* @param {Array<Object>} handlers notification handlers to register
*/
register(...handlers) {
this.registry.push(...handlers);
}
/**
* attach will setup the notifications by walking the registry and loading all
* the notification types onto the handler.
*
* @param {Object} broker the event emitter for the Talk events
*/
attach(broker) {
const events = groupBy(this.registry, 'event');
forEach(events, (handlers, event) => {
debug(
`will now notify the [${handlers
.map(({ category }) => category)
.join(', ')}] handlers when the '${event}' event is emitted`
);
broker.on(event, this.handle(handlers));
});
}
/**
* handle will wrap a notification handler and attach it to the notification
* stream system.
*
* @param {Object} handler a notification handler
*/
handle(handlers) {
return async (...args) => {
// 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);
// Only let handlers past that have a notification to send.
notifications = notifications.filter(property('notification'));
// Check to see if some of the other notifications that are queued
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// 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) {
const {
connectors: {
secrets: { jwt },
config: { JWT_ISSUER, JWT_AUDIENCE },
services: { Mailer, I18n: { t } },
},
loaders: { Settings },
} = ctx;
const { category } = handler;
try {
// Get the settings.
const { organizationName = null } = await Settings.load(
'organizationName'
);
if (organizationName === null) {
ctx.log.debug(
'could not send the notification, organization name not in settings'
);
return;
}
// unsubscribeToken is the token used to perform the one-click
// unsubscribe.
const unsubscribeToken = jwt.sign({
jti: uuid(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: UNSUBSCRIBE_SUBJECT,
user: userID,
});
// Compose the subject for the email.
const subject = t(
`talk-plugin-notifications.categories.${category}.subject`,
organizationName
);
// Load the content into the comment.
const body = await this.getBody(ctx, handler, context);
// Send the notification to the user.
const task = await Mailer.send({
template: 'notification',
locals: { body, organizationName, unsubscribeToken },
subject,
user: userID,
});
ctx.log.debug(`Sent the notification for Job.ID[${task.id}]`);
} catch (err) {
ctx.log.error(
{ err, message: err.message },
'could not send the notification, an error occurred'
);
return;
}
}
/**
* getBody will return the body for the notification payload.
*
* @param {Object} ctx the graph context
* @param {Object} handler the notification handler
* @param {Mixed} context the notification context
*/
async getBody(ctx, handler, context) {
const { connectors: { services: { I18n: { t } } } } = ctx;
const { category } = handler;
// Get the body replacement variables for the translation key.
const replacements = await handler.hydrate(ctx, category, context);
// Generate the body.
return t(
`talk-plugin-notifications.categories.${category}.body`,
...replacements
);
}
}
module.exports = NotificationManager;
@@ -0,0 +1,3 @@
module.exports = {
UNSUBSCRIBE_SUBJECT: 'nunsub',
};
@@ -0,0 +1,72 @@
const debug = require('debug')('talk-plugin-notifications');
const path = require('path');
const linkify = require('linkifyjs/html');
const NotificationManager = require('./NotificationManager');
module.exports = connectors => {
const {
graph: { subscriptions: { getBroker }, Context },
services: { Mailer, Plugins },
} = connectors;
// Setup the mailer. Other plugins registered before this one can replace the
// notification template by passing the same name + format for the template
// registration.
Mailer.templates.register(
path.join(__dirname, 'emails', 'notification.html.ejs'),
'notification',
'html'
);
Mailer.templates.register(
path.join(__dirname, 'emails', 'notification.txt.ejs'),
'notification',
'txt'
);
// Register the mail helpers. You can register your own helpers by calling
// this function in another plugin.
Mailer.registerHelpers({ linkify });
// Get the handle for the broker to attach to notifications.
const broker = getBroker();
// Create a NotificationManager to handle notifications.
const manager = new NotificationManager(Context);
// Get all the notification handlers. Additional plugins registered before
// this one can expose a `notifications` hook, that contains an array of
// notification handlers.
//
// A notification handler has the following form:
//
// {
// event // the graph event to listen for
// handle // the function called when the event is fired. It is called with
// // the (ctx, arg1, arg2, ...) where arg1, arg2 are args from the
// // event.
// category // the name representing the notification type (like 'reply')
// hydrate // returns the replacement parameters (in order!) to be used
// // in the translation.
// }
//
const notificationHandlers = Plugins.get('server', 'notifications').reduce(
(handlers, { plugin, notifications }) => {
debug(
`registered the ${
plugin.name
} plugin for notifications ${notifications.map(
({ category }) => category
)}`
);
handlers.push(...notifications);
return handlers;
},
[]
);
// Attach all the notification handlers.
manager.register(...notificationHandlers);
// Attach the broker to the manager so it can listen for the events.
manager.attach(broker);
};
@@ -0,0 +1,3 @@
<p><%= linkify(body, {nl2br: true}) %></p>
<p><%= t('talk-plugin-notifications.templates.footer', organizationName) %></p>
<p><a href="<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>" target="_blank"><%= t('talk-plugin-notifications.templates.links.unsubscribe') %></a></p>
@@ -0,0 +1,7 @@
<%= body %>
<%= t('talk-plugin-notifications.templates.footer', organizationName) %>
<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>
<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>
@@ -0,0 +1,16 @@
const path = require('path');
const connect = require('./connect');
const typeDefs = require('./typeDefs');
const resolvers = require('./resolvers');
const router = require('./router');
const mutators = require('./mutators');
const translations = path.join(__dirname, 'translations.yml');
module.exports = {
translations,
typeDefs,
resolvers,
mutators,
connect,
router,
};
@@ -0,0 +1,46 @@
const { reduce, isNull, isEmpty } = require('lodash');
/**
* Reduce the settings to dotize the settings.
*/
function reduceSettings(newSettings, newValue, key) {
if (!isNull(newValue)) {
newSettings[`metadata.notifications.settings.${key}`] = newValue;
}
return newSettings;
}
/**
* Update the user notification settings.
*/
async function updateNotificationSettings(ctx, settings) {
const { connectors: { models: { User } }, user } = ctx;
// Generate the settings set object, and just exit if we haven't changed
// anything.
const $set = reduce(settings, reduceSettings, {});
if (isEmpty($set)) {
return;
}
// Update the user.
return User.updateOne({ id: user.id }, { $set });
}
module.exports = ctx => {
let mutators = {
User: {
updateNotificationSettings: () =>
Promise.reject(ctx.connectors.errors.ErrNotAuthorized),
},
};
if (ctx.user) {
// TODO: check to see if the user is verified?
mutators.User.updateNotificationSettings = settings =>
updateNotificationSettings(ctx, settings);
}
return mutators;
};
@@ -0,0 +1,19 @@
const { get } = require('lodash');
module.exports = {
User: {
notificationSettings(user, args, { user: currentUser }) {
if (
currentUser &&
(currentUser.id === user.id || currentUser.can('VIEW_USER_STATUS'))
) {
return get(user, 'metadata.notifications.settings', {});
}
},
},
RootMutation: {
async updateNotificationSettings(obj, { input }, { mutators: { User } }) {
await User.updateNotificationSettings(input);
},
},
};
@@ -0,0 +1,95 @@
const path = require('path');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
const { get, isEmpty, reduce } = require('lodash');
module.exports = router => {
router.get('/account/unsubscribe-notifications', (req, res) => {
res.render(path.join(__dirname, 'views/unsubscribe-notifications'));
});
/**
* Verifies that the token is valid.
*/
const verifyToken = (req, res, next) => {
const {
connectors: { secrets: { jwt }, config: { JWT_ISSUER, JWT_AUDIENCE } },
} = req.context;
const { token: tokenString = '' } = req.body;
if (!tokenString) {
return res.status(400).end();
}
jwt.verify(
tokenString,
{
issuer: JWT_ISSUER,
subject: UNSUBSCRIBE_SUBJECT,
audience: JWT_AUDIENCE,
},
(err, token) => {
if (err) {
return res.status(400).end();
}
req.token = token;
next();
}
);
};
// Verifies that a token is valid.
router.post(
'/api/v1/account/unsubscribe-notifications/verify',
verifyToken,
(req, res) => {
res.status(204).end();
}
);
router.post(
'/api/v1/account/unsubscribe-notifications',
verifyToken,
async (req, res, next) => {
const { connectors: { models: { User } } } = req.context;
const { user: userID } = req.token;
try {
const user = await User.findOne({ id: userID });
if (!user) {
return res.status(400).end();
}
// Get the notification settings.
const settings = get(user, 'metadata.notifications.settings', {});
// If they have no notification settings set to true, then we're done.
if (isEmpty(settings)) {
return res.status(204).end();
}
const update = reduce(
settings,
(updates, value, key) => {
if (value) {
updates[`metadata.notifications.settings.${key}`] = false;
}
return updates;
},
{}
);
if (isEmpty(update)) {
return res.status(204).end();
}
// Save the user.
await User.update({ id: userID }, { $set: update });
res.status(204).end();
} catch (err) {
res.status(400).end();
}
}
);
};
@@ -0,0 +1,12 @@
en:
talk-plugin-notifications:
templates:
footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications."
links:
unsubscribe: "Unsubscribe from comment notifications"
unsubscribe_page:
unsubscribe: "Unsubscribe from comment notifications"
click_to_confirm: "Click below to confirm that you would like to unsubscribe from all notifications"
confirm: "Confirm"
are_unsubscribed: "You are now unsubscribed from all notifications."
token_invalid: "Unsubscribe link is invalid, click the link from a more recent email or visit a comment stream and login to change your notification preferences"
@@ -0,0 +1,21 @@
# NotificationSettings stores all the preferences related to notifications.
type NotificationSettings { }
type User {
notificationSettings: NotificationSettings
}
type UpdateNotificationSettingsResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
input NotificationSettingsInput {
}
type RootMutation {
# updateNotificationSettings will update the current user's notification
# settings.
updateNotificationSettings(input: NotificationSettingsInput!): UpdateNotificationSettingsResponse
}
@@ -0,0 +1,7 @@
const fs = require('fs');
const path = require('path');
module.exports = fs.readFileSync(
path.join(__dirname, 'typeDefs.graphql'),
'utf8'
);
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
<div class="error-console container"><%= t('talk-plugin-notifications.unsubscribe_page.token_invalid') %></div>
<div id="success" style="display:none;" class="legend container"><%= t('talk-plugin-notifications.unsubscribe_page.are_unsubscribed') %></div>
<form id="unsubscribe-form" class="container">
<legend class="legend"><%= t('talk-plugin-notifications.unsubscribe_page.click_to_confirm') %></legend>
<button type="submit"><%= t('talk-plugin-notifications.unsubscribe_page.confirm') %></button>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script type="text/javascript">
$(function() {
var submitting = false;
var payload = JSON.stringify({token: location.hash.replace('#', '')});
function handleSubmit(e) {
e.preventDefault();
if (submitting) {
return;
}
submitting = true;
$('.error-console').removeClass('active');
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/unsubscribe-notifications',
contentType: 'application/json',
method: 'POST',
data: payload,
}).then(function (success) {
$('#unsubscribe-form').fadeOut(function () {
$('#success').fadeIn();
});
}).catch(function () {
submitting = false;
$('.error-console').addClass('active');
});
}
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/unsubscribe-notifications/verify',
contentType: 'application/json',
method: 'POST',
data: payload,
})
.then(function () {
$('#unsubscribe-form').fadeIn().on('submit', handleSubmit);
})
.catch(function () {
$('.error-console').addClass('active');
});
});
</script>
</body>
</html>