Merge branch 'master' into redis-cluster-support

This commit is contained in:
Kim Gardner
2017-09-20 16:28:08 +01:00
committed by GitHub
52 changed files with 544 additions and 230 deletions
+4 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const bodyParser = require('body-parser');
const morgan = require('morgan');
const path = require('path');
const merge = require('lodash/merge');
const helmet = require('helmet');
const compression = require('compression');
const cookieParser = require('cookie-parser');
@@ -10,6 +11,7 @@ const {
BASE_PATH,
MOUNT_PATH,
STATIC_URL,
HELMET_CONFIGURATION,
} = require('./url');
const routes = require('./routes');
const debug = require('debug')('talk:app');
@@ -31,9 +33,9 @@ app.set('trust proxy', 1);
// Enable a suite of security good practices through helmet. We disable
// frameguard to allow crossdomain injection of the embed.
app.use(helmet({
app.use(helmet(merge(HELMET_CONFIGURATION, {
frameguard: false,
}));
})));
// Compress the responses if appropriate.
app.use(compression());
@@ -0,0 +1,27 @@
.root {
display: flex;
justify-content: flex-end;
}
.coreLabels {
> *:not(:last-child) {
margin-right: 3px;
}
}
.slot {
&:not(:empty) {
padding-left: 3px;
}
> *:not(:last-child) {
margin-right: 3px;
}
}
.replyLabel {
background-color: #3D73D5;
}
.premodLabel {
background-color: #063B9A;
}
@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import Label from 'coral-ui/components/Label';
import Slot from 'coral-framework/components/Slot';
import FlagLabel from 'coral-ui/components/FlagLabel';
import cn from 'classnames';
import styles from './CommentLabels.css';
function isUserFlagged(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.user);
}
function hasSuspectedWords(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter');
}
function hasHistoryFlag(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'TRUST');
}
const CommentLabels = ({comment, comment: {className, status, actions, hasParent}}) => {
return (
<div className={cn(className, styles.root)}>
<div className={styles.coreLabels}>
{hasParent && <Label iconName="reply" className={styles.replyLabel}>reply</Label>}
{status === 'PREMOD' && <Label iconName="query_builder" className={styles.premodLabel}>Pre-Mod</Label>}
{isUserFlagged(actions) && <FlagLabel iconName="person">User</FlagLabel>}
{hasSuspectedWords(actions) && <FlagLabel iconName="sms_failed">Suspect</FlagLabel>}
{hasHistoryFlag(actions) && <FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>}
</div>
<Slot className={styles.slot} fill="adminCommentLabels" queryData={{comment}} />
</div>
);
};
CommentLabels.propTypes = {
comment: PropTypes.shape({
className: PropTypes.string,
status: PropTypes.string,
actions: PropTypes.array,
hasParent: PropTypes.bool,
}),
};
export default CommentLabels;
@@ -1,30 +0,0 @@
.commentType {
display: inline-block;
color: white;
background: grey;
box-sizing: border-box;
padding: 2px 5px;
font-size: 12px;
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
> i {
font-size: 14px;
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
&.premod {
background: #063B9A;
}
&.flagged {
background: #d03235;
}
&.no-type {
display: none;
}
}
@@ -1,32 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './CommentType.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const CommentType = (props) => {
const typeData = getTypeData(props.type);
return (
<span className={cn(styles.commentType, styles[typeData.className], props.className)}>
<Icon name={typeData.icon}/>{typeData.text}
</span>
);
};
const getTypeData = (type) => {
switch (type) {
case 'premod':
return {icon: 'query_builder', text: 'Pre-Mod', className: 'premod'};
case 'flagged':
return {icon: 'flag', text: 'Flagged', className: 'flagged'};
default:
return {icon: 'flag', className: 'no-type'};
}
};
CommentType.propTypes = {
type: PropTypes.string.isRequired
};
export default CommentType;
@@ -5,7 +5,7 @@
vertical-align: middle;
padding: 1px 5px;
border-radius: 2px;
margin-left: 2px;
margin-left: 5px;
line-height: 18px;
box-sizing: border-box;
height: 18px;
@@ -1,10 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './CommentCount.css';
import styles from './CountBadge.css';
import t from 'coral-framework/services/i18n';
const CommentCount = ({count}) => {
const CountBadge = ({count}) => {
let number = count;
// shorten large counts to abbreviations
@@ -21,8 +21,8 @@ const CommentCount = ({count}) => {
);
};
CommentCount.propTypes = {
CountBadge.propTypes = {
count: PropTypes.number.isRequired
};
export default CommentCount;
export default CountBadge;
@@ -1,11 +0,0 @@
import React from 'react';
import {Badge} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const ReplyBadge = () => (
<Badge icon="reply">
{t('modqueue.reply')}
</Badge>
);
export default ReplyBadge;
@@ -55,7 +55,7 @@
position: relative;
}
.badgeBar {
.labels {
position: absolute;
right: 0px;
}
@@ -4,16 +4,14 @@ import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import ReplyBadge from './ReplyBadge';
import styles from './UserDetailComment.css';
import CommentType from './CommentType';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import CommentAnimatedEdit from './CommentAnimatedEdit';
import CommentLabels from '../containers/CommentLabels';
import t, {timeago} from 'coral-framework/services/i18n';
@@ -35,7 +33,6 @@ class UserDetailComment extends React.Component {
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
return (
<li
@@ -59,9 +56,8 @@ class UserDetailComment extends React.Component {
: null
}
<div className={styles.badgeBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType}/>
<div className={styles.labels}>
<CommentLabels comment={comment} />
</div>
</div>
<div className={styles.story}>
@@ -0,0 +1,27 @@
import {gql} from 'react-apollo';
import CommentLabels from '../components/CommentLabels';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
const slots = [
'adminCommentLabels',
];
export default withFragments({
comment: gql`
fragment CoralAdmin_CommentLabels_comment on Comment {
hasParent
status
actions {
__typename
... on FlagAction {
reason
}
user {
id
}
}
${getSlotFragmentSpreads(slots, 'comment')}
}
`
})(CommentLabels);
@@ -1,6 +1,8 @@
import {gql} from 'react-apollo';
import UserDetailComment from '../components/UserDetailComment';
import withFragments from 'coral-framework/hocs/withFragments';
import {getDefinitionName} from 'coral-framework/utils';
import CommentLabels from './CommentLabels';
export default withFragments({
comment: gql`
@@ -35,6 +37,8 @@ export default withFragments({
editing {
edited
}
...${getDefinitionName(CommentLabels.fragments.comment)}
}
${CommentLabels.fragments.comment}
`
})(UserDetailComment);
@@ -4,6 +4,7 @@ import CommunityMenu from './CommunityMenu';
import People from './People';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import RejectUsernameDialog from './RejectUsernameDialog';
import PropTypes from 'prop-types';
export default class Community extends Component {
@@ -76,7 +77,10 @@ export default class Community extends Component {
return (
<div>
<FlaggedAccounts />
<FlaggedAccounts
data={this.props.data}
root={this.props.root}
/>
<RejectUsernameDialog
open={community.rejectUsernameDialog}
handleClose={props.hideRejectUsernameDialog}
@@ -90,15 +94,25 @@ export default class Community extends Component {
render() {
const {searchValue} = this.state;
const tab = this.getTabContent(searchValue, this.props);
const {root: {flaggedUsernamesCount}} = this.props;
return (
<div>
<CommunityMenu />
<div>
{ tab }
</div>
<CommunityMenu flaggedUsernamesCount={flaggedUsernamesCount} />
<div>{tab}</div>
</div>
);
}
}
Community.propTypes = {
community: PropTypes.object,
fetchAccounts: PropTypes.func,
hideRejectUsernameDialog: PropTypes.func,
updateSorting: PropTypes.func,
newPage: PropTypes.func,
route: PropTypes.object,
rejectUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
};
@@ -1,18 +1,21 @@
import React from 'react';
import styles from './CommunityMenu.css';
import t from 'coral-framework/services/i18n';
import {Link} from 'react-router';
import PropTypes from 'prop-types';
import CountBadge from '../../../components/CountBadge';
const CommunityMenu = () => {
const CommunityMenu = ({flaggedUsernamesCount = 0}) => {
const flaggedPath = '/admin/community/flagged';
const peoplePath = '/admin/community/people';
return (
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div>
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{t('community.flaggedaccounts')}
<CountBadge count={flaggedUsernamesCount} />
</Link>
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{t('community.people')}
@@ -23,4 +26,8 @@ const CommunityMenu = () => {
);
};
CommunityMenu.propTypes = {
flaggedUsernamesCount: PropTypes.number,
};
export default CommunityMenu;
@@ -1,9 +1,16 @@
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import PropTypes from 'prop-types';
import FlaggedAccounts from '../containers/FlaggedAccounts';
import FlaggedUser from '../containers/FlaggedUser';
import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations';
import {getDefinitionName} from 'coral-framework/utils';
import {
fetchAccounts,
updateSorting,
@@ -20,15 +27,57 @@ class CommunityContainer extends Component {
}
render() {
return (
<Community {...this.props} />
);
return <Community
fetchAccounts={this.props.fetchAccounts}
community={this.props.community}
hideRejectUsernameDialog={this.props.hideRejectUsernameDialog}
updateSorting={this.props.updateSorting}
newPage={this.props.newPage}
route={this.props.route}
rejectUsername={this.props.rejectUsername}
data={this.props.data}
root={this.props.root}
/>;
}
}
const mapStateToProps = (state) => ({
community: state.community,
});
CommunityContainer.propTypes = {
community: PropTypes.object,
fetchAccounts: PropTypes.func,
hideRejectUsernameDialog: PropTypes.func,
updateSorting: PropTypes.func,
newPage: PropTypes.func,
route: PropTypes.object,
rejectUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
};
const withData = withQuery(gql`
query TalkAdmin_FlaggedUsernamesCount {
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
})
...${getDefinitionName(FlaggedAccounts.fragments.root)}
...${getDefinitionName(FlaggedUser.fragments.root)}
me {
...${getDefinitionName(FlaggedUser.fragments.me)}
__typename
}
}
${FlaggedAccounts.fragments.root}
${FlaggedUser.fragments.root}
${FlaggedUser.fragments.me}
`, {
options: {
fetchPolicy: 'network-only',
},
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
fetchAccounts,
@@ -41,4 +90,5 @@ export default compose(
connect(mapStateToProps, mapDispatchToProps),
withSetUserStatus,
withRejectUsername,
withData,
)(CommunityContainer);
@@ -2,8 +2,9 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import withQuery from 'coral-framework/hocs/withQuery';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
import {withSetUserStatus} from 'coral-framework/graphql/mutations';
import {showBanUserDialog} from 'actions/banUserDialog';
@@ -24,7 +25,10 @@ class FlaggedAccountsContainer extends Component {
}
approveUser = ({userId}) => {
return this.props.setUserStatus({userId, status: 'APPROVED'});
return this.props.setUserStatus({
userId,
status: 'APPROVED'
});
}
loadMore = () => {
@@ -74,6 +78,16 @@ class FlaggedAccountsContainer extends Component {
}
}
FlaggedAccountsContainer.propTypes = {
showBanUserDialog: PropTypes.func,
showSuspendUserDialog: PropTypes.func,
showRejectUsernameDialog: PropTypes.func,
viewUserDetail: PropTypes.func,
setUserStatus: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object
};
const LOAD_MORE_QUERY = gql`
query TalkAdmin_LoadMoreFlaggedAccounts($limit: Int, $cursor: Cursor) {
users(query:{action_type: FLAG, statuses: [PENDING], limit: $limit, cursor: $cursor}){
@@ -88,31 +102,6 @@ const LOAD_MORE_QUERY = gql`
${FlaggedUser.fragments.user}
`;
export const withFlaggedAccountsyQuery = withQuery(gql`
query TalkAdmin_FlaggedAccounts {
...${getDefinitionName(FlaggedUser.fragments.root)}
users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){
hasNextPage
endCursor
nodes {
__typename
...${getDefinitionName(FlaggedUser.fragments.user)}
}
}
me {
__typename
...${getDefinitionName(FlaggedUser.fragments.me)}
}
}
${FlaggedUser.fragments.root}
${FlaggedUser.fragments.user}
${FlaggedUser.fragments.me}
`, {
options: {
fetchPolicy: 'network-only',
},
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
showBanUserDialog,
@@ -123,6 +112,23 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
connect(null, mapDispatchToProps),
withFlaggedAccountsyQuery,
withSetUserStatus,
withFragments({
root: gql`
fragment TalkAdminCommunity_FlaggedAccounts_root on RootQuery {
users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){
hasNextPage
endCursor
nodes {
__typename
...${getDefinitionName(FlaggedUser.fragments.user)}
}
}
me {
id
}
}
${FlaggedUser.fragments.user}
`,
}),
)(FlaggedAccountsContainer);
@@ -3,10 +3,9 @@ import PropTypes from 'prop-types';
import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import ReplyBadge from 'coral-admin/src/components/ReplyBadge';
import FlagBox from 'coral-admin/src/components/FlagBox';
import styles from './styles.css';
import CommentType from 'coral-admin/src/components/CommentType';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
@@ -16,7 +15,6 @@ import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import t, {timeago} from 'coral-framework/services/i18n';
@@ -66,7 +64,6 @@ class Comment extends React.Component {
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -109,8 +106,9 @@ class Comment extends React.Component {
</ActionsMenu>
}
<div className={styles.adminCommentInfoBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType} className={styles.commentType}/>
<CommentLabels
comment={comment}
/>
<Slot
fill="adminCommentInfoBar"
data={data}
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import CommentCount from './CommentCount';
import CountBadge from '../../../components/CountBadge';
import styles from './styles.css';
import {SelectField, Option} from 'react-mdl-selectfield';
import {Icon} from 'coral-ui';
@@ -28,7 +28,7 @@ const ModerationMenu = ({
to={getModPath(queue.key, asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === queue.key})}
activeClassName={styles.active}>
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CommentCount count={queue.count} />
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CountBadge count={queue.count} />
</Link>
)}
</div>
@@ -1,7 +1,8 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import CommentLabels from '../../../containers/CommentLabels';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {getSlotFragmentSpreads, getDefinitionName} from 'coral-framework/utils';
const slots = [
'adminCommentInfoBar',
@@ -58,6 +59,8 @@ export default withFragments({
}
hasParent
${getSlotFragmentSpreads(slots, 'comment')}
...${getDefinitionName(CommentLabels.fragments.comment)}
}
${CommentLabels.fragments.comment}
`
})(Comment);
@@ -170,7 +170,8 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
statuses: this.props.queueConfig[tab].statuses,
statuses: this.props.queueConfig[tab].statuses || null,
tags: this.props.queueConfig[tab].tags || null,
action_type: this.props.queueConfig[tab].action_type,
};
return this.props.data.fetchMore({
@@ -214,7 +215,7 @@ class ModerationContainer extends Component {
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -293,8 +294,8 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type}) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -319,10 +320,10 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!) {
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
@@ -333,7 +334,7 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
`)}
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
@@ -361,6 +362,7 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
@@ -28,7 +28,6 @@ export default {
name: t('modqueue.rejected'),
},
all: {
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
icon: 'question_answer',
name: t('modqueue.all'),
},
-9
View File
@@ -1,9 +0,0 @@
export function getCommentType(comment) {
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')) {
commentType = 'flagged';
}
return commentType;
}
@@ -160,7 +160,7 @@ class Stream extends React.Component {
loading={loading}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
{t('stream.all_comments')} <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
}
appendTabPanes={
@@ -64,6 +64,13 @@ export default {
hasNextPage
}
}
actions {
__typename
... on FlagAction {
reason
message
}
}
}
fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment {
@@ -77,14 +84,6 @@ export default {
title
url
}
actions {
__typename
id
... on FlagAction {
reason
message
}
}
tags {
tag {
name
@@ -123,6 +122,8 @@ export default {
optimisticResponse: {
createComment: {
__typename: 'CreateCommentResponse',
errors: null,
actions: [],
comment: {
__typename: 'Comment',
user: {
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
import styles from './Badge.css';
import Icon from './Icon';
import cn from 'classnames';
const Badge = ({className, children, icon, props}) => (
<span className={cn(styles.badge, className)} {...props}>
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</span>
);
export default Badge;
+4
View File
@@ -0,0 +1,4 @@
.flag {
background: #d03235;
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './FlagLabel.css';
import Label from './Label';
import cn from 'classnames';
const FlagLabel = ({iconName, children, className}) => {
return (
<Label iconName={iconName} className={cn(className, styles.flag)}>
{children}
</Label>
);
};
FlagLabel.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
iconName: PropTypes.string,
};
export default FlagLabel;
@@ -1,4 +1,4 @@
.badge {
.root {
display: inline-block;
color: white;
background: grey;
@@ -8,8 +8,8 @@
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
background-color: #3D73D5;
margin-right: 4px;
min-width: 80px;
text-align: center;
}
.icon {
@@ -17,4 +17,6 @@
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Label.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const Label = ({iconName, children, className}) => {
return (
<span className={cn(className, styles.root)}>
<Icon name={iconName} className={styles.icon} /> {children}
</span>
);
};
Label.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
iconName: PropTypes.string,
};
export default Label;
+2 -1
View File
@@ -26,4 +26,5 @@ export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';
export {default as Badge} from './components/Badge';
export {default as Label} from './components/Label';
export {default as FlagLabel} from './components/FlagLabel';
+13 -3
View File
@@ -11,12 +11,20 @@ import {CommentForm} from './CommentForm';
export const name = 'talk-plugin-commentbox';
const notifyReasons = ['LINKS', 'TRUST'];
function shouldNotify(actions = []) {
return actions.some(({__typename, reason}) => __typename === 'FlagAction' && notifyReasons.includes(reason));
}
// Given a newly posted comment's status, show a notification to the user
// if needed
export const notifyForNewCommentStatus = (notify, comment) => {
export const notifyForNewCommentStatus = (notify, comment, actions) => {
if (comment.status === 'REJECTED') {
notify('error', t('comment_box.comment_post_banned_word'));
} else if (comment.status === 'PREMOD' || comment.status === 'SYSTEM_WITHHELD') {
} else if (
comment.status === 'PREMOD' ||
comment.status === 'SYSTEM_WITHHELD' && shouldNotify(actions)) {
notify('success', t('comment_box.comment_post_notif_premod'));
}
};
@@ -70,11 +78,12 @@ class CommentBox extends React.Component {
.then(({data}) => {
this.setState({loadingState: 'success', body: ''});
const postedComment = data.createComment.comment;
const actions = data.createComment.actions;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach((hook) => hook(data));
notifyForNewCommentStatus(notify, postedComment);
notifyForNewCommentStatus(notify, postedComment, actions);
if (commentPostedHandler) {
commentPostedHandler();
@@ -187,6 +196,7 @@ CommentBox.propTypes = {
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
notify: PropTypes.func.isRequired,
commentBox: PropTypes.object,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
+8
View File
@@ -79,6 +79,14 @@ const CONFIG = {
INSTALL_LOCK: process.env.TALK_INSTALL_LOCK === 'TRUE',
//------------------------------------------------------------------------------
// Middleware Configuration
//------------------------------------------------------------------------------
// HELMET_CONFIGURATION provides the entrypoint to override options for the
// helmet middleware used.
HELMET_CONFIGURATION: JSON.parse(process.env.TALK_HELMET_CONFIGURATION || '{}'),
//------------------------------------------------------------------------------
// External database url's
//------------------------------------------------------------------------------
+15
View File
@@ -3,6 +3,21 @@ title: Frequently Asked Questions
permalink: /docs/faq/
---
{% include toc %}
### My site doesn't use HSTS headers, how do I stop Talk from sending them too?
You can specify the configuration option `TALK_HELMET_CONFIGURATION` and set it
to:
```
TALK_HELMET_CONFIGURATION={"hsts": false}
```
Which will disable the HSTS module. See the
[helmet](https://github.com/helmetjs/helmet) repository for more information on
how to configure other security middleware used by default.
### How are new stories/assets added to Talk? Is there an API?
There are three ways that new assets can make their way into Talk:
+6
View File
@@ -99,6 +99,12 @@ These are only used during the webpack build.
and you would then specify the CDN/Storage url. (Default `process.env.TALK_ROOT_URL`)
- `TALK_DISABLE_STATIC_SERVER` (_optional_) - When `TRUE`, it will not mount the
static asset serving routes on the router. (Default `FALSE`)
- `TALK_HELMET_CONFIGURATION` (_optional_) - A JSON string representing the
configuration passed to the
[helmet](https://github.com/helmetjs/helmet) middleware. It can be used to
disable features like [HSTS](https://helmetjs.github.io/docs/hsts/) and others
by simply providing the configuration as detailed on the
[helmet README](https://github.com/helmetjs/helmet). (Default `{}`)
### Word Filter
+22 -16
View File
@@ -91,14 +91,23 @@ const getParentCountsByAssetID = (context, asset_ids) => {
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags, action_type}) => {
let query = CommentModel.find();
if (ids) {
query = query.where({id: {$in: ids}});
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(context.user == null || !context.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
) {
return null;
}
if (statuses) {
query = query.where({status: {$in: statuses}});
}
if (ids) {
query = query.where({id: {$in: ids}});
}
if (asset_id != null) {
query = query.where({asset_id});
}
@@ -279,20 +288,17 @@ const executeWithSort = async (ctx, query, {cursor, sortOrder, sortBy, limit}) =
const getCommentsByQuery = async (ctx, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sortOrder, sortBy, excludeIgnored, tags, action_type}) => {
let comments = CommentModel.find();
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (ctx.user != null && ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses && statuses.length > 0) {
comments = comments.where({
status: {
$in: statuses
}
});
} else {
comments = comments.where({
status: {
$in: ['NONE', 'ACCEPTED']
}
});
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
) {
return null;
}
if (statuses) {
comments = comments.where({status: {$in: statuses}});
}
if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
+36 -1
View File
@@ -107,6 +107,40 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor,
};
};
/**
* Retrieves the count of users based on the passed in query.
* @param {Object} context graph context
* @param {Object} query query to execute against the users collection
* to compute the counts
* @return {Promise} resolves to the counts of the users from the
* query
*/
const getCountByQuery = async ({loaders: {Actions}}, {action_type, statuses}) => {
let query = UserModel.find();
if (action_type) {
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
query = query.find({
id: {
$in: userIds
}
});
}
if (statuses) {
query = query.where({
status: {
$in: statuses
}
});
}
return UserModel
.find(query)
.count();
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -115,6 +149,7 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor,
module.exports = (context) => ({
Users: {
getByQuery: (query) => getUsersByQuery(context, query),
getByID: new DataLoader((ids) => genUserByIDs(context, ids))
getByID: new DataLoader((ids) => genUserByIDs(context, ids)),
getCountByQuery: (query) => getCountByQuery(context, query)
}
});
+17 -12
View File
@@ -393,13 +393,12 @@ const moderationPhases = [
];
/**
* This resolves a given comment's status to take into account moderator actions
* are applied.
* This resolves a given comment's status and actions.
* @param {Object} context graphql context
* @param {String} body body of the comment
* @param {String} [asset_id] asset for the comment
* @param {Object} [wordlist={}] the results of the wordlist scan
* @return {Promise} resolves to the comment's status
* @return {Promise} resolves to the comment's status and actions
*/
const resolveCommentModeration = async (context, comment) => {
@@ -418,6 +417,8 @@ const resolveCommentModeration = async (context, comment) => {
// Combine the asset and the settings to get the asset settings.
const assetSettings = await AssetsService.rectifySettings(asset, settings);
let actions = comment.actions || [];
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of moderationPhases) {
const result = await phase(context, comment, {
@@ -429,13 +430,14 @@ const resolveCommentModeration = async (context, comment) => {
if (result) {
// Merge the comment and the result together.
comment = merge(comment, result);
if (result.actions) {
actions.push(...result.actions);
}
// If this result contained a status, then we've finished resolving
// phases!
if (result.status) {
return comment.actions;
return {status: result.status, actions};
}
}
}
@@ -454,17 +456,20 @@ const createPublicComment = async (context, comment) => {
// We then take the wordlist and the comment into consideration when
// considering what status to assign the new comment, and resolve the new
// status to set the comment to.
let actions = await resolveCommentModeration(context, comment);
let {actions, status} = await resolveCommentModeration(context, comment);
// Assign status to comment.
comment.status = status;
// Then we actually create the comment with the new status.
comment = await createComment(context, comment);
const result = await createComment(context, comment);
// Create all the actions that were determined during the moderation check
// phase.
await createActions(comment.id, actions);
await createActions(result.id, actions);
// Finally, we return the comment.
return comment;
return result;
};
// createActions will for each of the provided actions, create the given action
@@ -515,10 +520,10 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
let comment = {id, asset_id, body};
// Determine the new status of the comment.
const actions = await resolveCommentModeration(context, comment);
const {actions, status} = await resolveCommentModeration(context, comment);
// Execute the edit.
comment = await CommentsService.edit({id, author_id: context.user.id, body, status: comment.status});
comment = await CommentsService.edit({id, author_id: context.user.id, body, status});
// Create all the actions that were determined during the moderation check
// phase.
+1
View File
@@ -26,6 +26,7 @@ const Comment = {
query.asset_id = asset_id;
query.parent_id = id;
query.statuses = ['NONE', 'ACCEPTED'];
return Comments.getByQuery(query);
},
+8 -3
View File
@@ -1,7 +1,12 @@
const RootMutation = {
createComment: async (_, {input}, {mutators: {Comment}}) => ({
comment: await Comment.create(input),
}),
createComment: async (_, {input}, {mutators: {Comment}, loaders: {Actions}}) => {
const comment = await Comment.create(input);
// Retrieve actions that was assigned to comment.
const actions = await Actions.getByID.load(comment.id);
return {comment, actions};
},
editComment: async (_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) => ({
comment: await Comment.edit({id, asset_id, edit: {body}}),
}),
+8
View File
@@ -50,6 +50,14 @@ const RootQuery = {
return Comments.getCountByQuery(query);
},
async userCount(_, {query}, {user, loaders: {Users}}) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
return Users.getCountByQuery(query);
},
assetMetrics(_, query, {user, loaders: {Metrics: {Assets}}}) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
+27 -6
View File
@@ -261,11 +261,12 @@ enum ACTION_TYPE {
# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
# Author of the comments
# Author of the commente
author_id: ID
# Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!]
# Current status of a comment.
# This field is restricted.
statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]
# Asset that a comment is on.
asset_id: ID
@@ -317,8 +318,9 @@ input RepliesQuery {
# methods.
input CommentCountQuery {
# Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!]
# Current status of a comment.
# This field is restricted.
statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]
# Asset that a comment is on.
asset_id: ID
@@ -341,6 +343,18 @@ input CommentCountQuery {
tags: [String!]
}
# UserCountQuery allows the ability to query user counts by specific
# methods.
input UserCountQuery {
# comments returned will only be ones which have at least one action of this
# type.
action_type: ACTION_TYPE
# Current status of a user.
statuses: [USER_STATUS]
}
type EditInfo {
edited: Boolean!
editableUntil: Date
@@ -688,7 +702,7 @@ type Asset {
# The comments that are attached to the asset. When `deep` is true, the
# comments returned will be at all depths.
comments(query: CommentsQuery = {}, deep: Boolean = false): CommentConnection!
comments(query: CommentsQuery = {}, deep: Boolean = false): CommentConnection
# A Comment from the Asset by comment's ID
comment(id: ID!): Comment
@@ -838,6 +852,10 @@ type RootQuery {
# expensive as it is not batched. Requires the `ADMIN` role.
commentCount(query: CommentCountQuery!): Int
# Return the count of users satisfied by the query. Note that this edge is
# expensive as it is not batched. This field is restricted.
userCount(query: UserCountQuery!): Int
# The currently logged in user based on the request. Requires any logged in
# role.
me: User
@@ -875,6 +893,9 @@ type CreateCommentResponse implements Response {
# The comment that was created.
comment: Comment
# Actions that was assigned during creation of the comment.
actions: [Action]
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
+1 -1
View File
@@ -313,7 +313,6 @@ en:
report_notif_remove: "Your report has been removed."
reported: Reported
settings:
all_comments: "All Comments"
from_settings_page: "From the Profile Page you can see your comment history."
my_comment_history: "My comment History"
profile: Profile
@@ -322,6 +321,7 @@ en:
to_access: "to access Profile"
user_no_comment: "You've never left a comment. Join the conversation!"
stream:
all_comments: "All Comments"
temporarily_suspended: "In accordance with {0}'s community guidelines, your account has been temporarily suspended. Please rejoin the conversation {1}."
comment_not_found: "Comment was not found"
step_1_header: "Report an issue"
+1 -1
View File
@@ -305,7 +305,6 @@ es:
report_notif_remove: "Tu reporte ha sido eliminado."
reported: "Reportado"
settings:
all_comments: "Todos los comentarios"
from_settings_page: "Desde la página de configuración puedes ver tu historial de comentarios."
my_comment_history: "Mi historial de comentarios"
profile: Perfil
@@ -348,6 +347,7 @@ es:
step_2_header: "Ayúdanos a comprender"
step_3_header: "Gracias por tu participación"
stream:
all_comments: "Todos los comentarios"
temporarily_suspended: "De acuerdo con la guía de la comunidad de {0}, su cuenta ha sido temporalmente suspendida. Por favor unirse a la conversación {1}."
comment_not_found: "Comentario no encontrado"
streams:
+1 -1
View File
@@ -263,7 +263,6 @@ fr:
reported: Signalé
set_best: "Sélectionner comme le meilleur"
settings:
all_comments: "Tous les commentaires"
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
my_comment_history: "Mon historique de commentaires"
profile: Profil
@@ -272,6 +271,7 @@ fr:
to_access: "Accéder au profil"
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation!"
stream:
all_comments: "Tous les commentaires"
temporarily_suspended: "Conformément à la charte d'utilisation des commentaires de {0}, votre compte a été temporairement suspendu. Merci de revenir dans la conversation {1}."
step_1_header: "Signaler un problème"
step_2_header: "Aidez-nous à comprendre"
+2 -3
View File
@@ -303,7 +303,6 @@ pt_BR:
report_notif_remove: "Seu marcado foi removido."
reported: Reportado
settings:
all_comments: "Todos os comentários"
from_settings_page: "Na página de Perfil, você pode ver seu histórico de comentários."
my_comment_history: "Histórico de comentários"
profile: Perfil
@@ -312,8 +311,8 @@ pt_BR:
to_access: "para acessar o perfil"
user_no_comment: "Você nunca deixou um comentário. Participe da conversa!"
stream:
temporarily_suspended: "
De acordo com as diretrizes da comunidade de {0}, sua conta foi temporariamente suspensa. Por favor, volte para a conversa {1}."
all_comments: "Todos os comentários"
temporarily_suspended: "De acordo com as diretrizes da comunidade de {0}, sua conta foi temporariamente suspensa. Por favor, volte para a conversa {1}."
step_1_header: "Relatar um problema"
step_2_header: "Ajude-nos a entender"
step_3_header: "Obrigdo por sua contribuição"
+2 -1
View File
@@ -20,6 +20,7 @@
"talk-plugin-sort-most-replied",
"talk-plugin-author-menu",
"talk-plugin-member-since",
"talk-plugin-ignore-user"
"talk-plugin-ignore-user",
"talk-plugin-moderation-actions"
]
}
@@ -1,4 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {t} from 'plugin-api/beta/client/services';
/**
* CheckToxicityHook adds hooks to the `commentBox`
@@ -20,7 +22,11 @@ export default class CheckToxicityHook extends React.Component {
}
});
this.toxicityPostHook = this.props.registerHook('postSubmit', () => {
this.toxicityPostHook = this.props.registerHook('postSubmit', (result) => {
const actions = result.createComment.actions;
if (actions && actions.some(({__typename, reason}) => __typename === 'FlagAction' && reason === 'TOXIC_COMMENT')) {
this.props.notify('error', t('talk-plugin-toxic-comments.still_toxic'));
}
// Reset `checked` after comment was successfully posted.
this.checked = false;
@@ -36,3 +42,9 @@ export default class CheckToxicityHook extends React.Component {
return null;
}
}
CheckToxicityHook.propTypes = {
notify: PropTypes.func.isRequired,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
};
@@ -0,0 +1,8 @@
import React from 'react';
import {FlagLabel} from 'plugin-api/beta/client/components/ui';
const ToxicLabel = () => (
<FlagLabel iconName="add_box">Toxic</FlagLabel>
);
export default ToxicLabel;
@@ -0,0 +1,9 @@
import {bindActionCreators} from 'redux';
import {connect} from 'plugin-api/beta/client/hocs';
import {notify} from 'plugin-api/beta/client/actions/notification';
import CheckToxicityHook from '../components/CheckToxicityHook';
const mapDispatchToProps = (dispatch) =>
bindActionCreators({notify}, dispatch);
export default connect(null, mapDispatchToProps)(CheckToxicityHook);
@@ -0,0 +1,24 @@
import {compose, gql} from 'react-apollo';
import {withFragments, excludeIf} from 'plugin-api/beta/client/hocs';
import ToxicLabel from '../components/ToxicLabel';
function isToxic(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'TOXIC_COMMENT');
}
const enhance = compose(
withFragments({
comment: gql`
fragment TalkToxicComments_Comment on Comment {
actions {
__typename
... on FlagAction {
reason
}
}
}`,
}),
excludeIf(({comment: {actions}}) => !isToxic(actions)),
);
export default enhance(ToxicLabel);
@@ -1,9 +1,11 @@
import translations from './translations.yml';
import CheckToxicityHook from './components/CheckToxicityHook';
import CheckToxicityHook from './containers/CheckToxicityHook';
import ToxicLabel from './containers/ToxicLabel';
export default {
translations,
slots: {
commentInputDetailArea: [CheckToxicityHook],
adminCommentLabels: [ToxicLabel],
},
};
@@ -3,4 +3,8 @@ en:
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.
talk-plugin-toxic-comments:
still_toxic: |
This edited comment might still violate our community guidelines.
Our moderation team will review your comment shortly.
es: