Merge master

This commit is contained in:
Mendel Konikov
2018-04-10 20:10:21 -04:00
537 changed files with 14237 additions and 4489 deletions
+21
View File
@@ -0,0 +1,21 @@
---
title: talk-plugin-akismet
permalink: /plugin/talk-plugin-akismet/
layout: plugin
plugin:
name: talk-plugin-akismet
provides:
- Server
- Client
---
Enables spam detection from [Akismet](https://akismet.com/). Comments will be passed to the Akismet API for spam detection. If a comment
is determined to be spam, it will prompt the user, indicating that the comment might be considered spam. If the user continues after this
point with the still spam-like comment, the comment will be reported as containing spam, and sent for moderator approval.
**Note: [Akismet](https://akismet.com/) is a premium service, charges may apply.**
Configuration:
- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page.
- `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/`
@@ -15,8 +15,11 @@ export default class CheckSpamHook extends React.Component {
// If we haven't check the spam yet, make sure to include `checkSpam=true` in the mutation.
// Otherwise post comment without checking the spam.
if (!this.checked) {
input.checkSpam = true;
this.checked = true;
return {
...input,
checkSpam: true,
};
}
});
@@ -1,3 +1,17 @@
ar:
error:
COMMENT_IS_SPAM: |
تبدو اللغة في هذا التعليق مثل الرسائل غير المرغوب فيها. تستطيع تعديل التعليق أو تقديمه على أي حال لمراجعة مشرف.
talk-plugin-akismet:
spam: "بريد غير مرغوب فيه"
spam_comment: "بريد غير مرغوب فيه"
detected: "الكشف عنها من قبل Akismet"
still_spam: |
شكرا لكم. سيراجع فريق الإشراف لدينا تعليقك قريبا.
flags:
reasons:
comment:
spam_comment: "تم اكتشاف الرسائل غير المرغوب فيها"
da:
error:
COMMENT_IS_SPAM: |
+16
View File
@@ -0,0 +1,16 @@
---
title: talk-plugin-auth
permalink: /plugin/talk-plugin-auth/
layout: plugin
plugin:
name: talk-plugin-auth
default: true
provides:
- Client
---
This provides the base plugin that is the basis for all auth based plugins that
utilize our internal authentication system.
To sync Talk auth with your own auth systems, you can use this plugin as a
template.
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Slot } from 'plugin-api/beta/client/components';
import {
Button,
TextField,
@@ -28,6 +29,15 @@ class SignUp extends React.Component {
this.props.onSubmit();
};
childFactory = el => {
const key = el.key;
const props = {
indicateBlocker: () => this.props.indicateBlocker(key),
indicateBlockerResolved: () => this.props.indicateBlockerResolved(key),
};
return React.cloneElement(el, props);
};
render() {
const {
username,
@@ -42,6 +52,7 @@ class SignUp extends React.Component {
errorMessage,
requireEmailConfirmation,
success,
blocked,
} = this.props;
return (
@@ -103,6 +114,10 @@ class SignUp extends React.Component {
onChange={this.handlePasswordRepeatChange}
minLength="8"
/>
<Slot
fill="talkPluginAuth.formField"
childFactory={this.childFactory}
/>
<div className={styles.action}>
<Button
type="submit"
@@ -110,6 +125,7 @@ class SignUp extends React.Component {
id="coralSignUpButton"
className={styles.button}
full
disabled={blocked}
>
{t('talk-plugin-auth.login.sign_up')}
</Button>
@@ -161,6 +177,9 @@ SignUp.propTypes = {
errorMessage: PropTypes.string,
requireEmailConfirmation: PropTypes.bool.isRequired,
success: PropTypes.bool.isRequired,
blocked: PropTypes.bool.isRequired,
indicateBlocker: PropTypes.func.isRequired,
indicateBlockerResolved: PropTypes.func.isRequired,
};
export default SignUp;
@@ -16,8 +16,19 @@ class SignUpContainer extends Component {
emailError: null,
passwordError: null,
passwordRepeatError: null,
blockers: [],
};
indicateBlocker = key =>
this.setState(state => ({
blockers: state.blockers.concat(key),
}));
indicateBlockerResolved = key =>
this.setState(state => ({
blockers: state.blockers.filter(i => i !== key),
}));
validate = data => {
let valid = true;
const changes = {};
@@ -48,7 +59,7 @@ class SignUpContainer extends Component {
passwordRepeat: this.state.passwordRepeat,
};
if (this.validate(data)) {
if (this.validate(data) && !this.state.blockers.length) {
this.props.signUp(data);
}
};
@@ -76,6 +87,9 @@ class SignUpContainer extends Component {
render() {
return (
<SignUp
indicateBlocker={this.indicateBlocker}
indicateBlockerResolved={this.indicateBlockerResolved}
blocked={!!this.state.blockers.length}
onSubmit={this.handleSubmit}
onUsernameChange={this.setUsername}
onEmailChange={this.props.setEmail}
@@ -1,3 +1,48 @@
ar:
talk-plugin-auth:
login:
email_verify_cta: "يرجى التحقق من عنوان البريد الإلكتروني الخاص بك."
request_new_verify_email: "طلب بريد إلكتروني آخر"
verify_email: "شكرا لك على إنشاء حساب جديد! لقد أرسلنا رسالة إلكترونية إلى البريد الإلكتروني الذي قدمته للتحقق من حسابك."
verify_email2: "يجب إثبات ملكية حسابك قبل التفاعل مع المجموعة."
not_you: "ليس انت؟"
logged_in_as: "تسجيل الدخول ك"
facebook_sign_in: "تسجيل الدخول باستخدام الفيسبوك"
facebook_sign_up: "اشترك عبر حساب فايسبوك"
logout: "خروج"
sign_in: "تسجيل الدخول"
sign_in_to_join: "سجل الدخول للانضمام إلى المحادثة"
or: "أو"
email: "البريد الإلكتروني"
password: "كلمة المرور"
forgot_your_pass: "نسيت كلمة المرور؟"
need_an_account: "تحتاج الى حساب؟"
register: "تسجيل"
sign_up: "سجل"
confirm_password: "تأكيد كلمة المرور"
username: "اسم المستخدم"
already_have_an_account: "هل لديك حساب؟"
recover_password: "إستعادة كلمة المرور"
email_in_use: "البريد الالكتروني قيد الاستخدام"
email_or_username_in_use: "البريد الإلكتروني أو اسم المستخدم قيد الاستخدام"
required_field: "هذه الخانة مطلوبه"
passwords_dont_match: "كلمات المرور غير متطابقة."
special_characters: "يمكن أن تحتوي أسماء المستخدمين على أحرف وأرقام و _ فقط"
sign_in_to_comment: "تسجيل الدخول للتعليق"
check_the_form: "استمارة غير صالحة. يرجى التحقق من الحقول"
set_username_dialog:
check_the_form: "استمارة غير صالحة. يرجى التحقق من الحقول"
continue: "تابع بنفس اسم المستخدم الخاص بفيسبوك"
error_create: "حدث خطأ أثناء تغيير اسم المستخدم"
fake_comment_body: "هذا مثال للتعليق. يمكن للقراء تبادل الأفكار والآراء مع غرف الأخبار في قسم التعليقات."
fake_comment_date: "منذ دقيقة"
if_you_dont_change_your_name: "إذا لم تقم بتغيير اسم المستخدم الخاص بك في هذه الخطوة سوف يظهر اسم المستخدم الخاص بفيسبوك جنبا إلى جنب مع كل تعليقاتك."
required_field: "حقل مطلوب"
save: حفظ
special_characters: "يمكن أن تحتوي أسماء المستخدمين على أحرف, أرقام و _ فقط"
username: اسم المستخدم
write_your_username: "عدل اسم المستخدم"
your_username: "يظهر اسم المستخدم في كل تعليق تنشره."
da:
talk-plugin-auth:
login:
+20
View File
@@ -0,0 +1,20 @@
---
title: talk-plugin-author-menu
permalink: /plugin/talk-plugin-author-menu/
layout: plugin
plugin:
name: talk-plugin-author-menu
default: true
provides:
- Client
---
Enables plugins to integrate into the embed stream at the author name location.
We have recipes for showing the commenter's "member since" date, and to show a
subscriber badge. These will require some integration on your side to connect
them to the data source that houses this information.
- [talk-plugin-member-since](/talk/plugin/talk-plugin-member-since)
- [talk-plugin-subscriber](/talk/plugin/talk-plugin-subscriber)
To get started, check out our [Talk Recipes](https://github.com/coralproject/talk-recipes).
@@ -1,14 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import Menu from './Menu';
import styles from './AuthorName.css';
import { ClickOutside } from 'plugin-api/beta/client/components';
import cn from 'classnames';
export default ({
data,
root,
asset,
comment,
const AuthorName = ({
slotPassthrough,
username,
contentSlot,
menuVisible,
toggleMenu,
@@ -21,18 +20,23 @@ export default ({
className={cn(styles.button, 'talk-plugin-author-menu-button')}
onClick={toggleMenu}
>
<span className={styles.name}>{comment.user.username}</span>
<span className={styles.name}>{username}</span>
</button>
{menuVisible && (
<Menu
data={data}
root={root}
asset={asset}
comment={comment}
contentSlot={contentSlot}
/>
<Menu slotPassthrough={slotPassthrough} contentSlot={contentSlot} />
)}
</div>
</ClickOutside>
);
};
AuthorName.propTypes = {
slotPassthrough: PropTypes.object.isRequired,
username: PropTypes.string.isRequired,
menuVisible: PropTypes.bool.isRequired,
toggleMenu: PropTypes.func.isRequired,
hideMenu: PropTypes.func.isRequired,
contentSlot: PropTypes.string,
};
export default AuthorName;
@@ -1,17 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Menu.css';
import { Slot } from 'plugin-api/beta/client/components';
import cn from 'classnames';
export default ({ data, root, asset, comment, contentSlot }) => {
const Menu = ({ slotPassthrough, contentSlot }) => {
if (contentSlot) {
return (
<div className={cn(styles.menu, 'talk-plugin-author-menu-popup')}>
<Slot
fill={contentSlot}
data={data}
queryData={{ asset, root, comment }}
/>
<Slot fill={contentSlot} passthrough={slotPassthrough} />
</div>
);
}
@@ -21,15 +18,20 @@ export default ({ data, root, asset, comment, contentSlot }) => {
<Slot
className={cn('talk-plugin-author-menu-infos')}
fill={'authorMenuInfos'}
data={data}
queryData={{ asset, root, comment }}
passthrough={slotPassthrough}
/>
<Slot
className={cn(styles.actions, 'talk-plugin-author-menu-actions')}
fill={'authorMenuActions'}
data={data}
queryData={{ asset, root, comment }}
passthrough={slotPassthrough}
/>
</div>
);
};
Menu.propTypes = {
slotPassthrough: PropTypes.object.isRequired,
contentSlot: PropTypes.string,
};
export default Menu;
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import { bindActionCreators } from 'redux';
import AuthorName from '../components/AuthorName';
@@ -47,21 +48,39 @@ class AuthorNameContainer extends React.Component {
};
render() {
const {
root,
asset,
comment,
contentSlot,
showMenuForComment,
} = this.props;
const slotPassthrough = { root, asset, comment };
return (
<AuthorName
data={this.props.data}
root={this.props.root}
asset={this.props.asset}
comment={this.props.comment}
contentSlot={this.props.contentSlot}
menuVisible={this.props.showMenuForComment === this.props.comment.id}
username={comment.user.username}
contentSlot={contentSlot}
menuVisible={showMenuForComment === comment.id}
toggleMenu={this.toggleMenu}
hideMenu={this.hideMenu}
slotPassthrough={slotPassthrough}
/>
);
}
}
AuthorNameContainer.propTypes = {
root: PropTypes.object.isRequired,
asset: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
contentSlot: PropTypes.string,
showMenuForComment: PropTypes.string,
openMenu: PropTypes.func.isRequired,
closeMenu: PropTypes.func.isRequired,
};
const slots = ['authorMenuInfos', 'authorMenuActions'];
const mapStateToProps = ({ talkPluginAuthorMenu: state }) => ({
@@ -1,3 +1,5 @@
ar:
talk-plugin-author-menu:
da:
talk-plugin-author-menu:
en:
@@ -0,0 +1,15 @@
---
title: talk-plugin-comment-content
permalink: /plugin/talk-plugin-comment-content/
layout: plugin
plugin:
name: talk-plugin-comment-content
default: true
provides:
- Client
---
Pluginizes the text of a comment to support custom treatment of this text. This
plugin currently parses the given text to see if it contains a link, and makes
them clickable using
[react-linkify](https://www.npmjs.com/package/react-linkify).
@@ -0,0 +1,15 @@
---
title: talk-plugin-deep-reply-count
permalink: /plugin/talk-plugin-deep-reply-count/
layout: plugin
plugin:
name: talk-plugin-deep-reply-count
provides:
- Server
---
The Deep Reply Count plugin will add a new graph edge, `Comment.deepReplyCount`
that will return the count of all descendant replies.
**Warning: Enabling the talk-plugin-deep-reply-count plugin introduces a significant
performance impact on larger sites, use with care.**
@@ -4,7 +4,7 @@ const DataLoader = require('dataloader');
const CommentModel = require('../../models/comment');
console.warn(
'Enabling the talk-plugin-deep-reply-count plugin introduces a signifigant performance impact on larger sites, use with care.'
'Enabling the talk-plugin-deep-reply-count plugin introduces a significant performance impact on larger sites, use with care.'
);
// genDeepCommentCount will return the deep comment count for a given parent id.
@@ -72,7 +72,7 @@ module.exports = {
typeDefs: `
type Comment {
# deepReplyCount is the count of all decendant replies.
# deepReplyCount is the count of all descendant replies.
deepReplyCount: Int
}
`,
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,39 @@
.container {
display: inline-block;
}
.button {
color: #2a2a2a;
margin: 5px 10px 5px 0px;
background: none;
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
cursor: pointer;
}
&.downvoted {
color: #cc0000;
&:hover {
color: #ff3232;
cursor: pointer;
}
}
}
.icon {
font-size: 12px;
padding: 0 3px;
}
@media (max-width: 425px) {
.label {
display: none;
}
}
@@ -0,0 +1,56 @@
import React from 'react';
import Icon from './Icon';
import styles from './DownvoteButton.css';
import { withReaction } from 'plugin-api/beta/client/hocs';
import cn from 'classnames';
const plugin = 'talk-plugin-downvote';
class DownvoteButton extends React.Component {
handleClick = () => {
const {
postReaction,
deleteReaction,
showSignInDialog,
alreadyReacted,
user,
} = this.props;
// If the current user does not exist, trigger sign in dialog.
if (!user) {
showSignInDialog();
return;
}
if (alreadyReacted) {
deleteReaction();
} else {
postReaction();
}
};
render() {
const { count, alreadyReacted } = this.props;
return (
<div className={cn(styles.container, `${plugin}-container`)}>
<button
className={cn(
styles.button,
{
[`${
styles.downvoted
} talk-plugin-downvote-downvoted`]: alreadyReacted,
},
`${plugin}-button`
)}
onClick={this.handleClick}
>
<Icon className={cn(styles.icon, `${plugin}-icon`)} />
<span className={cn(`${plugin}-count`)}>{count > 0 && count}</span>
</button>
</div>
);
}
}
export default withReaction('downvote')(DownvoteButton);
@@ -0,0 +1,10 @@
import React from 'react';
import cn from 'classnames';
// @TODO change icon when we deprecate FA
export default ({ className }) => (
<i
className={cn('fa', 'fa-arrow-circle-down', className)}
aria-hidden="true"
/>
);
@@ -0,0 +1,7 @@
import DownvoteButton from './components/DownvoteButton';
export default {
slots: {
commentReactions: [DownvoteButton],
},
};
+2
View File
@@ -0,0 +1,2 @@
const { getReactionConfig } = require('../../plugin-api/beta/server');
module.exports = getReactionConfig('downvote');
@@ -0,0 +1,9 @@
{
"name": "@coralproject/talk-plugin-downvote",
"pluginName": "talk-plugin-downvote",
"version": "0.0.1",
"description": "Downvote comments",
"main": "index.js",
"author": "The Coral Project Team <coral@mozillafoundation.org>",
"license": "Apache-2.0"
}
@@ -0,0 +1,30 @@
---
title: talk-plugin-facebook-auth
permalink: /plugin/talk-plugin-facebook-auth/
layout: plugin
plugin:
name: talk-plugin-facebook-auth
depends:
- name: talk-plugin-auth
provides:
- Server
- Client
---
Enables sign-in via Facebook via the server side passport middleware.
Configuration:
- `TALK_FACEBOOK_APP_ID` (**required**) - The Facebook App ID for your Facebook
Login enabled app. You can learn more about getting a Facebook App ID at the
[Facebook Developers Portal](https://developers.facebook.com) or by visiting
the [Creating an App ID](https://developers.facebook.com/docs/apps/register)
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
enabled.
- `TALK_FACEBOOK_APP_SECRET` (**required**) - The Facebook App Secret for your
Facebook Login enabled app. You can learn more about getting a Facebook App
Secret at the [Facebook Developers Portal](https://developers.facebook.com)
or by visiting the
[Creating an App ID](https://developers.facebook.com/docs/apps/register)
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
enabled.
@@ -1,3 +1,7 @@
ar:
talk-plugin-facebook-auth:
sign_in: "تسجيل الدخول عبر حساب الفيسبوك"
sign_up: "اشترك عبر حساب الفيسبوك"
en:
talk-plugin-facebook-auth:
sign_in: "Sign in with Facebook"
@@ -25,7 +25,14 @@ module.exports = passport => {
async (req, accessToken, refreshToken, profile, done) => {
let user;
try {
user = await UsersService.findOrCreateExternalUser(profile);
const { id, provider, displayName } = profile;
user = await UsersService.findOrCreateExternalUser(
req.context,
id,
provider,
displayName
);
} catch (err) {
return done(err);
}
@@ -0,0 +1,19 @@
---
title: talk-plugin-featured-comments
permalink: /plugin/talk-plugin-featured-comments/
layout: plugin
plugin:
name: talk-plugin-featured-comments
default: true
provides:
- Server
- Client
---
Enables the ability for Moderators to feature and un-feature comments via the
Stream and the Admin. Featured comments show in a first-place tab on the Stream
if there are any featured comments on that story.
When paired with the [talk-plugin-moderator-actions](/talk/plugin/talk-plugin-moderator-actions)
plugin, moderators will have the option of featuring comments from the comment
stream.
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './Comment.css';
import { t } from 'plugin-api/beta/client/services';
@@ -18,8 +19,8 @@ class Comment extends React.Component {
};
render() {
const { comment, asset, root, data } = this.props;
const queryData = { comment, asset, root };
const { comment, asset, root } = this.props;
const slotPassthrough = { comment, asset, root };
return (
<div className={cn(styles.root, `${pluginName}-comment`)}>
<Slot
@@ -27,8 +28,8 @@ class Comment extends React.Component {
className={cn(styles.quote, `${pluginName}-comment-body`)}
fill="commentContent"
defaultComponent={CommentContent}
data={data}
queryData={queryData}
passthrough={slotPassthrough}
size={1}
/>
<div className={cn(`${pluginName}-comment-username-box`)}>
@@ -36,8 +37,7 @@ class Comment extends React.Component {
className={cn(styles.username, `${pluginName}-comment-username`)}
fill="commentAuthorName"
defaultComponent={CommentAuthorName}
queryData={queryData}
data={data}
passthrough={slotPassthrough}
inline
/>
@@ -45,9 +45,7 @@ class Comment extends React.Component {
fill="commentTimestamp"
defaultComponent={CommentTimestamp}
className={cn(styles.timestamp, `${pluginName}-comment-timestamp`)}
created_at={comment.created_at}
data={data}
queryData={queryData}
passthrough={{ created_at: comment.created_at, ...slotPassthrough }}
inline
/>
</div>
@@ -62,19 +60,11 @@ class Comment extends React.Component {
>
<Slot
fill="commentReactions"
root={root}
data={data}
comment={comment}
asset={asset}
passthrough={slotPassthrough}
inline
/>
<FeaturedButton
root={root}
data={data}
comment={comment}
asset={asset}
/>
<FeaturedButton root={root} comment={comment} asset={asset} />
</div>
<div
className={cn(
@@ -99,4 +89,11 @@ class Comment extends React.Component {
}
}
Comment.propTypes = {
viewComment: PropTypes.func,
comment: PropTypes.object,
asset: PropTypes.object,
root: PropTypes.object,
};
export default Comment;
@@ -22,7 +22,6 @@ class TabPane extends React.Component {
render() {
const {
root,
data,
asset: { featuredComments, ...asset },
viewComment,
} = this.props;
@@ -32,7 +31,6 @@ class TabPane extends React.Component {
<Comment
key={comment.id}
root={root}
data={data}
comment={comment}
asset={asset}
viewComment={viewComment}
@@ -1,6 +1,7 @@
import React from 'react';
import { gql } from 'react-apollo';
import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql';
import { compose, withSubscribeToMore } from 'plugin-api/beta/client/hocs';
class ModIndicatorSubscription extends React.Component {
subscriptions = null;
@@ -27,7 +28,7 @@ class ModIndicatorSubscription extends React.Component {
},
];
this.subscriptions = configs.map(config =>
this.props.data.subscribeToMore(config)
this.props.subscribeToMore(config)
);
}
@@ -60,4 +61,4 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql`
}
`;
export default ModIndicatorSubscription;
export default compose(withSubscribeToMore)(ModIndicatorSubscription);
@@ -6,6 +6,11 @@ import { getDefinitionName } from 'coral-framework/utils';
import truncate from 'lodash/truncate';
import t from 'coral-framework/services/i18n';
import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql';
import {
compose,
withSubscribeToMore,
withVariables,
} from 'plugin-api/beta/client/hocs';
function prepareNotificationText(text) {
return truncate(text, { length: 50 }).replace('\n', ' ');
@@ -19,7 +24,7 @@ class ModSubscription extends React.Component {
{
document: COMMENT_FEATURED_SUBSCRIPTION,
variables: {
assetId: this.props.data.variables.asset_id,
assetId: this.props.variables.asset_id,
},
updateQuery: (
prev,
@@ -39,7 +44,7 @@ class ModSubscription extends React.Component {
{
document: COMMENT_UNFEATURED_SUBSCRIPTION,
variables: {
assetId: this.props.data.variables.asset_id,
assetId: this.props.variables.asset_id,
},
updateQuery: (
prev,
@@ -62,7 +67,7 @@ class ModSubscription extends React.Component {
},
];
this.subscriptions = configs.map(config =>
this.props.data.subscribeToMore(config)
this.props.subscribeToMore(config)
);
}
@@ -111,4 +116,8 @@ const mapStateToProps = state => ({
user: state.auth.user,
});
export default connect(mapStateToProps, null)(ModSubscription);
export default compose(
connect(mapStateToProps, null),
withVariables,
withSubscribeToMore
)(ModSubscription);
@@ -2,7 +2,12 @@ import React from 'react';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
import TabPane from '../components/TabPane';
import { withFragments, connect } from 'plugin-api/beta/client/hocs';
import {
withFragments,
connect,
withFetchMore,
withVariables,
} from 'plugin-api/beta/client/hocs';
import Comment from '../containers/Comment';
import { viewComment } from 'coral-embed-stream/src/actions/stream';
import {
@@ -13,15 +18,15 @@ import update from 'immutability-helper';
class TabPaneContainer extends React.Component {
loadMore = () => {
return this.props.data.fetchMore({
return this.props.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.asset.featuredComments.endCursor,
asset_id: this.props.asset.id,
sortOrder: this.props.data.variables.sortOrder,
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
sortOrder: this.props.variables.sortOrder,
sortBy: this.props.variables.sortBy,
excludeIgnored: this.props.variables.excludeIgnored,
},
updateQuery: (previous, { fetchMoreResult: { comments } }) => {
const updated = update(previous, {
@@ -86,6 +91,8 @@ const mapDispatchToProps = dispatch =>
const enhance = compose(
connect(null, mapDispatchToProps),
withFetchMore,
withVariables,
withFragments({
root: gql`
fragment TalkFeaturedComments_TabPane_root on RootQuery {
@@ -1,3 +1,18 @@
ar:
talk-plugin-featured-comments:
un_feature: إلغاء التميّز
feature: ميّز
featured: متميز
featured_comments: التعليقات المميزة
go_to_conversation: انتقل إلى المحادثة
tooltip_description: تعليقات مختارة من قبل فريقنا تستحق القراءة
notify_self_featured: 'التعليق من {0} هو الآن مميّز و موافق عليه'
notify_featured: '{0} ميّز و وافق على التعليق "{1}"'
notify_unfeatured: '{0} ألغي تميّز التعليق "{1}"'
feature_comment: ميّز التعليق؟
are_you_sure: هل أنت متأكد أنك تريد تميّز هذا التعليق؟
cancel: إلغاء
yes_feature_comment: نعم، ميّز التعليق؟
da:
talk-plugin-featured-comments:
un_feature: Un-Feature
@@ -0,0 +1,13 @@
---
title: talk-plugin-flag-details
permalink: /plugin/talk-plugin-flag-details/
layout: plugin
plugin:
name: talk-plugin-flag-details
default: true
provides:
- Client
---
Pluginizes the Flag Details area of comments in the Moderation Queues to display
data. Some basic details are already included on flags by default.
@@ -10,7 +10,7 @@ import {
class FlagDetails extends Component {
render() {
const { comment: { actions }, more, data, root, comment } = this.props;
const { comment: { actions }, more, root, comment } = this.props;
const flagActions =
actions && actions.filter(a => a.__typename === 'FlagAction');
@@ -26,7 +26,7 @@ class FlagDetails extends Component {
}, {});
const reasons = Object.keys(summaries);
const queryData = {
const slotPassthrough = {
root,
comment,
};
@@ -52,12 +52,11 @@ class FlagDetails extends Component {
{more && (
<IfSlotIsNotEmpty
slot="adminCommentMoreFlagDetails"
queryData={queryData}
passthrough={slotPassthrough}
>
<Slot
fill="adminCommentMoreFlagDetails"
data={data}
queryData={queryData}
passthrough={slotPassthrough}
/>
</IfSlotIsNotEmpty>
)}
@@ -68,7 +67,6 @@ class FlagDetails extends Component {
FlagDetails.propTypes = {
more: PropTypes.bool,
data: PropTypes.object,
root: PropTypes.object,
comment: PropTypes.shape({
actions: PropTypes.arrayOf(
@@ -1,3 +1,6 @@
ar:
talk-plugin-flag-details:
flags: تقارير
da:
talk-plugin-flag-details:
flags: Reports
+29
View File
@@ -0,0 +1,29 @@
---
title: talk-plugin-google-auth
permalink: /plugin/talk-plugin-google-auth/
layout: plugin
plugin:
name: talk-plugin-google-auth
depends:
- name: talk-plugin-auth
provides:
- Server
- Client
---
Enables sign-in via Google+ via the server side passport middleware.
You will need to enable the Google+ API in the dashboard and create credentials
for a new OAuth client ID web application. The authorized JavaScript origin
should be set to the Talk domain, and the authorized redirect URI should be set
to http://<example.com>/api/v1/auth/google/callback. This is only required while
the `talk-plugin-google-auth` plugin is enabled.
Configuration:
- `TALK_GOOGLE_CLIENT_ID` (**required**) - The Google OAuth2 client ID for your
Google login web app. You can learn more about getting a Google Client ID at
the [Google API Console](https://console.developers.google.com/apis/).
- `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for
your Google login web app. You can learn more about getting a Google Client
ID at the [Google API Console](https://console.developers.google.com/apis/).
@@ -1,3 +1,7 @@
ar:
talk-plugin-google-auth:
sign_in: "تسجيل الدخول عبر حساب جوجل"
sign_up: "اشترك عبر حساب جوجل"
en:
talk-plugin-google-auth:
sign_in: "Sign in with Google"
@@ -24,9 +24,16 @@ module.exports = passport => {
async (req, accessToken, refreshToken, profile, done) => {
let user;
try {
user = await UsersService.findOrCreateExternalUser(profile);
const { id, provider, displayName } = profile;
user = await UsersService.findOrCreateExternalUser(
req.context,
id,
provider,
displayName
);
} catch (err) {
return done(err.toString());
return done(err);
}
return ValidateUserLogin(profile, user, done);
+14
View File
@@ -0,0 +1,14 @@
---
title: talk-plugin-ignore-user
permalink: /plugin/talk-plugin-ignore-user/
layout: plugin
plugin:
name: talk-plugin-ignore-user
default: true
provides:
- Client
---
Enables ability for users to ignore (or "mute") other users. If a user is
ignored, you will not see any of their comments. You can un-ignore a user via
the My Profile tab.
@@ -20,3 +20,7 @@
color: #D0011B;
text-decoration: underline;
}
.blank {
color: #7d8285;
}
@@ -1,23 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './IgnoredUserSection.css';
import { t } from 'plugin-api/beta/client/services';
export default ({ ignoredUsers, stopIgnoringUser }) => (
const IgnoreUserSection = ({ ignoredUsers, stopIgnoringUser }) => (
<section className={'talk-plugin-ignore-user-section'}>
<h3>{t('talk-plugin-ignore-user.section_title')}</h3>
<p>{t('talk-plugin-ignore-user.section_info')}</p>
<ul className={styles.list}>
{ignoredUsers.map(({ username, id }) => (
<li className={styles.listItem} key={id}>
<span className={styles.username}>{username}</span>
<button
onClick={() => stopIgnoringUser({ id })}
className={styles.button}
>
{t('talk-plugin-ignore-user.stop_ignoring')}
</button>
</li>
))}
</ul>
{!ignoredUsers.length && (
<p className={styles.blank}>{t('talk-plugin-ignore-user.blank_info')}</p>
)}
{!!ignoredUsers.length && (
<div>
<p>{t('talk-plugin-ignore-user.section_info')}</p>
<ul className={styles.list}>
{ignoredUsers.map(({ username, id }) => (
<li className={styles.listItem} key={id}>
<span className={styles.username}>{username}</span>
<button
onClick={() => stopIgnoringUser({ id })}
className={styles.button}
>
{t('talk-plugin-ignore-user.stop_ignoring')}
</button>
</li>
))}
</ul>
</div>
)}
</section>
);
IgnoreUserSection.propTypes = {
ignoredUsers: PropTypes.array.isRequired,
stopIgnoringUser: PropTypes.func.isRequired,
};
export default IgnoreUserSection;
@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import IgnoredUserSection from '../components/IgnoredUserSection';
import { compose, gql } from 'react-apollo';
import {
withFragments,
excludeIf,
withStopIgnoringUser,
} from 'plugin-api/beta/client/hocs';
@@ -18,6 +18,11 @@ class IgnoredUserSectionContainer extends React.Component {
}
}
IgnoredUserSectionContainer.propTypes = {
stopIgnoringUser: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
const withIgnoredUserSectionFragments = withFragments({
root: gql`
fragment TalkIgnoreUser_IgnoredUserSection_root on RootQuery {
@@ -32,10 +37,6 @@ const withIgnoredUserSectionFragments = withFragments({
`,
});
const enhance = compose(
withIgnoredUserSectionFragments,
withStopIgnoringUser,
excludeIf(({ root: { me } }) => me.ignoredUsers.length === 0)
);
const enhance = compose(withIgnoredUserSectionFragments, withStopIgnoringUser);
export default enhance(IgnoredUserSectionContainer);
@@ -1,3 +1,15 @@
ar:
talk-plugin-ignore-user:
section_title: المستخدمون الذين تم تجاهلهم
section_info: لأنك تجاهلت المعلقين التاليين، يتم إخفاء تعليقاتهم.
stop_ignoring: إيقاف التجاهل
ignore_user: تجاهل المستخدم
cancel: إلغاء
confirmation: |
عند تجاهل المستخدم، سيتم إخفاء جميع التعليقات التي كتبها على الموقع منك. يمكنك التراجع عن هذا لاحقا من ملفي الشخصي.
notify_success: |
أنت الآن تتجاهل {0}. يمكنك التراجع عن هذا الإجراء من ملفي الشخصي.
confirmation_title: تجاهل {0}؟
da:
talk-plugin-ignore-user:
section_title: Ignored users
+12
View File
@@ -0,0 +1,12 @@
---
title: talk-plugin-like
permalink: /plugin/talk-plugin-like/
layout: plugin
plugin:
name: talk-plugin-like
provides:
- Server
- Client
---
Enables a `like` reaction button.
@@ -1,3 +1,7 @@
ar:
talk-plugin-like:
like: أعجبني
liked: إعجاب
da:
talk-plugin-like:
like: Like
+12
View File
@@ -0,0 +1,12 @@
---
title: talk-plugin-love
permalink: /plugin/talk-plugin-love/
layout: plugin
plugin:
name: talk-plugin-love
provides:
- Server
- Client
---
Enables a `love` reaction button.
@@ -1,3 +1,7 @@
ar:
talk-plugin-love:
love: Love
loved: Loved
da:
talk-plugin-love:
love: Love
@@ -0,0 +1,15 @@
---
title: talk-plugin-member-since
permalink: /plugin/talk-plugin-member-since/
layout: plugin
plugin:
name: talk-plugin-member-since
default: true
depends:
- name: talk-plugin-author-menu
provides:
- Client
---
It will show the date that the member/user joined when you hover over the
username as retrieved from the `createdAt` time on the user.
@@ -24,7 +24,7 @@ export default {
createComment: {
comment: {
user: {
created_at: new Date(),
created_at: new Date().toISOString(),
__typename: 'User',
},
__typename: 'Comment',
@@ -1,3 +1,6 @@
ar:
talk-plugin-member-since:
member_since: "عضو منذ"
da:
talk-plugin-member-since:
member_since: "Member Since"
@@ -0,0 +1,15 @@
---
title: talk-plugin-moderator-actions
permalink: /plugin/talk-plugin-moderator-actions/
layout: plugin
plugin:
name: talk-plugin-moderator-actions
default: true
provides:
- Client
---
Enables in-stream moderation so that Moderators can reject, approve comments,
as well as ban users, directly from the comment stream. When
[talk-plugin-featured-comments](/talk/plugin/talk-plugin-featured-comments) is
enabled, it will also give the User's an option to feature from the stream.
@@ -16,12 +16,16 @@ export default class ModerationActions extends React.Component {
comment,
root,
asset,
data,
menuVisible,
toogleMenu,
hideMenu,
} = this.props;
const slotPassthrough = {
comment,
asset,
};
return (
<ClickOutside onClickOutside={hideMenu}>
<div
@@ -41,12 +45,11 @@ export default class ModerationActions extends React.Component {
)}
</span>
{menuVisible && (
<Menu className="talk-plugin-modetarion-actions-menu">
<Menu className="talk-plugin-moderation-actions-menu">
<Slot
className="talk-plugin-modetarion-actions-slot"
className="talk-plugin-moderation-actions-slot"
fill="moderationActions"
queryData={{ comment, asset }}
data={data}
passthrough={slotPassthrough}
/>
<ApproveCommentAction comment={comment} hideMenu={hideMenu} />
<RejectCommentAction comment={comment} hideMenu={hideMenu} />
@@ -67,7 +70,6 @@ ModerationActions.propTypes = {
comment: PropTypes.object,
root: PropTypes.object,
asset: PropTypes.object,
data: PropTypes.object,
menuVisible: PropTypes.bool,
toogleMenu: PropTypes.func,
hideMenu: PropTypes.func,
@@ -42,7 +42,6 @@ class ModerationActionsContainer extends React.Component {
render() {
return (
<ModerationActions
data={this.props.data}
root={this.props.root}
asset={this.props.asset}
comment={this.props.comment}
@@ -1,3 +1,15 @@
ar:
talk-plugin-moderation-actions:
reject_comment: "رفض"
approve_comment: "موافقة"
approved_comment: "موافق عليه"
moderation_actions: "إجراءات الإشراف"
ban_user: "حجب المستخدم"
ban_user_dialog_sub: "هل أنت متأكد أنك تريد حجب هذا المستخدم؟"
ban_user_dialog_copy: "ملاحظة: سيؤدي حجب هذا المستخدم أيضا إلى وضع هذا التعليق في قائمة الرفض."
ban_user_dialog_cancel: "إلغاء"
ban_user_dialog_yes: "نعم، احجب المستخدم"
ban_user_dialog_headline: "احجب المستخدم؟"
da:
talk-plugin-moderation-actions:
reject_comment: "Reject"
@@ -0,0 +1,17 @@
---
title: talk-plugin-notifications-category-featured
permalink: /plugin/talk-plugin-notifications-category-featured/
layout: plugin
plugin:
name: talk-plugin-notifications-category-featured
depends:
- name: talk-plugin-notifications
- name: talk-plugin-featured-comments
provides:
- Server
- Client
---
When a comment is featured (via the
[talk-plugin-featured-comments](/talk/plugin/talk-plugin-featured-comments)
plugin), the user will receive a notification email.
@@ -1,74 +0,0 @@
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}
disabled={this.props.disabled}
>
{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,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryFeatured_Toggle_root on RootQuery {
me {
notificationSettings {
onFeatured
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -1,33 +0,0 @@
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 });
},
}),
},
};
@@ -1,11 +1,14 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
import { t } from 'plugin-api/beta/client/services';
import { createSettingsToggle } from 'talk-plugin-notifications/client/api/factories';
const SettingsToggle = createSettingsToggle('onFeatured', () =>
t('talk-plugin-notifications-category-featured.toggle_description')
);
export default {
slots: {
notificationSettings: [Toggle],
notificationSettings: [SettingsToggle],
},
translations,
...graphql,
};
@@ -84,6 +84,7 @@ const handler = {
category: 'featured',
event: 'commentFeatured',
hydrate,
digestOrder: 10,
};
module.exports = {
@@ -2,5 +2,5 @@ en:
talk-plugin-notifications:
categories:
featured:
subject: "One of your comments was featured on [{0}]"
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,15 @@
---
title: talk-plugin-notifications-category-reply
permalink: /plugin/talk-plugin-notifications-category-reply/
layout: plugin
plugin:
name: talk-plugin-notifications-category-reply
depends:
- name: talk-plugin-notifications
provides:
- Server
- Client
---
Replies made to each user will trigger an email to be sent with the notification
details if enabled.
@@ -1,74 +0,0 @@
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}
disabled={this.props.disabled}
>
{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,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryReply_Toggle_root on RootQuery {
me {
notificationSettings {
onReply
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -1,33 +0,0 @@
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 });
},
}),
},
};
@@ -1,11 +1,14 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
import { t } from 'plugin-api/beta/client/services';
import { createSettingsToggle } from 'talk-plugin-notifications/client/api/factories';
const SettingsToggle = createSettingsToggle('onReply', () =>
t('talk-plugin-notifications-category-reply.toggle_description')
);
export default {
slots: {
notificationSettings: [Toggle],
notificationSettings: [SettingsToggle],
},
translations,
...graphql,
};
@@ -2,6 +2,12 @@ const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
// Check to see if this reply is visible.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
@@ -90,7 +96,32 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, permalink];
};
const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
// commentAcceptedHandleAdapter will check to see if we need to send a
// notification for this comment if the comment has been recently approved but
// has not been approved before.
const commentAcceptedHandleAdapter = (ctx, comment) => {
// Don't send a notification for a non-visible comment.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Don't send a notification if the comment was previously visible.
if (
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
comment.status_history
.slice(0, comment.status_history.length - 1)
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
) {
ctx.log.info(
'comment was previously already visible, not sending another notification'
);
return;
}
// Delegate to the handle function.
return handle(ctx, comment);
};
module.exports = {
typeDefs: `
@@ -109,5 +140,20 @@ module.exports = {
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
notifications: [
{
handle,
category: 'reply',
event: 'commentAdded',
hydrate,
digestOrder: 30,
},
{
handle: commentAcceptedHandleAdapter,
category: 'reply',
event: 'commentAccepted',
hydrate,
digestOrder: 30,
},
],
};
@@ -0,0 +1,15 @@
---
title: talk-plugin-notifications-category-staff
permalink: /plugin/talk-plugin-notifications-category-staff/
layout: plugin
plugin:
name: talk-plugin-notifications-category-staff
depends:
- name: talk-plugin-notifications
provides:
- Server
- Client
---
Replies made to each user by a staff member will trigger an email to be sent
with the notification details if enabled.
@@ -1,74 +0,0 @@
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}
disabled={this.props.disabled}
>
{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,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryStaffReply_User_Fragment on RootQuery {
me {
notificationSettings {
onStaffReply
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -1,33 +0,0 @@
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 });
},
}),
},
};
@@ -1,11 +1,14 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
import { t } from 'plugin-api/beta/client/services';
import { createSettingsToggle } from 'talk-plugin-notifications/client/api/factories';
const SettingsToggle = createSettingsToggle('onStaffReply', () =>
t('talk-plugin-notifications-category-staff.toggle_description')
);
export default {
slots: {
notificationSettings: [Toggle],
notificationSettings: [SettingsToggle],
},
translations,
...graphql,
};
@@ -2,6 +2,11 @@ const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
@@ -111,12 +116,31 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, organizationName, permalink];
};
const handler = {
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
// commentAcceptedHandleAdapter will check to see if we need to send a
// notification for this comment if the comment has been recently approved but
// has not been approved before.
const commentAcceptedHandleAdapter = (ctx, comment) => {
// Don't send a notification for a non-visible comment.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
return;
}
// Don't send a notification if the comment was previously visible.
if (
// TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check.
comment.status_history
.slice(0, comment.status_history.length - 1)
.some(({ type }) => ['ACCEPTED', 'NONE'].includes(type))
) {
ctx.log.info(
'comment was previously already visible, not sending another notification'
);
return;
}
// Delegate to the handle function.
return handle(ctx, comment);
};
module.exports = {
@@ -136,5 +160,22 @@ module.exports = {
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
notifications: [
{
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
},
{
handle: commentAcceptedHandleAdapter,
category: 'staff',
event: 'commentAccepted',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
},
],
};
@@ -0,0 +1,16 @@
---
title: talk-plugin-notifications-digest-daily
permalink: /plugin/talk-plugin-notifications-digest-daily/
layout: plugin
plugin:
name: talk-plugin-notifications-digest-daily
depends:
- name: talk-plugin-notifications
provides:
- Server
- Client
---
Enables a digesting option for users to digest their notifications on an `DAILY`
basis, where the notification batching occurs every day at midnight in the
`America/New_York` timezone.
@@ -0,0 +1,7 @@
import translations from './translations.yml';
export default {
// The only thing necessary on the client side is to provide
// a translation for the new enum.
translations,
};
@@ -0,0 +1,4 @@
en:
talk-plugin-notifications:
digest_enum:
DAILY: In a daily digest
@@ -0,0 +1,11 @@
module.exports = {
typeDefs: `
enum DIGEST_FREQUENCY {
# DAILY will queue up the notifications and send them daily.
DAILY
}
`,
notificationDigests: {
DAILY: { cronTime: '0 0 * * *', timeZone: 'America/New_York' },
},
};
@@ -0,0 +1,16 @@
---
title: talk-plugin-notifications-digest-hourly
permalink: /plugin/talk-plugin-notifications-digest-hourly/
layout: plugin
plugin:
name: talk-plugin-notifications-digest-hourly
depends:
- name: talk-plugin-notifications
provides:
- Server
- Client
---
Enables a digesting option for users to digest their notifications on an `HOURLY`
basis, where the notification batching occurs every hour in the
`America/New_York` timezone.
@@ -0,0 +1,7 @@
import translations from './translations.yml';
export default {
// The only thing necessary on the client side is to provide
// a translation for the new enum.
translations,
};
@@ -0,0 +1,4 @@
en:
talk-plugin-notifications:
digest_enum:
HOURLY: In an hourly digest
@@ -0,0 +1,11 @@
module.exports = {
typeDefs: `
enum DIGEST_FREQUENCY {
# HOURLY will queue up the notifications and send them hourly.
HOURLY
}
`,
notificationDigests: {
HOURLY: { cronTime: '0 * * * *', timeZone: 'America/New_York' },
},
};
@@ -0,0 +1,31 @@
---
title: talk-plugin-notifications
permalink: /plugin/talk-plugin-notifications/
layout: plugin
plugin:
name: talk-plugin-notifications
provides:
- Server
- Client
---
Enables the Notification system for sending out enabled email notifications to
users when they interact with Talk. By itself, this plugin will not send
anything. You need to enable one of the `talk-plugin-notifications-category-*` plugins.
Configuration:
- `TALK_DISABLE_REQUIRE_EMAIL_VERIFICATIONS_NOTIFICATIONS` - 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_CLIENT_FORCE_NOTIFICATION_SETTINGS` - When `TRUE`, the settings pane for notifications will show always, even if the user does not have a `local` profile. (Default `FALSE`).
You can enable other notification options by adding more
`talk-plugin-notification-*` plugins!
## Email Subjects
While it seems in your notification category plugin you can set the subject
line by adjusting the translation, Talk's default behavior is to add a prefix
before the subject of each email sent. This is always set to the
[TALK-EMAIL-SUBJECT-PREFIX](/talk/advanced-configuration/#TALK-EMAIL-SUBJECT-PREFIX)
configuration variable. You should change this parameter if you want to affect
how the subject is rendered.
@@ -0,0 +1,20 @@
.title {
display: inline-block;
width: 100%;
cursor: pointer;
user-select: none;
&.disabled {
color: #e5e5e5;
cursor: default;
}
}
.toggle {
display: flex;
align-items: center;
}
.checkBox {
text-align: right;
}
@@ -0,0 +1,41 @@
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';
import cn from 'classnames';
class Toggle extends React.Component {
id = uuid();
render() {
const { checked, onChange, children, disabled } = this.props;
return (
<div className={styles.toggle}>
<label
htmlFor={this.id}
className={cn(styles.title, { [styles.disabled]: disabled })}
>
{children}
</label>
<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,
};
export default Toggle;
@@ -0,0 +1 @@
export { default as Toggle } from './Toggle';
@@ -0,0 +1,22 @@
import React from 'react';
import Toggle from '../components/Toggle';
import withSettingsToggle from '../hocs/withSettingsToggle';
/**
* createSettingsToggle will add a boolean setting with the
* name `settingsName` to notification settings and return
* a full Toggle Component.
*
* You must provide a `label` either as a string or as a callback.
* E.g. to provide translations you could do:
*
* `const SettingsToggle = createSettingsToggle('onReply', () => t('translate'));`
*/
const createSettingsToggle = (settingsName, label) => {
const SettingsToggle = props => (
<Toggle {...props}>{typeof label === 'function' ? label() : label}</Toggle>
);
return withSettingsToggle(settingsName)(SettingsToggle);
};
export default createSettingsToggle;
@@ -0,0 +1 @@
export { default as createSettingsToggle } from './createSettingsToggle';
@@ -0,0 +1 @@
export { default as withSettingsToggle } from './withSettingsToggle';
@@ -0,0 +1,121 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import {
withFragments,
withGraphQLExtension,
} from 'plugin-api/beta/client/hocs';
const createHOC = settingsName => WrappedComponent => {
class WithSettingsToggle extends React.Component {
constructor(props) {
super(props);
props.setTurnOffInputFragment({ [settingsName]: false });
if (this.isChecked()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.isChecked(this.props);
const nextSetting = this.isChecked(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
isChecked = (props = this.props) =>
props.root.me.notificationSettings[settingsName];
toggle = () => {
this.props.updateNotificationSettings({
[settingsName]: !this.isChecked(),
});
};
render() {
return (
<WrappedComponent
checked={this.isChecked()}
onChange={this.toggle}
disabled={this.props.disabled}
/>
);
}
}
WithSettingsToggle.propTypes = {
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
return WithSettingsToggle;
};
/**
* withSettingsToggle will add a boolean setting with the
* name `settingsName` to notification settings and provide
* the folliwng props:
*
* `checked: boolean` Whether setting is on or off
* `onChange: () => void` Calling this will toggle the setting
* `disabled: boolean` Whether setting is disabled
*/
const withSettingsToggle = settingsName => {
const extension = {
mutations: {
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
update: proxy => {
if (input[settingsName] === undefined) {
return;
}
const fragment = gql`
fragment TalkNotifications_Toggle_${settingsName}_Fragment on User {
notificationSettings {
${settingsName}
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
[settingsName]: input[settingsName],
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
return compose(
withFragments({
root: gql`
fragment TalkNotifications_Toggle_${settingsName}_root on RootQuery {
me {
notificationSettings {
${settingsName}
}
}
}
`,
}),
withGraphQLExtension(extension),
createHOC(settingsName)
);
};
export default withSettingsToggle;
@@ -1,6 +1,6 @@
.root {
margin-bottom: 20px;
width: 380px;
max-width: 350px;
}
.innerSettings {
@@ -30,3 +30,16 @@
.disabled {
color: #e5e5e5;
}
.digest {
display: flex;
align-items: center;
}
.titleDigest {
width: 100%;
}
.digestDropDown {
flex-shrink: 0;
}
@@ -4,16 +4,20 @@ 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';
import {
BareButton,
Dropdown,
Option,
} from 'plugin-api/beta/client/components/ui';
import EmailVerificationBanner from '../containers/EmailVerificationBanner';
import cn from 'classnames';
class Settings extends React.Component {
childFactory = el => {
const pluginName = el.type.talkPluginName;
const key = el.key;
const props = {
indicateOn: () => this.props.indicateOn(pluginName),
indicateOff: () => this.props.indicateOff(pluginName),
indicateOn: () => this.props.indicateOn(key),
indicateOff: () => this.props.indicateOff(key),
};
return React.cloneElement(el, props);
};
@@ -24,16 +28,32 @@ class Settings extends React.Component {
setTurnOffInputFragment,
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
needEmailVerification,
email,
digestFrequencyValues,
digestFrequency,
disableDigest,
disableTurnoffButton,
onChangeDigestFrequency,
} = this.props;
const slotPassthrough = {
root,
setTurnOffInputFragment,
updateNotificationSettings,
disabled: needEmailVerification,
};
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<IfSlotIsNotEmpty
slot="notificationSettings"
passthrough={slotPassthrough}
>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
{needEmailVerification && <EmailVerificationBanner email={email} />}
<div className={styles.bannerContainer}>
{needEmailVerification && <EmailVerificationBanner email={email} />}
</div>
<h4
className={cn(styles.subtitle, {
[styles.disabled]: needEmailVerification,
@@ -45,20 +65,42 @@ class Settings extends React.Component {
<Slot
className={styles.notifcationSettingsSlot}
fill="notificationSettings"
queryData={{ root }}
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
disabled={needEmailVerification}
passthrough={slotPassthrough}
/>
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={turnOffButtonDisabled}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
{digestFrequencyValues.length > 1 && (
<div className={styles.digest}>
<h4
className={cn(styles.titleDigest, {
[styles.disabled]: disableDigest,
})}
>
{t('talk-plugin-notifications.digest_option')}
</h4>
<Dropdown
className={styles.digestDropDown}
value={digestFrequency}
onChange={onChangeDigestFrequency}
disabled={disableDigest}
>
{digestFrequencyValues.map(v => (
<Option
value={v}
key={v}
label={t(`talk-plugin-notifications.digest_enum.${v}`)}
/>
))}
</Dropdown>
</div>
)}
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={disableTurnoffButton}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
</IfSlotIsNotEmpty>
);
@@ -72,9 +114,13 @@ Settings.propTypes = {
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
disableTurnoffButton: PropTypes.bool.isRequired,
disableDigest: PropTypes.bool.isRequired,
needEmailVerification: PropTypes.bool.isRequired,
email: PropTypes.string,
digestFrequencyValues: PropTypes.array.isRequired,
digestFrequency: PropTypes.string.isRequired,
onChangeDigestFrequency: PropTypes.func.isRequired,
};
export default Settings;
@@ -2,9 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import {
withFragments,
excludeIf,
withEnumValues,
} from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
import { connect } from 'plugin-api/beta/client/hocs';
import { staticConfigSelector } from 'plugin-api/beta/client/selectors';
const slots = ['notificationSettings'];
@@ -14,14 +20,14 @@ class SettingsContainer extends React.Component {
turnOffInput: {},
};
indicateOn = plugin =>
indicateOn = key =>
this.setState(state => ({
hasNotifications: state.hasNotifications.concat(plugin),
hasNotifications: state.hasNotifications.concat(key),
}));
indicateOff = plugin =>
indicateOff = key =>
this.setState(state => ({
hasNotifications: state.hasNotifications.filter(i => i !== plugin),
hasNotifications: state.hasNotifications.filter(i => i !== key),
}));
setTurnOffInputFragment = fragment =>
@@ -33,34 +39,46 @@ class SettingsContainer extends React.Component {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
setDigestFrequency = digestFrequency => {
this.props.updateNotificationSettings({ digestFrequency });
};
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
return (
this.props.root.settings.notificationsRequireConfirmation &&
!this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
)
);
}
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}
needEmailVerification={this.getNeedEmailVerification()}
email={this.props.root.me.email}
digestFrequencyValues={this.props.digestFrequencyValues}
digestFrequency={
this.props.root.me.notificationSettings.digestFrequency
}
disableDigest={this.state.hasNotifications.length === 0}
disableTurnoffButton={this.state.hasNotifications.length === 0}
onChangeDigestFrequency={this.setDigestFrequency}
/>
);
}
}
SettingsContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
updateNotificationSettings: PropTypes.func.isRequired,
digestFrequencyValues: PropTypes.array.isRequired,
};
const enhance = compose(
@@ -70,6 +88,9 @@ const enhance = compose(
__typename
${getSlotFragmentSpreads(slots, 'root')}
me {
notificationSettings {
digestFrequency
}
email
profiles {
provider
@@ -78,14 +99,26 @@ const enhance = compose(
}
}
}
settings {
notificationsRequireConfirmation
}
}
`,
}),
// Grab the static configuration from the redux store.
connect(state => ({
static: staticConfigSelector(state),
})),
excludeIf(
props =>
// If the environment variable for TALK_CLIENT_FORCE_NOTIFICATION_SETTINGS
// is `TRUE`, then always show it.
props.static.TALK_CLIENT_FORCE_NOTIFICATION_SETTINGS !== 'TRUE' &&
// Only show the settings pane if we have a local profile otherwise.
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
withUpdateNotificationSettings
withUpdateNotificationSettings,
withEnumValues('DIGEST_FREQUENCY', 'digestFrequencyValues')
);
export default enhance(SettingsContainer);
@@ -11,13 +11,38 @@ export default {
`,
},
mutations: {
UpdateNotificationSettings: () => ({
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
optimisticResponse: {
updateNotificationSettings: {
__typename: 'UpdateNotificationSettingsResponse',
errors: null,
},
},
update: proxy => {
if (input.digestFrequency === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryReply_User_Fragment on User {
notificationSettings {
digestFrequency
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
digestFrequency: input.digestFrequency,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -13,3 +13,6 @@ en:
banner_error:
title: Error
text: There has been an error sending your verification email. Please try again later.
digest_option: Send notifications
digest_enum:
NONE: Immediately
@@ -6,6 +6,7 @@
"license": "Apache-2.0",
"private": false,
"dependencies": {
"cron": "^1.3.0",
"linkifyjs": "^2.1.5"
}
}
@@ -1,10 +1,24 @@
const { get, find, groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const {
UNSUBSCRIBE_SUBJECT,
DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
} = require('./config');
merge,
map,
get,
find,
groupBy,
forEach,
flatten,
property,
} = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
const { CronJob } = require('cron');
const { getOrganizationName } = require('./util');
const {
processNewNotifications,
filterSuperseded,
filterVerified,
sendNotification,
} = require('./messages');
const { renderDigestMessage } = require('./digests');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
@@ -32,97 +46,11 @@ 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 }, 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;
this.registry = [];
this.digests = [];
}
/**
@@ -149,17 +77,169 @@ class NotificationManager {
.map(({ category }) => category)
.join(', ')}] handlers when the '${event}' event is emitted`
);
broker.on(event, this.handle(handlers));
broker.on(event, this.handleUserEvent(handlers));
});
}
/**
* handle will wrap a notification handler and attach it to the notification
* stream system.
* registerDigests will register the digest handlers.
*
* @param {Array<Object>} handlers digest handlers for options related to digesting
*/
registerDigests(...handlers) {
this.digests.push(...handlers);
}
/**
* startDigesting will register all the digests to run and setup the cron
* jobs.
*/
startDigesting() {
this.digests.forEach(({ frequency, config }) => {
new CronJob(
merge(config, {
start: true,
onTick: this.handleDigestEvent(frequency),
})
);
});
}
handleDigestEvent(frequency) {
return async () => {
// Create a system context to send down.
const ctx = this.context.forSystem();
try {
// Pull out some useful tools.
const {
connectors: { models: { User }, services: { I18n: { t } } },
} = ctx;
const organizationName = await getOrganizationName(ctx);
if (!organizationName) {
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
}
const subject = t(
'talk-plugin-notifications.templates.digest.subject',
organizationName
);
// Continue to pull from the Users digest until the queue is empty.
while (true) {
// Pull notifications from a user that have notifications enabled for
// `frequency` and currently have notifications.
const user = await User.findOneAndUpdate(
{
'metadata.notifications.settings.digestFrequency': frequency,
'metadata.notifications.digests': { $exists: true, $ne: [] },
},
{ $set: { 'metadata.notifications.digests': [] } }
);
if (!user) {
// There are no more users that meet the search criteria! We're
// done!
ctx.log.info('no notifications from database');
break;
}
// Begin rendering the user's digest.
const digests = get(user, 'metadata.notifications.digests');
if (!digests) {
// We couldn't get the digest from the user (even after Mongo said
// we would get it?).
ctx.log.info(
{ userID: user.id },
'no notifications from user in database'
);
continue;
}
ctx.log.info(
{ userID: user.id, notifications: digests.length },
'generating notification digest email'
);
const flattenedDigestCategories = this.flattenDigests(ctx, digests);
// Get all the notifications together.
const allMessages = await renderDigestMessage(
ctx,
flattenedDigestCategories
);
// Send the email with the digested body.
await sendNotification(
ctx,
user.id,
subject,
flatten(allMessages),
'notification-digest'
);
}
} catch (err) {
ctx.log.error({ err }, 'could not handle digests');
}
};
}
flattenDigests(ctx, digests) {
// Digests are store in the database like:
//
// [{ notification: { userID, date, context }, category }, ...]
//
// So lets group our notifications by category, creating the
// following:
//
// {[category]: [{notification: { userID, date, context }}, ...], ...}
//
const groupedDigests = groupBy(digests, 'category');
// Lets attach the handler reference onto each of these, so we
// transform it again to the following:
//
// [{ handler, notifications: [{ userID, date, context }]}]
//
return Object.keys(groupedDigests)
.map(category => {
// Get the handler.
const handler = find(this.registry, ['category', category]);
if (!handler) {
ctx.log.info({ category }, 'notification category not found');
return;
}
// Get the notifications.
const notifications = map(get(groupedDigests, category), digests =>
get(digests, 'notification')
);
return { notifications, handler };
})
.filter(digest => digest)
.sort((a, b) => {
const aDigestOrder = get(a, 'handler.digestOrder', 0);
const bDigestOrder = get(b, 'handler.digestOrder', 0);
if (aDigestOrder < bDigestOrder) {
return -1;
}
if (aDigestOrder > bDigestOrder) {
return 1;
}
return 0;
});
}
/**
* handleUserEvent will wrap a notification handler and attach it to the
* notification stream system.
*
* @param {Object} handler a notification handler
*/
handle(handlers) {
handleUserEvent(handlers) {
return async (...args) => {
// Create a system context to send down.
const ctx = this.context.forSystem();
@@ -181,95 +261,9 @@ class NotificationManager {
}
// Send the remaining notifications.
return Promise.all(
notifications.map(
({ handler, notification: { userID, date, context } }) =>
this.send(ctx, userID, date, handler, context)
)
);
return processNewNotifications(ctx, notifications);
};
}
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.error(
'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.info(`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, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
`talk-plugin-notifications.categories.${category}.body`,
...replacements
);
}
}
module.exports = NotificationManager;
@@ -2,6 +2,7 @@ const debug = require('debug')('talk-plugin-notifications');
const path = require('path');
const linkify = require('linkifyjs/html');
const NotificationManager = require('./NotificationManager');
const { map, reduce } = require('lodash');
module.exports = connectors => {
const {
@@ -12,17 +13,15 @@ module.exports = 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'
);
['notification', 'notification-digest'].forEach(name => {
['txt', 'html'].forEach(format => {
Mailer.templates.register(
path.join(__dirname, 'emails', `${name}.${format}.ejs`),
name,
format
);
});
});
// Register the mail helpers. You can register your own helpers by calling
// this function in another plugin.
Mailer.registerHelpers({ linkify });
@@ -67,6 +66,47 @@ module.exports = connectors => {
// Attach all the notification handlers.
manager.register(...notificationHandlers);
// Digest handlers should export the following to the `notificationDigests`
// plugin hook:
//
// {DAILY: { cronTime: '0 0 * * *', timeZone: 'America/New_York' }}
//
// Where `DAILY` is the key referenced in the typeDefs as a new type of
// `DIGEST_FREQUENCY`, and the value of that key is the one provided to the
// constructor for the Cron object:
//
// https://github.com/kelektiv/node-cron
//
// Which is used to trigger the digest operation for those uses setup with
// that type of digesting.
const digestHandlers = Plugins.get('server', 'notificationDigests').reduce(
(handlers, { plugin, notificationDigests }) => {
debug(
`registered the ${plugin.name} plugin for digest notifications ${map(
notificationDigests,
(config, frequency) => frequency
)}`
);
return reduce(
notificationDigests,
(handlers, config, frequency) => {
handlers.push({ config, frequency });
return handlers;
},
handlers
);
},
[]
);
// Attach all the notification digest handlers.
manager.registerDigests(...digestHandlers);
// Attach the broker to the manager so it can listen for the events.
manager.attach(broker);
// Start processing digests.
manager.startDigesting();
};
@@ -0,0 +1,60 @@
const { get, flatten } = require('lodash');
const { getNotificationBody } = require('./util');
const QUEUE_DIGEST_NOTIFICATION_QUERY = `
query CheckDigest($userID: ID!) {
user(id: $userID) {
notificationSettings {
digestFrequency
}
}
}
`;
// checkDigests will return a boolean indicating if the user has digesting
// enabled.
const checkDigests = async (ctx, userID) => {
const { data, errors } = await ctx.graphql(QUEUE_DIGEST_NOTIFICATION_QUERY, {
userID,
});
if (errors) {
ctx.log.error(
{ err: errors },
'could not check the digest status of the user, skipping notifications'
);
return;
}
return get(data, 'user.notificationSettings.digestFrequency') !== 'NONE';
};
// renderDigestMessage will render the notification body value for a digest
// message. It expects that the digestCategories are parsed into a list grouped
// by category with the handler available.
const renderDigestMessage = async (ctx, flattenedDigestCategories) => {
// Render the messages in this format:
//
// [{handler, notifications: [{ context }, ...]}, ...]
//
// To:
//
// [['body', 'body'], ['body']]
//
const notifications = await Promise.all(
flattenedDigestCategories.map(async ({ handler, notifications }) =>
Promise.all(
notifications.map(async ({ context }) =>
getNotificationBody(ctx, handler, context)
)
)
)
);
// Flatten the array of categories:
//
// [[..., ...], [..., ...], ...] -> [..., ..., ...]
//
return flatten(notifications);
};
module.exports = { renderDigestMessage, checkDigests };
@@ -0,0 +1,6 @@
<% body.forEach((message) => { %>
<p><%= linkify(message, {nl2br: true}) %></p>
<% }) %>
<br/>
<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,9 @@
<% body.forEach((message) => { %>
<%= message %>
<% }) %>
<%= t('talk-plugin-notifications.templates.footer', organizationName) %>
<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>
<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>
@@ -0,0 +1,37 @@
const { isValidJSValue } = require('graphql');
module.exports = {
NotificationSettings: {
digestFrequency: {
// This hook will swap out the digest frequency with `NONE` in the event
// that an org had a digest plugin enabled and later switched it off. This
// will force users that have previously had a digested option enabled to
// get notifications immediately until they update their frequency
// options.
post: async (settings, args, ctx, { schema }, frequency) => {
try {
// Validate that the type is correct.
const errors = isValidJSValue(
frequency,
schema.getType('DIGEST_FREQUENCY')
);
if (errors && errors.length > 0) {
ctx.log.info(
{ frequency },
'invalid frequency, swapping with `NONE`, plugin likely disabled'
);
// Fallback to 'NONE' if the digest value has an error.
frequency = 'NONE';
}
} catch (err) {
ctx.log.error({ err }, 'could not check if the type was valid');
// Fallback to 'NONE' if we couldn't validate the value.
frequency = 'NONE';
}
return frequency;
},
},
},
};

Some files were not shown because too many files have changed in this diff Show More