mirror of
https://github.com/wassname/talk.git
synced 2026-07-23 13:10:20 +08:00
Merge master
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user