mirror of
https://github.com/wassname/talk.git
synced 2026-07-03 16:59:15 +08:00
Merge branch 'master' into remember-sort
Conflicts: .eslintignore .gitignore
This commit is contained in:
@@ -22,6 +22,7 @@ plugins/*
|
||||
!plugins/talk-plugin-author-menu
|
||||
!plugins/talk-plugin-member-since
|
||||
!plugins/talk-plugin-ignore-user
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-remember-sort
|
||||
|
||||
node_modules
|
||||
|
||||
+11
-23
@@ -3,7 +3,9 @@
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2017
|
||||
},
|
||||
@@ -12,9 +14,7 @@
|
||||
"json"
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error",
|
||||
2
|
||||
],
|
||||
"indent": ["error", 2],
|
||||
"no-console": "off",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "single"],
|
||||
@@ -29,7 +29,7 @@
|
||||
"no-global-assign": "error",
|
||||
"no-implied-eval": "error",
|
||||
"lines-around-comment": ["warn", {"beforeLineComment": true}],
|
||||
"spaced-comment": ["warn", "always", { "line": { "exceptions": ["-", "="] } }],
|
||||
"spaced-comment": ["warn", "always", {"line": {"exceptions": ["-", "="]}}],
|
||||
"no-script-url": "error",
|
||||
"no-throw-literal": "error",
|
||||
"yoda": "warn",
|
||||
@@ -41,32 +41,20 @@
|
||||
"object-curly-spacing": "warn",
|
||||
"space-infix-ops": ["error"],
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-unary-ops": ["error", {
|
||||
"words": true,
|
||||
"nonwords": false
|
||||
}],
|
||||
"space-unary-ops": ["error", {"words": true, "nonwords": false}],
|
||||
"no-const-assign": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"prefer-template": "warn",
|
||||
"comma-spacing": ["error", {
|
||||
"after": true
|
||||
}],
|
||||
"comma-spacing": ["error", {"after": true}],
|
||||
"no-var": "error",
|
||||
"no-lonely-if": "error",
|
||||
"curly": "error",
|
||||
"no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_|next",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
"no-multiple-empty-lines": ["error", {
|
||||
"max": 1
|
||||
}],
|
||||
"newline-per-chained-call": ["error", {
|
||||
"ignoreChainWithDepth": 2
|
||||
}],
|
||||
"no-unused-vars": ["error", {"argsIgnorePattern": "^_|next", "varsIgnorePattern": "^_"}],
|
||||
"no-multiple-empty-lines": ["error", {"max": 1}],
|
||||
"newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
|
||||
"promise/no-return-wrap": "error",
|
||||
"promise/param-names": "error",
|
||||
"promise/catch-or-return": "error",
|
||||
"promise/catch-or-return": "warn",
|
||||
"promise/no-native": "off",
|
||||
"promise/no-nesting": "warn",
|
||||
"promise/no-promise-in-callback": "warn",
|
||||
|
||||
@@ -29,6 +29,7 @@ plugins/*
|
||||
!plugins/talk-plugin-comment-content
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-featured-comments
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-sort-newest
|
||||
!plugins/talk-plugin-sort-oldest
|
||||
!plugins/talk-plugin-sort-most-replied
|
||||
@@ -38,6 +39,7 @@ plugins/*
|
||||
!plugins/talk-plugin-author-menu
|
||||
!plugins/talk-plugin-member-since
|
||||
!plugins/talk-plugin-ignore-user
|
||||
!plugins/talk-plugin-toxic-comments
|
||||
!plugins/talk-plugin-remember-sort
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
@@ -128,18 +128,18 @@ const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
|
||||
const checkInstallSuccess = (installed) => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
|
||||
const checkInstallFailure = (error) => ({type: actions.CHECK_INSTALL_FAILURE, error});
|
||||
|
||||
export const checkInstall = (next) => (dispatch, _, {rest}) => {
|
||||
export const checkInstall = (next) => async (dispatch, _, {rest}) => {
|
||||
dispatch(checkInstallRequest());
|
||||
rest('/setup')
|
||||
.then(({installed}) => {
|
||||
dispatch(checkInstallSuccess(installed));
|
||||
if (installed) {
|
||||
next();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(checkInstallFailure(errorMessage));
|
||||
});
|
||||
|
||||
try {
|
||||
const {installed} = await rest('/setup');
|
||||
dispatch(checkInstallSuccess(installed));
|
||||
if (installed) {
|
||||
next();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(checkInstallFailure(errorMessage));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,16 +28,26 @@ export default class UserDetail extends React.Component {
|
||||
bulkReject: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
rejectThenReload = (info) => {
|
||||
this.props.rejectComment(info).then(() => {
|
||||
rejectThenReload = async (info) => {
|
||||
try {
|
||||
await this.props.rejectComment(info);
|
||||
this.props.data.refetch();
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
// TODO: handle error.
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
acceptThenReload = (info) => {
|
||||
this.props.acceptComment(info).then(() => {
|
||||
acceptThenReload = async (info) => {
|
||||
try {
|
||||
await this.props.acceptComment(info);
|
||||
this.props.data.refetch();
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
// TODO: handle error.
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
showAll = () => {
|
||||
@@ -133,7 +143,7 @@ export default class UserDetail extends React.Component {
|
||||
<Slot
|
||||
fill="userProfile"
|
||||
data={this.props.data}
|
||||
queryData={root, user}
|
||||
queryData={{root, user}}
|
||||
/>
|
||||
|
||||
<hr/>
|
||||
|
||||
@@ -36,14 +36,19 @@ class UserDetailContainer extends React.Component {
|
||||
isLoadingMore = false;
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
bulkSetCommentStatus = async (status) => {
|
||||
const changes = this.props.selectedCommentIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
try {
|
||||
await Promise.all(changes);
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
// TODO: handle error.
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
bulkReject = () => {
|
||||
|
||||
@@ -85,7 +85,7 @@ class User extends React.Component {
|
||||
<span className={styles.flaggedByLabel}>
|
||||
{t('community.flags')}({ user.actions.length })
|
||||
</span>:
|
||||
{ user.action_summaries.map(
|
||||
{ user.action_summaries.map(
|
||||
(action, i) => {
|
||||
return <span className={styles.flaggedBy} key={i}>
|
||||
{shortReasons[action.reason]} ({action.count})
|
||||
|
||||
@@ -48,11 +48,15 @@ class RejectUsernameDialog extends Component {
|
||||
|
||||
const cancel = this.props.handleClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => {
|
||||
rejectUsername({id: user.user.id, message: this.state.email})
|
||||
.then(() => {
|
||||
this.props.handleClose();
|
||||
});
|
||||
const suspend = async () => {
|
||||
try {
|
||||
await rejectUsername({id: user.user.id, message: this.state.email});
|
||||
this.props.handleClose();
|
||||
} catch (err) {
|
||||
|
||||
// TODO: handle error.
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const suspendModalActions = [
|
||||
|
||||
@@ -50,17 +50,22 @@ export default class Stories extends Component {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
onStatusClick = (closeStream, id, statusMenuOpen) => () => {
|
||||
onStatusClick = (closeStream, id, statusMenuOpen) => async () => {
|
||||
if (statusMenuOpen) {
|
||||
this.setState((prev) => {
|
||||
prev.statusMenus[id] = false;
|
||||
return prev;
|
||||
});
|
||||
this.props.updateAssetState(id, closeStream ? Date.now() : null)
|
||||
.then(() => {
|
||||
const {search, sort, filter, page} = this.state;
|
||||
this.props.fetchAssets(page, limit, search, sort, filter);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.props.updateAssetState(id, closeStream ? Date.now() : null);
|
||||
const {search, sort, filter, page} = this.state;
|
||||
this.props.fetchAssets(page, limit, search, sort, filter);
|
||||
} catch (err) {
|
||||
|
||||
// TODO: handle error.
|
||||
console.error(err);
|
||||
}
|
||||
} else {
|
||||
this.setState((prev) => {
|
||||
prev.statusMenus[id] = true;
|
||||
|
||||
@@ -15,8 +15,8 @@ class StreamTabPanel extends React.Component {
|
||||
{loading
|
||||
? <div className={styles.spinnerContainer}><Spinner /></div>
|
||||
: <TabContent activeTab={activeTab} sub={sub}>
|
||||
{tabPanes}
|
||||
</TabContent>
|
||||
{tabPanes}
|
||||
</TabContent>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
},
|
||||
mutations: {
|
||||
PostComment: ({
|
||||
variables: {comment: {asset_id, body, parent_id, tags = []}},
|
||||
variables: {input: {asset_id, body, parent_id, tags = []}},
|
||||
state: {auth},
|
||||
}) => ({
|
||||
optimisticResponse: {
|
||||
|
||||
@@ -6,6 +6,7 @@ export default {
|
||||
'SetCommentStatusResponse',
|
||||
'SuspendUserResponse',
|
||||
'RejectUsernameResponse',
|
||||
'CreateCommentResponse',
|
||||
'SetUserStatusResponse',
|
||||
'CreateFlagResponse',
|
||||
'EditCommentResponse',
|
||||
|
||||
@@ -192,17 +192,17 @@ export const withSetUserStatus = withMutation(
|
||||
|
||||
export const withPostComment = withMutation(
|
||||
gql`
|
||||
mutation PostComment($comment: CreateCommentInput!) {
|
||||
createComment(comment: $comment) {
|
||||
mutation PostComment($input: CreateCommentInput!) {
|
||||
createComment(input: $input) {
|
||||
...CreateCommentResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
postComment: (comment) => {
|
||||
postComment: (input) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
comment
|
||||
input
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function mergeDocuments(documents) {
|
||||
export function getResponseErrors(mutationResult) {
|
||||
const result = [];
|
||||
Object.keys(mutationResult.data).forEach((response) => {
|
||||
const errors = mutationResult.data[response].errors;
|
||||
const errors = mutationResult.data[response] && mutationResult.data[response].errors;
|
||||
if (errors && errors.length) {
|
||||
result.push(...errors);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class CommentBox extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
let comment = {
|
||||
let input = {
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
body: this.state.body,
|
||||
@@ -62,10 +62,10 @@ class CommentBox extends React.Component {
|
||||
};
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach((hook) => hook());
|
||||
this.state.hooks.preSubmit.forEach((hook) => hook(input));
|
||||
this.setState({loadingState: 'loading'});
|
||||
|
||||
postComment(comment, 'comments')
|
||||
postComment(input, 'comments')
|
||||
.then(({data}) => {
|
||||
this.setState({loadingState: 'success', body: ''});
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const {forEachField} = require('./utils');
|
||||
const {maskErrors} = require('graphql-errors');
|
||||
const errors = require('../errors');
|
||||
const {Error: {ValidationError}} = require('mongoose');
|
||||
|
||||
// If an APIError happens in a mutation, then respond with `{errors: Array}`
|
||||
// according to the schema.
|
||||
const decorateWithMutationErrorHandler = (field) => {
|
||||
const fieldResolver = field.resolve;
|
||||
field.resolve = async (obj, args, ctx, info) => {
|
||||
try {
|
||||
return await fieldResolver(obj, args, ctx, info);
|
||||
}
|
||||
catch(err) {
|
||||
if (err instanceof errors.APIError) {
|
||||
return {
|
||||
errors: [err]
|
||||
};
|
||||
} else if (err instanceof ValidationError) {
|
||||
|
||||
// TODO: wrap this with one of our internal errors.
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Masks errors during production and handle mutation errors inside the schema.
|
||||
* @param {GraphQLSchema} schema the schema to decorate
|
||||
* @return {void}
|
||||
*/
|
||||
const decorateWithErrorHandler = (schema) => {
|
||||
forEachField(schema, (field, typeName) => {
|
||||
|
||||
// Handle mutation errors.
|
||||
if (typeName === 'RootMutation') {
|
||||
decorateWithMutationErrorHandler(field);
|
||||
}
|
||||
|
||||
// If we are in production mode, don't show server errors to the front end.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
// Mask errors that are thrown if we are in a production environment.
|
||||
maskErrors(field);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
decorateWithErrorHandler,
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
const errors = require('../../errors');
|
||||
const {Error: {ValidationError}} = require('mongoose');
|
||||
|
||||
/**
|
||||
* Wraps up a promise or value to return an object with the resolution of the promise
|
||||
* keyed at `key` or an error caught at `errors`.
|
||||
*/
|
||||
|
||||
const wrapResponse = (key) => async (promise) => {
|
||||
try {
|
||||
let value = await promise;
|
||||
|
||||
let res = {};
|
||||
if (key) {
|
||||
res[key] = value;
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err instanceof errors.APIError) {
|
||||
return {
|
||||
errors: [err]
|
||||
};
|
||||
} else if (err instanceof ValidationError) {
|
||||
|
||||
// TODO: wrap this with one of our internal errors.
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = wrapResponse;
|
||||
+3
-31
@@ -1,7 +1,4 @@
|
||||
const {
|
||||
GraphQLObjectType,
|
||||
GraphQLInterfaceType
|
||||
} = require('graphql');
|
||||
const {forEachField} = require('./utils');
|
||||
const debug = require('debug')('talk:graph:schema');
|
||||
const Joi = require('joi');
|
||||
|
||||
@@ -26,33 +23,6 @@ const defaultResolveFn = (source, args, context, {fieldName}) => {
|
||||
}
|
||||
};
|
||||
|
||||
// This function is pretty much copied verbatim from the graphql-tools repo:
|
||||
// https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
|
||||
// With the small alteration that we look for the `resolveType` function on the
|
||||
// schema so we can wrap post hooks around it to provide additional resolve
|
||||
// points.
|
||||
const forEachField = (schema, fn) => {
|
||||
const typeMap = schema.getTypeMap();
|
||||
Object.keys(typeMap).forEach((typeName) => {
|
||||
const type = typeMap[typeName];
|
||||
|
||||
if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
|
||||
|
||||
// Here we capture the change to extract the resolve type. We pass this
|
||||
// with the `isResolveType = true` to introduce the specific beheviour.
|
||||
if ('resolveType' in type) {
|
||||
fn(type, typeName, '__resolveType', true);
|
||||
}
|
||||
|
||||
const fields = type.getFields();
|
||||
Object.keys(fields).forEach((fieldName) => {
|
||||
const field = fields[fieldName];
|
||||
fn(field, typeName, fieldName);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorates the field with the post resolvers (if available) and attaches a
|
||||
* default type in the form of `Default${typeName}`.
|
||||
@@ -239,6 +209,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
|
||||
return result;
|
||||
}, result);
|
||||
};
|
||||
}, {
|
||||
includeResolveType: true,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
||||
+19
-12
@@ -3,6 +3,10 @@ const DataLoader = require('dataloader');
|
||||
const util = require('./util');
|
||||
const union = require('lodash/union');
|
||||
|
||||
const {
|
||||
SEARCH_OTHER_USERS,
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const UsersService = require('../../services/users');
|
||||
const UserModel = require('../../models/user');
|
||||
|
||||
@@ -28,12 +32,23 @@ const genUserByIDs = async (context, ids) => {
|
||||
* @param {Object} query query terms to apply to the users query
|
||||
*/
|
||||
const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, statuses, action_type, sortOrder}) => {
|
||||
|
||||
let query = UserModel.find();
|
||||
|
||||
if (action_type) {
|
||||
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
|
||||
ids = ids ? union(ids, userIds) : userIds;
|
||||
if (action_type || statuses) {
|
||||
if (!user || !user.can(SEARCH_OTHER_USERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statuses) {
|
||||
query = query.where({
|
||||
status: {
|
||||
$in: statuses
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
|
||||
ids = ids ? union(ids, userIds) : userIds;
|
||||
}
|
||||
}
|
||||
|
||||
if (ids) {
|
||||
@@ -44,14 +59,6 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor,
|
||||
});
|
||||
}
|
||||
|
||||
if (statuses) {
|
||||
query = query.where({
|
||||
status: {
|
||||
$in: statuses
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sortOrder === 'DESC') {
|
||||
query = query.where({
|
||||
|
||||
@@ -154,7 +154,7 @@ const adjustKarma = (Comments, id, status) => async () => {
|
||||
* @param {String} [status='NONE'] the status of the new comment
|
||||
* @return {Promise} resolves to the created comment
|
||||
*/
|
||||
const createComment = async (context, {tags = [], body, asset_id, parent_id = null}, status = 'NONE') => {
|
||||
const createComment = async (context, {tags = [], body, asset_id, parent_id = null, metadata = {}}, status = 'NONE') => {
|
||||
const {user, loaders: {Comments}, pubsub} = context;
|
||||
|
||||
// Resolve the tags for the comment.
|
||||
@@ -166,7 +166,8 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
|
||||
parent_id,
|
||||
status,
|
||||
tags,
|
||||
author_id: user.id
|
||||
author_id: user.id,
|
||||
metadata,
|
||||
});
|
||||
|
||||
// If the loaders are present, clear the caches for these values because we
|
||||
@@ -214,7 +215,7 @@ const filterNewComment = (context, {body, asset_id}) => {
|
||||
* @param {Object} [wordlist={}] the results of the wordlist scan
|
||||
* @return {Promise} resolves to the comment's status
|
||||
*/
|
||||
const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => {
|
||||
const resolveNewCommentStatus = async (context, {asset_id, body, status}, wordlist = {}, settings = {}) => {
|
||||
let {user} = context;
|
||||
|
||||
// Check to see if the body is too short, if it is, then complain about it!
|
||||
@@ -269,7 +270,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
|
||||
}
|
||||
}
|
||||
|
||||
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
|
||||
return (moderation === 'PRE' || status === 'PREMOD') ? 'PREMOD' : 'NONE';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
const wrapResponse = require('../helpers/response');
|
||||
|
||||
const RootMutation = {
|
||||
createComment(_, {comment}, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.create(comment));
|
||||
createComment: async (_, {input}, {mutators: {Comment}}) => ({
|
||||
comment: await Comment.create(input),
|
||||
}),
|
||||
editComment: async (_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) => ({
|
||||
comment: await Comment.edit({id, asset_id, edit: {body}}),
|
||||
}),
|
||||
createFlag: async (_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
|
||||
flag: Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}),
|
||||
}),
|
||||
createDontAgree: async (_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
|
||||
dontagree: await Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}),
|
||||
}),
|
||||
deleteAction: async (_, {id}, {mutators: {Action}}) => {
|
||||
await Action.delete({id});
|
||||
},
|
||||
editComment(_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.edit({id, asset_id, edit: {body}}));
|
||||
setUserStatus: async (_, {id, status}, {mutators: {User}}) => {
|
||||
await User.setUserStatus({id, status});
|
||||
},
|
||||
createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
|
||||
return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
|
||||
suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => {
|
||||
await User.suspendUser({id, message, until});
|
||||
},
|
||||
createDontAgree(_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
|
||||
return wrapResponse('dontagree')(Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}));
|
||||
rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => {
|
||||
await User.rejectUsername({id, message});
|
||||
},
|
||||
deleteAction(_, {id}, {mutators: {Action}}) {
|
||||
return wrapResponse(null)(Action.delete({id}));
|
||||
ignoreUser: async (_, {id}, {mutators: {User}}) => {
|
||||
await User.ignoreUser({id});
|
||||
},
|
||||
setUserStatus(_, {id, status}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.setUserStatus({id, status}));
|
||||
stopIgnoringUser: async (_, {id}, {mutators: {User}}) => {
|
||||
await User.stopIgnoringUser({id});
|
||||
},
|
||||
suspendUser(_, {input: {id, message, until}}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.suspendUser({id, message, until}));
|
||||
updateAssetSettings: async (_, {id, input: settings}, {mutators: {Asset}}) => {
|
||||
await Asset.updateSettings(id, settings);
|
||||
},
|
||||
rejectUsername(_, {input: {id, message}}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.rejectUsername({id, message}));
|
||||
updateAssetStatus: async (_, {id, input: status}, {mutators: {Asset}}) => {
|
||||
await Asset.updateStatus(id, status);
|
||||
},
|
||||
updateAssetSettings(_, {id, input: settings}, {mutators: {Asset}}) {
|
||||
return wrapResponse(null)(Asset.updateSettings(id, settings));
|
||||
},
|
||||
updateAssetStatus(_, {id, input: status}, {mutators: {Asset}}) {
|
||||
return wrapResponse(null)(Asset.updateStatus(id, status));
|
||||
},
|
||||
ignoreUser(_, {id}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.ignoreUser({id}));
|
||||
},
|
||||
stopIgnoringUser(_, {id}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.stopIgnoringUser({id}));
|
||||
},
|
||||
async setCommentStatus(_, {id, status}, {mutators: {Comment}, pubsub}) {
|
||||
setCommentStatus: async (_, {id, status}, {mutators: {Comment}, pubsub}) => {
|
||||
const comment = await Comment.setStatus({id, status});
|
||||
if (status === 'ACCEPTED') {
|
||||
|
||||
@@ -48,25 +46,24 @@ const RootMutation = {
|
||||
// Publish the comment status change via the subscription.
|
||||
pubsub.publish('commentRejected', comment);
|
||||
}
|
||||
return wrapResponse(null)(comment);
|
||||
},
|
||||
addTag(_, {tag}, {mutators: {Tag}}) {
|
||||
return wrapResponse(null)(Tag.add(tag));
|
||||
addTag: async (_, {tag}, {mutators: {Tag}}) => {
|
||||
await Tag.add(tag);
|
||||
},
|
||||
removeTag(_, {tag}, {mutators: {Tag}}) {
|
||||
return wrapResponse(null)(Tag.remove(tag));
|
||||
removeTag: async (_, {tag}, {mutators: {Tag}}) => {
|
||||
await Tag.remove(tag);
|
||||
},
|
||||
updateSettings(_, {input: settings}, {mutators: {Settings}}) {
|
||||
return wrapResponse(null)(Settings.update(settings));
|
||||
updateSettings: async (_, {input: settings}, {mutators: {Settings}}) => {
|
||||
await Settings.update(settings);
|
||||
},
|
||||
updateWordlist(_, {input: wordlist}, {mutators: {Settings}}) {
|
||||
return wrapResponse(null)(Settings.updateWordlist(wordlist));
|
||||
updateWordlist: async (_, {input: wordlist}, {mutators: {Settings}}) => {
|
||||
await Settings.updateWordlist(wordlist);
|
||||
},
|
||||
createToken(_, {input}, {mutators: {Token}}) {
|
||||
return wrapResponse('token')(Token.create(input));
|
||||
},
|
||||
revokeToken(_, {input}, {mutators: {Token}}) {
|
||||
return wrapResponse(null)(Token.revoke(input));
|
||||
createToken: async (_, {input}, {mutators: {Token}}) => ({
|
||||
token: await Token.create(input),
|
||||
}),
|
||||
revokeToken: async (_, {input}, {mutators: {Token}}) => {
|
||||
await Token.revoke(input);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+3
-7
@@ -1,6 +1,6 @@
|
||||
const {makeExecutableSchema} = require('graphql-tools');
|
||||
const {maskErrors} = require('graphql-errors');
|
||||
const {decorateWithHooks} = require('./hooks');
|
||||
const {decorateWithErrorHandler} = require('./errorHandler');
|
||||
|
||||
const plugins = require('../services/plugins');
|
||||
const resolvers = require('./resolvers');
|
||||
@@ -11,11 +11,7 @@ const schema = makeExecutableSchema({typeDefs, resolvers});
|
||||
// Plugin to the schema level resolvers to provide an before/after hook.
|
||||
decorateWithHooks(schema, plugins.get('server', 'hooks'));
|
||||
|
||||
// If we are in production mode, don't show server errors to the front end.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
// Mask errors that are thrown if we are in a production environment.
|
||||
maskErrors(schema);
|
||||
}
|
||||
// Handle errors like masking in production and mutation errors.
|
||||
decorateWithErrorHandler(schema);
|
||||
|
||||
module.exports = schema;
|
||||
|
||||
+13
-13
@@ -1254,7 +1254,7 @@ type RevokeTokenResponse implements Response {
|
||||
type RootMutation {
|
||||
|
||||
# Creates a comment on the asset.
|
||||
createComment(comment: CreateCommentInput!): CreateCommentResponse!
|
||||
createComment(input: CreateCommentInput!): CreateCommentResponse!
|
||||
|
||||
# Creates a flag on an entity.
|
||||
createFlag(flag: CreateFlagInput!): CreateFlagResponse!
|
||||
@@ -1270,42 +1270,42 @@ type RootMutation {
|
||||
|
||||
# Sets User status. Requires the `ADMIN` role.
|
||||
# Mutation is restricted.
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse!
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
|
||||
|
||||
# Suspends a user. Requires the `ADMIN` role.
|
||||
# Mutation is restricted.
|
||||
suspendUser(input: SuspendUserInput!): SuspendUserResponse!
|
||||
suspendUser(input: SuspendUserInput!): SuspendUserResponse
|
||||
|
||||
# Reject a username. Requires the `ADMIN` role.
|
||||
# Mutation is restricted.
|
||||
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse!
|
||||
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
|
||||
|
||||
# Sets Comment status. Requires the `ADMIN` role.
|
||||
# Mutation is restricted.
|
||||
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse!
|
||||
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
|
||||
|
||||
# Add a tag.
|
||||
addTag(tag: ModifyTagInput!): ModifyTagResponse!
|
||||
addTag(tag: ModifyTagInput!): ModifyTagResponse
|
||||
|
||||
# Removes a tag.
|
||||
removeTag(tag: ModifyTagInput!): ModifyTagResponse!
|
||||
removeTag(tag: ModifyTagInput!): ModifyTagResponse
|
||||
|
||||
# Updates settings on a given asset.
|
||||
# Mutation is restricted.
|
||||
updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse!
|
||||
updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse
|
||||
|
||||
# Updates the status of an asset allowing you to close/reopen an asset for
|
||||
# commenting.
|
||||
# Mutation is restricted.
|
||||
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse!
|
||||
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse
|
||||
|
||||
# updateSettings will update the global settings.
|
||||
# Mutation is restricted.
|
||||
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse!
|
||||
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse
|
||||
|
||||
# updateWordlist will update the given Wordlist.
|
||||
# Mutation is restricted.
|
||||
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse!
|
||||
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse
|
||||
|
||||
# Ignore comments by another user
|
||||
ignoreUser(id: ID!): IgnoreUserResponse
|
||||
@@ -1316,10 +1316,10 @@ type RootMutation {
|
||||
|
||||
# RevokeToken will revoke an existing token.
|
||||
# Mutation is restricted.
|
||||
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse!
|
||||
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse
|
||||
|
||||
# Stop Ignoring comments by another user.
|
||||
stopIgnoringUser(id: ID!): StopIgnoringUserResponse!
|
||||
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const {
|
||||
GraphQLObjectType,
|
||||
GraphQLInterfaceType
|
||||
} = require('graphql');
|
||||
|
||||
/**
|
||||
* Iterates over each field in a schema.
|
||||
* This function is pretty much copied verbatim from the graphql-tools repo:
|
||||
* https://github.com/apollographql/graphql-tools/blob/b12973c86e00be209d04af0184780998056051c4/src/schemaGenerator.ts#L180-L194
|
||||
* With the small alteration that we look for the `resolveType` function on the
|
||||
* schema so we can wrap post hooks around it to provide additional resolve
|
||||
* points. (Only when `options.includeResolveType` is set to true).
|
||||
*
|
||||
* @param {GraphQLSchema} schema the schema to iterate over
|
||||
* @param {function} fn callback to call on each field
|
||||
* @param {object} [options] options
|
||||
* @param {boolean} [options.includeResolveType] include resolveType during iteration
|
||||
* @return {void}
|
||||
*/
|
||||
const forEachField = (schema, fn, options = {}) => {
|
||||
const {includeResolveType = false} = options;
|
||||
|
||||
const typeMap = schema.getTypeMap();
|
||||
Object.keys(typeMap).forEach((typeName) => {
|
||||
const type = typeMap[typeName];
|
||||
|
||||
if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
|
||||
|
||||
// Here we capture the change to extract the resolve type. We pass this
|
||||
// with the `isResolveType = true` to introduce the specific beheviour.
|
||||
if (includeResolveType && 'resolveType' in type) {
|
||||
fn(type, typeName, '__resolveType', true);
|
||||
}
|
||||
|
||||
const fields = type.getFields();
|
||||
Object.keys(fields).forEach((fieldName) => {
|
||||
const field = fields[fieldName];
|
||||
fn(field, typeName, fieldName);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
forEachField,
|
||||
};
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
"version": "3.5.0",
|
||||
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "./bin/cli plugins reconcile --skip-remote",
|
||||
"start": "./bin/cli serve -j -w",
|
||||
@@ -11,8 +12,8 @@
|
||||
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
|
||||
"prebuild-watch": "yarn generate-introspection",
|
||||
"build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch",
|
||||
"lint": "eslint --ext .json bin/* .",
|
||||
"lint-fix": "eslint bin/* . --fix",
|
||||
"lint": "eslint --ext=.js --ext=.json bin/* .",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
|
||||
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const wrapResponse = require('../../../graph/helpers/response');
|
||||
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
|
||||
const errors = require('../../../errors');
|
||||
const pluralize = require('pluralize');
|
||||
@@ -90,10 +89,6 @@ function getReactionConfig(reaction) {
|
||||
}
|
||||
|
||||
type Delete${Reaction}ActionResponse implements Response {
|
||||
|
||||
# The ${reaction} that was created.
|
||||
${reaction}: ${Reaction}Action
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
}
|
||||
@@ -101,7 +96,7 @@ function getReactionConfig(reaction) {
|
||||
type RootMutation {
|
||||
|
||||
# Creates a ${reaction} on an entity.
|
||||
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse
|
||||
create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse!
|
||||
delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse
|
||||
}
|
||||
|
||||
@@ -160,7 +155,7 @@ function getReactionConfig(reaction) {
|
||||
}
|
||||
},
|
||||
RootMutation: {
|
||||
[`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => {
|
||||
[`create${Reaction}Action`]: async (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
|
||||
const comment = await Comments.get.load(item_id);
|
||||
|
||||
let action;
|
||||
@@ -180,9 +175,11 @@ function getReactionConfig(reaction) {
|
||||
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
|
||||
}
|
||||
|
||||
return action;
|
||||
}),
|
||||
[`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => wrapResponse(reaction)(async () => {
|
||||
return {
|
||||
[reaction]: action,
|
||||
};
|
||||
},
|
||||
[`delete${Reaction}Action`]: async (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
|
||||
const action = await Action.delete({id});
|
||||
if (!action) {
|
||||
return null;
|
||||
@@ -194,9 +191,7 @@ function getReactionConfig(reaction) {
|
||||
// The comment is needed to allow better filtering e.g. by asset_id.
|
||||
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
|
||||
}
|
||||
|
||||
return action;
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -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,38 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* CheckToxicityHook adds hooks to the `commentBox`
|
||||
* that handles checking a comment for toxicity.
|
||||
*/
|
||||
export default class CheckToxicityHook extends React.Component {
|
||||
|
||||
// checked signifies if we already sent a request with the `checkToxicity` set to true.
|
||||
checked = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.toxicityPreHook = this.props.registerHook('preSubmit', (input) => {
|
||||
|
||||
// If we haven't check the toxicity yet, make sure to include `checkToxicity=true` in the mutation.
|
||||
// Otherwise post comment without checking the toxicity.
|
||||
if (!this.checked) {
|
||||
input.checkToxicity = true;
|
||||
this.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.toxicityPostHook = this.props.registerHook('postSubmit', () => {
|
||||
|
||||
// Reset `checked` after comment was successfully posted.
|
||||
this.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.unregisterHook(this.toxicityPreHook);
|
||||
this.props.unregisterHook(this.toxicityPostHook);
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import translations from './translations.yml';
|
||||
import CheckToxicityHook from './components/CheckToxicityHook';
|
||||
|
||||
export default {
|
||||
translations,
|
||||
slots: {
|
||||
commentInputDetailArea: [CheckToxicityHook],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
en:
|
||||
error:
|
||||
COMMENT_IS_TOXIC: |
|
||||
Are you sure? The language in this comment might violate our community guidelines.
|
||||
You can edit the comment or submit it for moderator review.
|
||||
es:
|
||||
@@ -0,0 +1,8 @@
|
||||
const {readFileSync} = require('fs');
|
||||
const path = require('path');
|
||||
const hooks = require('./server/hooks');
|
||||
|
||||
module.exports = {
|
||||
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'),
|
||||
hooks,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-toxicity",
|
||||
"pluginName": "talk-plugin-toxicity",
|
||||
"version": "0.0.1",
|
||||
"description": "Provides support for measuring the toxicity of user comments using the Perspectives API",
|
||||
"main": "index.js",
|
||||
"author": "The Coral Project Team <coral@mozillafoundation.org>",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const config = {
|
||||
API_ENDPOINT: process.env.TALK_PERSPECTIVE_API_ENDPOINT || 'https://commentanalyzer.googleapis.com/v1alpha1',
|
||||
API_KEY: process.env.TALK_PERSPECTIVE_API_KEY,
|
||||
THRESHOLD: process.env.TALK_TOXICITY_THRESHOLD || 0.8,
|
||||
API_TIMEOUT: process.env.TALK_PERSPECTIVE_TIMEOUT || 300,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && !config.API_KEY) {
|
||||
throw new Error('Please set the TALK_PERSPECTIVE_API_KEY environment variable to use the toxic-comments plugin. Visit https://www.perspectiveapi.com/ to request API access.');
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,13 @@
|
||||
const {APIError} = require('errors');
|
||||
|
||||
// ErrToxic is sent during a `CreateComment` mutation where
|
||||
// `input.checkToxicity` is set to true and the comment contains
|
||||
// toxic language as determined by the perspective service.
|
||||
const ErrToxic = new APIError('Comment is toxic', {
|
||||
status: 400,
|
||||
translation_key: 'COMMENT_IS_TOXIC',
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ErrToxic,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
const {getScores, isToxic} = require('./perspective');
|
||||
const {ErrToxic} = require('./errors');
|
||||
const ActionsService = require('../../../services/actions');
|
||||
|
||||
// We don't add the hooks during _test_ as the perspective API is not available.
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
async pre(_, {input}, _context, _info) {
|
||||
|
||||
let scores;
|
||||
|
||||
// Try getting scores.
|
||||
try {
|
||||
scores = await getScores(input.body);
|
||||
}
|
||||
catch(err) {
|
||||
|
||||
// Warn and let mutation pass.
|
||||
console.trace(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const commentIsToxic = isToxic(scores);
|
||||
|
||||
if (input.checkToxicity && commentIsToxic) {
|
||||
throw ErrToxic;
|
||||
}
|
||||
|
||||
// attach scores to metadata.
|
||||
input.metadata = Object.assign({}, input.metadata, {
|
||||
perspective: scores,
|
||||
});
|
||||
|
||||
if (commentIsToxic) {
|
||||
|
||||
// TODO: this should have a different status than Premod.
|
||||
input.status = 'PREMOD';
|
||||
}
|
||||
},
|
||||
async post(_, _input, _context, _info, result) {
|
||||
const metadata = result.comment.metadata;
|
||||
if (metadata.perspective && isToxic(metadata.perspective)) {
|
||||
|
||||
// TODO: this is kind of fragile, we should refactor this to resolve
|
||||
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
|
||||
// defined in a checkable schema.
|
||||
|
||||
// Add a flag to the comment.
|
||||
await ActionsService.create({
|
||||
item_id: result.comment.id,
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'FLAG',
|
||||
user_id: null,
|
||||
group_id: 'Comment contains toxic language',
|
||||
metadata: {}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
const fetch = require('node-fetch');
|
||||
const {API_ENDPOINT, API_KEY, THRESHOLD, API_TIMEOUT} = require('./config');
|
||||
|
||||
/**
|
||||
* Get scores from the perspective api
|
||||
* @param {string} text text to be anaylized
|
||||
* @return {object} object containing toxicity scores
|
||||
*/
|
||||
async function getScores(text) {
|
||||
const response = await fetch(`${API_ENDPOINT}/comments:analyze?key=${API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: API_TIMEOUT,
|
||||
body: JSON.stringify({
|
||||
comment: {
|
||||
text,
|
||||
},
|
||||
|
||||
// TODO: support other languages.
|
||||
languages: ['en'],
|
||||
requestedAttributes: {
|
||||
TOXICITY: {},
|
||||
SEVERE_TOXICITY: {},
|
||||
}
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return {
|
||||
TOXICITY: {
|
||||
summaryScore: data.attributeScores.TOXICITY.summaryScore.value
|
||||
},
|
||||
SEVERE_TOXICITY: {
|
||||
summaryScore: data.attributeScores.SEVERE_TOXICITY.summaryScore.value
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get toxicity probability
|
||||
* @param {object} scores scores as returned by `getScores`
|
||||
* @return {number} toxicity probability from 0 - 1.0
|
||||
*/
|
||||
function getProbability(scores) {
|
||||
return scores.SEVERE_TOXICITY.summaryScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* isToxic determines if given probabilty or scores meets the toxicity threshold.
|
||||
* @param {object|number} scoresOrProbability scores or probability
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isToxic(scoresOrProbability) {
|
||||
const probability = typeof scoresOrProbability === 'object'
|
||||
? getProbability(scoresOrProbability)
|
||||
: scoresOrProbability;
|
||||
return probability > THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* maskKeyInError is a decorator that calls fn and masks the
|
||||
* API_KEY in errors before throwing.
|
||||
* @param {function} fn Function that returns a Promise
|
||||
* @return {function} decorated function
|
||||
*/
|
||||
function maskKeyInError(fn) {
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
}
|
||||
catch(err) {
|
||||
if (err.message) {
|
||||
err.message = err.message.replace(API_KEY, '***');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getScores: maskKeyInError(getScores),
|
||||
getProbability,
|
||||
isToxic,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
input CreateCommentInput {
|
||||
|
||||
# If true, the mutation will fail when the
|
||||
# body contains toxic language.
|
||||
checkToxicity: Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ describe('graph.mutations.createComment', () => {
|
||||
beforeEach(() => SettingsService.init());
|
||||
|
||||
const query = `
|
||||
mutation CreateComment($comment: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) {
|
||||
createComment(comment: $comment) {
|
||||
mutation CreateComment($input: CreateCommentInput = {asset_id: 123, body: "Here's my comment!"}) {
|
||||
createComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
@@ -176,7 +176,7 @@ describe('graph.mutations.createComment', () => {
|
||||
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
|
||||
|
||||
return graphql(schema, query, {}, context, {
|
||||
comment: {
|
||||
input: {
|
||||
asset_id: '123',
|
||||
body
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('graph.mutations.removeTag', () => {
|
||||
console.error(response.errors);
|
||||
}
|
||||
expect(response.errors).to.be.empty;
|
||||
expect(response.data.removeTag.errors).to.be.null;
|
||||
expect(response.data.removeTag).to.be.null;
|
||||
|
||||
let retrievedComment = await CommentsService.findById(comment.id);
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ describe('graph.mutations.updateAssetSettings', () => {
|
||||
expect(res.data.updateAssetSettings.errors).to.not.be.empty;
|
||||
expect(res.data.updateAssetSettings.errors[0]).to.have.property('translation_key', error);
|
||||
} else {
|
||||
expect(res.data.updateAssetSettings.errors).to.be.null;
|
||||
if (res.data.updateAssetSettings && res.data.updateAssetSettings.errors) {
|
||||
console.error(res.data.updateAssetSettings.errors);
|
||||
}
|
||||
expect(res.data.updateAssetSettings).to.be.null;
|
||||
|
||||
const retrievedAsset = await AssetModel.findOne({id: asset.id});
|
||||
Object.keys(settings).forEach((key) => {
|
||||
|
||||
@@ -55,7 +55,10 @@ describe('graph.mutations.updateAssetStatus', () => {
|
||||
expect(res.data.updateAssetStatus.errors).to.not.be.empty;
|
||||
expect(res.data.updateAssetStatus.errors[0]).to.have.property('translation_key', error);
|
||||
} else {
|
||||
expect(res.data.updateAssetStatus.errors).to.be.null;
|
||||
if (res.data.updateAssetStatus && res.data.updateAssetStatus.errors) {
|
||||
console.error(res.data.updateAssetStatus.errors);
|
||||
}
|
||||
expect(res.data.updateAssetStatus).to.be.null;
|
||||
|
||||
const retrievedAsset = await AssetModel.findOne({id: asset.id});
|
||||
expect(retrievedAsset.closedAt).to.not.be.null;
|
||||
|
||||
@@ -58,10 +58,10 @@ describe('graph.mutations.updateSettings', () => {
|
||||
expect(res.data.updateSettings.errors).to.not.be.empty;
|
||||
expect(res.data.updateSettings.errors[0]).to.have.property('translation_key', error);
|
||||
} else {
|
||||
if (res.data.updateSettings.errors) {
|
||||
if (res.data.updateSettings && res.data.updateSettings.errors) {
|
||||
console.error(res.data.updateSettings.errors);
|
||||
}
|
||||
expect(res.data.updateSettings.errors).to.be.null;
|
||||
expect(res.data.updateSettings).to.be.null;
|
||||
|
||||
const retrievedSettings = await SettingsService.retrieve();
|
||||
Object.keys(newSettings).forEach((key) => {
|
||||
|
||||
@@ -65,10 +65,10 @@ describe('graph.mutations.updateWordlist', () => {
|
||||
expect(retrievedWordlist).to.have.property('suspect');
|
||||
expect(retrievedWordlist.suspect).to.have.members([]);
|
||||
} else {
|
||||
if (res.data.updateWordlist.errors) {
|
||||
if (res.data.updateWordlist && res.data.updateWordlist.errors) {
|
||||
console.error(res.data.updateWordlist.errors);
|
||||
}
|
||||
expect(res.data.updateWordlist.errors).to.be.null;
|
||||
expect(res.data.updateWordlist).to.be.null;
|
||||
|
||||
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
|
||||
expect(retrievedWordlist).to.have.property('banned');
|
||||
|
||||
Reference in New Issue
Block a user