Merge pull request #937 from coralproject/reject-instream

In-Stream Moderation - Accept, Reject and Feature!
This commit is contained in:
Kim Gardner
2017-09-11 11:39:11 +01:00
committed by GitHub
33 changed files with 599 additions and 59 deletions
+1
View File
@@ -22,6 +22,7 @@ plugins/*
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
node_modules
+1
View File
@@ -39,6 +39,7 @@ plugins/*
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
**/node_modules/*
@@ -18,7 +18,7 @@ import {getEditableUntilDate} from './util';
import {findCommentWithId} from '../graphql/utils';
import CommentContent from './CommentContent';
import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import CommentTombstone from './CommentTombstone';
import InactiveCommentLabel from './InactiveCommentLabel';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive, getShallowChanges} from 'coral-framework/utils';
@@ -209,6 +209,10 @@ export default class Comment extends React.Component {
}
}
commentIsRejected(comment) {
return comment.status === 'REJECTED';
}
commentIsIgnored(comment) {
const me = this.props.root.me;
return (
@@ -337,9 +341,13 @@ export default class Comment extends React.Component {
emit,
commentClassNames = []
} = this.props;
if (this.commentIsRejected(comment)) {
return <CommentTombstone action='reject' />;
}
if (this.commentIsIgnored(comment)) {
return <IgnoredCommentTombstone />;
return <CommentTombstone action='ignore' />;
}
const view = this.getVisibileReplies();
@@ -2,8 +2,21 @@ import React from 'react';
import t from 'coral-framework/services/i18n';
// Render in place of a Comment when the author of the comment is ignored
class IgnoredCommentTombstone extends React.Component {
// Render in place of a Comment when the author of the comment is <action>
class CommentTombstone extends React.Component {
getCopy() {
const {action} = this.props;
switch (action) {
case 'ignore':
return t('framework.comment_is_ignored');
case 'reject':
return t('framework.comment_is_rejected');
default :
return t('framework.comment_is_hidden');
}
}
render() {
return (
<div>
@@ -14,11 +27,11 @@ class IgnoredCommentTombstone extends React.Component {
padding: '1em',
color: '#3E4F71',
}}>
{t('framework.comment_is_ignored')}
{this.getCopy()}
</p>
</div>
);
}
}
export default IgnoredCommentTombstone;
export default CommentTombstone;
@@ -127,6 +127,27 @@ export const withSetCommentStatus = withMutation(
commentId,
status,
},
optimisticResponse: {
setCommentStatus: {
__typename: 'SetCommentStatusResponse',
errors: null,
}
},
update: (proxy) => {
const fragment = gql`
fragment Talk_SetCommentStatus on Comment {
status
}`;
const fragmentId = `Comment_${commentId}`;
const data = proxy.readFragment({fragment, id: fragmentId});
data.status = status;
proxy.writeFragment({fragment, id: fragmentId, data});
}
});
}
})
+2
View File
@@ -221,6 +221,8 @@ en:
banned_account_body: "This means that you cannot Like, Report, or write comments."
comment: comment
comment_is_ignored: "This comment is hidden because you ignored this user."
comment_is_rejected: "You have rejected this comment."
comment_is_hidden: "This comment is not available."
comments: comments
configure_stream: "Configure"
content_not_available: "This content is not available"
+2
View File
@@ -219,6 +219,8 @@ es:
banned_account_body: "Esto significa que no puedes gustar, marcar o escribir comentarios."
comment: "comentario"
comment_is_ignored: "Este comentario está escondido porque has ignorado al usuario."
comment_is_rejected: "Has rechazado este comentario."
comment_is_hidden: "Este comentario no está disponible."
comments: "comentarios"
configure_stream: "Configurar Hilo de Comentarios"
content_not_available: "Este contenido no se encuentra disponible"
+1
View File
@@ -8,4 +8,5 @@ export {default as withEmit} from 'coral-framework/hocs/withEmit';
export {
withIgnoreUser,
withStopIgnoringUser,
withSetCommentStatus,
} from 'coral-framework/graphql/mutations';
@@ -14,4 +14,4 @@
.icon {
font-size: 18px;
vertical-align: top;
}
}
@@ -24,4 +24,3 @@ const Button = (props) => {
};
export default withTags('featured')(Button);
@@ -5,6 +5,7 @@ import {t, timeago} from 'plugin-api/beta/client/services';
import {Slot, CommentAuthorName} from 'plugin-api/beta/client/components';
import {Icon} from 'plugin-api/beta/client/components/ui';
import {pluginName} from '../../package.json';
import Button from './Button';
class Comment extends React.Component {
@@ -48,6 +49,13 @@ class Comment extends React.Component {
asset={asset}
inline
/>
<Button
root={root}
data={data}
comment={comment}
asset={asset}
/>
</div>
<div className={cn(styles.actionsContainer, `${pluginName}-comment-actions`)}>
<button className={cn(styles.goTo, `${pluginName}-comment-go-to`)} onClick={this.viewComment}>
@@ -0,0 +1,31 @@
.button {
composes: buttonReset from "coral-framework/styles/reset.css";
padding: 6px;
font-size: 14px;
transition: color 100ms, background-color 100ms;
border-radius: 3px;
color: #383A43;
width: 100%;
text-align: left;
letter-spacing: 0.3px;
&:hover {
background-color: #D8D8D8;
color: #383a43;
}
}
.icon {
margin-right: 15px;
font-size: 16px;
}
.button.featured {
color: #10589b;
font-weight: bold;
&:hover {
background-color: #D8D8D8;
color: #383a43;
}
}
@@ -0,0 +1,60 @@
import React from 'react';
import cn from 'classnames';
import styles from './ModActionButton.css';
import {pluginName} from '../../package.json';
import {t} from 'plugin-api/beta/client/services';
import {withTags} from 'plugin-api/beta/client/hocs';
import {Icon} from 'plugin-api/beta/client/components/ui';
export class Button extends React.Component {
constructor() {
super();
this.state = {
on: false
};
}
handleMouseEnter = (e) => {
e.preventDefault();
this.setState({
on: true
});
}
handleMouseLeave = (e) => {
e.preventDefault();
this.setState({
on: false
});
}
render() {
const {alreadyTagged, deleteTag, postTag} = this.props;
return (
<button className={cn(`${pluginName}-tag-button`, styles.button, {[styles.featured] : alreadyTagged})}
onClick={alreadyTagged ? deleteTag : postTag}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} >
{alreadyTagged ? (
<span className={styles.approved}>
<Icon name="star" className={styles.icon} />
{!this.state.on ? t('talk-plugin-featured-comments.featured') : t('talk-plugin-featured-comments.un_feature')}
</span>
) : (
<span>
<Icon name="star_border" className={styles.icon} />
{t('talk-plugin-featured-comments.feature')}
</span>
)}
</button>
);
}
}
export default withTags('featured')(Button);
@@ -34,9 +34,9 @@
}
.tooltip {
top: 36px;
top: 33px;
left: auto;
right: 10px;
right: 5px;
}
.tooltip::before{
@@ -48,4 +48,8 @@
left: auto;
right: 16px;
top: -20px;
}
.tagContainer {
position: relative;
}
@@ -3,7 +3,6 @@ import cn from 'classnames';
import styles from './Tag.css';
import Tooltip from './Tooltip';
import {t} from 'plugin-api/beta/client/services';
import {isTagged} from 'plugin-api/beta/client/utils';
export default class Tag extends React.Component {
constructor() {
@@ -32,19 +31,14 @@ export default class Tag extends React.Component {
render() {
const {tooltip} = this.state;
return(
<div className={styles.noSelect} onMouseEnter={this.showTooltip}
<span className={cn(styles.tagContainer, styles.noSelect)} onMouseEnter={this.showTooltip}
onMouseLeave={this.hideTooltip} onTouchStart={this.showTooltip}
onTouchEnd={this.hideTooltip}>
{
isTagged(this.props.comment.tags, 'FEATURED') ? (
<span
className={cn(styles.tag, styles.noSelect, {[styles.on]: tooltip})}>
{t('talk-plugin-featured-comments.featured')}
</span>
) : null
}
onTouchEnd={this.hideTooltip} >
<span className={cn(styles.tag, styles.noSelect, {[styles.on]: tooltip})}>
{t('talk-plugin-featured-comments.featured')}
</span>
{tooltip && <Tooltip className={styles.tooltip} />}
</div>
</span>
);
}
}
@@ -0,0 +1,10 @@
import {compose} from 'react-apollo';
import {excludeIf} from 'plugin-api/beta/client/hocs';
import {can} from 'plugin-api/beta/client/services';
import Button from '../components/Button';
const enhance = compose(
excludeIf((props) => !can(props.user, 'MODERATE_COMMENTS')),
);
export default enhance(Button);
@@ -1,15 +1,18 @@
import {gql} from 'react-apollo';
import {compose, gql} from 'react-apollo';
import Tag from '../components/Tag';
import {withFragments} from 'plugin-api/beta/client/hocs';
import {isTagged} from 'plugin-api/beta/client/utils';
import {withFragments, excludeIf} from 'plugin-api/beta/client/hocs';
export default withFragments({
comment: gql`
fragment TalkFeaturedComments_Tag_comment on Comment {
tags {
tag {
name
export default compose(
withFragments({
comment: gql`
fragment TalkFeaturedComments_Tag_comment on Comment {
tags {
tag {
name
}
}
}
}
`
})(Tag);
`}),
excludeIf((props) => !isTagged(props.comment.tags, 'FEATURED'))
)(Tag);
@@ -1,6 +1,6 @@
import Tab from './containers/Tab';
import Tag from './containers/Tag';
import Button from './components/Button';
import ModActionButton from './components/ModActionButton';
import TabPane from './containers/TabPane';
import translations from './translations.yml';
import update from 'immutability-helper';
@@ -18,7 +18,7 @@ export default {
streamTabs: [Tab],
streamTabPanes: [TabPane],
commentInfoBar: [Tag],
commentReactions: [Button],
moderationActions: [ModActionButton],
adminModeration: [ModSubscription],
adminCommentInfoBar: [ModTag],
},
@@ -49,25 +49,39 @@ export default {
AddTag: ({variables}) => ({
updateQueries: {
CoralEmbedStream_Embed: (previous) => {
let updated = previous;
if (variables.name !== 'FEATURED') {
return;
}
const comment = findCommentInEmbedQuery(previous, variables.id);
const updated = update(previous, {
asset: {
featuredComments: {
nodes: {
$apply: (nodes) => prependNewNodes(nodes, [comment]),
}
},
featuredCommentsCount: {
$apply: (value) => value + 1
if (previous.asset.comments) {
updated = update(previous, {
asset: {
comments: {
nodes: {
$apply: (nodes) => nodes.map((node) => {
if (node.id === variables.id) {
node.status = 'ACCEPTED';
}
return node;
})
}
},
featuredComments: {
nodes: {
$apply: (nodes) => prependNewNodes(nodes, [comment]),
}
},
featuredCommentsCount: {
$apply: (value) => value + 1
},
}
}
});
});
}
return updated;
},
@@ -76,24 +90,27 @@ export default {
RemoveTag: ({variables}) => ({
updateQueries: {
CoralEmbedStream_Embed: (previous) => {
let updated = previous;
if (variables.name !== 'FEATURED') {
return;
}
const updated = update(previous, {
asset: {
featuredComments: {
nodes: {
$apply: (nodes) =>
nodes.filter((n) => n.id !== variables.id)
if (previous.asset.comments) {
updated = update(previous, {
asset: {
featuredComments: {
nodes: {
$apply: (nodes) =>
nodes.filter((n) => n.id !== variables.id)
}
},
featuredCommentsCount: {
$apply: (value) => value - 1
}
},
featuredCommentsCount: {
$apply: (value) => value - 1
}
}
});
});
}
return updated;
},
@@ -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,23 @@
import React from 'react';
import styles from './styles.css';
import {t} from 'plugin-api/beta/client/services';
import {Icon} from 'plugin-api/beta/client/components/ui';
import cn from 'classnames';
const isApproved = (status) => (status === 'ACCEPTED');
export default ({approveComment, comment: {status}}) => (
<button className={cn(styles.button, 'talk-plugin-moderation-actions-reject')} onClick={approveComment}>
{isApproved(status) ? (
<span className={styles.approved}>
<Icon name="check_circle" className={styles.icon} />
{t('talk-plugin-moderation-actions.approved_comment')}
</span>
) : (
<span>
<Icon name="done" className={styles.icon} />
{t('talk-plugin-moderation-actions.approve_comment')}
</span>
)}
</button>
);
@@ -0,0 +1,22 @@
.moderationActions {
position: relative;
display: inline-block;
padding: 0 4px;
}
.arrow {
-ms-user-select:none;
-moz-user-select: none;
-webkit-user-select: none;
-webkit-touch-callout:none;
user-select: none;
-webkit-tap-highlight-color:rgba(0,0,0,0);
}
.arrow:hover {
cursor: pointer;
}
.icon {
font-size: 16px;
}
@@ -0,0 +1,62 @@
import React from 'react';
import cn from 'classnames';
import Tooltip from './Tooltip';
import styles from './ModerationActions.css';
import {Icon} from 'plugin-api/beta/client/components/ui';
import ClickOutside from 'coral-framework/components/ClickOutside';
import RejectCommentAction from '../containers/RejectCommentAction';
import ApproveCommentAction from '../containers/ApproveCommentAction';
import {Slot} from 'plugin-api/beta/client/components';
export default class ModerationActions extends React.Component {
constructor() {
super();
this.state = {
tooltip: false
};
}
toogleTooltip = () => {
const {tooltip} = this.state;
this.setState({
tooltip: !tooltip
});
}
hideTooltip = () => {
this.setState({
tooltip: false
});
}
render() {
const {tooltip} = this.state;
const {comment, asset, data} = this.props;
return(
<ClickOutside onClickOutside={this.hideTooltip}>
<div className={cn(styles.moderationActions, 'talk-plugin-moderation-actions')}>
<span onClick={this.toogleTooltip} className={cn(styles.arrow, 'talk-plugin-moderation-actions-arrow')}>
{tooltip ? <Icon name="keyboard_arrow_up" className={styles.icon} /> :
<Icon name="keyboard_arrow_down" className={styles.icon} />}
</span>
{tooltip && (
<Tooltip>
<Slot
className="talk-plugin-modetarion-actions-slot"
fill="moderationActions"
queryData={{comment, asset}}
data={data}
/>
<ApproveCommentAction comment={comment} />
<RejectCommentAction comment={comment} />
</Tooltip>
)}
</div>
</ClickOutside>
);
}
}
@@ -0,0 +1,12 @@
import React from 'react';
import cn from 'classnames';
import styles from './styles.css';
import {t} from 'plugin-api/beta/client/services';
import {Icon} from 'plugin-api/beta/client/components/ui';
export default ({rejectComment}) => (
<button className={cn(styles.button, 'talk-plugin-moderation-actions-reject')} onClick={rejectComment}>
<Icon name="clear" className={styles.icon} />
{t('talk-plugin-moderation-actions.reject_comment')}
</button>
);
@@ -0,0 +1,47 @@
.tooltip {
background-color: white;
border: solid 1px #999;
border-radius: 3px;
padding: 10px;
position: absolute;
-webkit-box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12), 0 3px 1px -2px rgba(0,0,0,0.2);
z-index: 10;
top: 32px;
right: 0px;
width: 140px;
text-align: left;
color: #616161;
}
.tooltip::before{
content: '';
border: 10px solid transparent;
border-top-color: #999;
position: absolute;
right: 0px;
top: -20px;
transform: rotate(180deg);
}
.tooltip::after{
content: '';
border: 10px solid transparent;
border-top-color: white;
position: absolute;
right: 0px;
top: -19px;
transform: rotate(180deg);
}
.headline {
color: #484747;
display: inline-block;
margin: 0;
padding: 0;
font-size: 1em;
vertical-align: middle;
margin-bottom: 4px;
line-height: 22px;
letter-spacing: 0.3px;
}
@@ -0,0 +1,13 @@
import React from 'react';
import cn from 'classnames';
import styles from './Tooltip.css';
import {t} from 'plugin-api/beta/client/services';
export default ({className = '', children}) => (
<div className={cn(styles.tooltip, className)}>
<h3 className={styles.headline}>
{t('talk-plugin-moderation-actions.moderation_actions')}
</h3>
{children}
</div>
);
@@ -0,0 +1,29 @@
.root {
white-space: nowrap;
}
.button {
composes: buttonReset from "coral-framework/styles/reset.css";
padding: 6px;
font-size: 14px;
transition: color 100ms, background-color 100ms;
border-radius: 3px;
color: #383A43;
width: 100%;
text-align: left;
letter-spacing: 0.3px;
&:hover {
background-color: #D8D8D8;
}
}
.icon {
margin-right: 15px;
font-size: 16px;
}
.approved {
color: #519954;
font-weight: bold;
}
@@ -0,0 +1,33 @@
import React from 'react';
import {getErrorMessages} from 'plugin-api/beta/client/utils';
import {withSetCommentStatus} from 'plugin-api/beta/client/hocs';
import {notify} from 'plugin-api/beta/client/actions/notification';
import ApproveCommentAction from '../components/ApproveCommentAction';
import isNil from 'lodash/isNil';
class ApproveCommentActionContainer extends React.Component {
approveComment = async () => {
const {setCommentStatus, comment} = this.props;
try {
const result = await setCommentStatus({
commentId: comment.id,
status: 'ACCEPTED'
});
if (!isNil(result.data.setCommentStatus)) {
throw result.data.setCommentStatus.errors;
}
} catch (err) {
notify('error', getErrorMessages(err));
}
}
render() {
return <ApproveCommentAction comment={this.props.comment} approveComment={this.approveComment}/>;
}
}
export default withSetCommentStatus(ApproveCommentActionContainer);
@@ -0,0 +1,32 @@
import {gql, compose} from 'react-apollo';
import {can} from 'plugin-api/beta/client/services';
import ModerationActions from '../components/ModerationActions';
import {connect, excludeIf, withFragments} from 'plugin-api/beta/client/hocs';
const mapStateToProps = ({auth}) => ({
user: auth.user
});
const enhance = compose(
connect(mapStateToProps),
withFragments({
asset: gql`
fragment TalkModerationActions_asset on Asset {
id
}`
,
comment: gql`
fragment TalkModerationActions_comment on Comment {
id
status
tags {
tag {
name
}
}
}
`}),
excludeIf((props) => !can(props.user, 'MODERATE_COMMENTS')),
);
export default enhance(ModerationActions);
@@ -0,0 +1,33 @@
import React from 'react';
import {getErrorMessages} from 'plugin-api/beta/client/utils';
import {withSetCommentStatus} from 'plugin-api/beta/client/hocs';
import {notify} from 'plugin-api/beta/client/actions/notification';
import RejectCommentAction from '../components/RejectCommentAction';
import isNil from 'lodash/isNil';
class RejectCommentActionContainer extends React.Component {
rejectComment = async () => {
const {setCommentStatus, comment} = this.props;
try {
const result = await setCommentStatus({
commentId: comment.id,
status: 'REJECTED'
});
if (!isNil(result.data.setCommentStatus)) {
throw result.data.setCommentStatus.errors;
}
} catch (err) {
notify('error', getErrorMessages(err));
}
}
render() {
return <RejectCommentAction rejectComment={this.rejectComment}/>;
}
}
export default withSetCommentStatus(RejectCommentActionContainer);
@@ -0,0 +1,9 @@
import ModerationActions from './containers/ModerationActions';
import translations from './translations.yml';
export default {
slots: {
commentInfoBar: [ModerationActions],
},
translations
};
@@ -0,0 +1,12 @@
en:
talk-plugin-moderation-actions:
reject_comment: "Reject"
approve_comment: "Approve"
approved_comment: "Approved"
moderation_actions: "Moderation Actions"
es:
talk-plugin-moderation-actions:
reject_comment: "Rechazar"
approve_comment: "Aprobar"
approved_comment: "Aprobado"
moderation_actions: "Acciones de Moderación"
@@ -0,0 +1 @@
module.exports = {};