mirror of
https://github.com/wassname/talk.git
synced 2026-07-01 11:10:55 +08:00
Make UserDetail work independently
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import Linkify from 'react-linkify';
|
||||
const linkify = new Linkify();
|
||||
|
||||
export default ({suspectWords, bannedWords, body, ...rest}) => {
|
||||
|
||||
const links = linkify.getMatches(body);
|
||||
const linkText = links ? links.map((link) => link.raw) : [];
|
||||
|
||||
// since words are checked against word boundaries on the backend,
|
||||
// should be the behavior on the front end as well.
|
||||
// currently the highlighter plugin does not support out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter((w) => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
{...rest}
|
||||
searchWords={searchWords}
|
||||
textToHighlight={body}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import Linkify from 'react-linkify';
|
||||
const linkify = new Linkify();
|
||||
|
||||
export default ({text, children}) => {
|
||||
const hasLinks = !!linkify.getMatches(text);
|
||||
|
||||
if (!hasLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.Children.only(children);
|
||||
};
|
||||
@@ -192,7 +192,6 @@
|
||||
.minimal {
|
||||
width: 45px;
|
||||
min-width: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.approve__active {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
} from 'coral-admin/src/actions/moderation';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import Comment from './Comment';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
`;
|
||||
|
||||
const slots = [
|
||||
'userProfile',
|
||||
];
|
||||
|
||||
class UserDetailContainer extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
this.props.data.refetch(); // some comments may have moved out of this tab
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
}
|
||||
|
||||
bulkReject = () => {
|
||||
this.bulkSetCommentStatus('REJECTED');
|
||||
}
|
||||
|
||||
bulkAccept = () => {
|
||||
this.bulkSetCommentStatus('ACCEPTED');
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!('user' in this.props.root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UserDetail
|
||||
bulkReject={this.bulkReject}
|
||||
bulkAccept={this.bulkAccept}
|
||||
changeStatus={this.props.changeUserDetailStatuses}
|
||||
toggleSelect={this.props.toggleSelectCommentInUserDetail}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const withUserDetailQuery = withQuery(gql`
|
||||
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
user(id: $author_id) {
|
||||
id
|
||||
username
|
||||
created_at
|
||||
profiles {
|
||||
id
|
||||
provider
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'user')}
|
||||
}
|
||||
totalComments: commentCount(query: {author_id: $author_id})
|
||||
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
|
||||
comments: comments(query: {
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
|
||||
return {
|
||||
variables: {author_id: id, statuses}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
}, dispatch)
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withUserDetailQuery,
|
||||
withSetCommentStatus,
|
||||
)(UserDetailContainer);
|
||||
@@ -1,22 +1,20 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import FlagBox from './FlagBox';
|
||||
import styles from './styles.css';
|
||||
import CommentType from './CommentType';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import CommentAnimatedEdit from './CommentAnimatedEdit';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
|
||||
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 {murmur3} from 'murmurhash-js';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
const linkify = new Linkify();
|
||||
import {getCommentType} from 'coral-admin/src/utils/comment';
|
||||
|
||||
import t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
@@ -29,41 +27,16 @@ class Comment extends React.Component {
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
minimal,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map((link) => link.raw) : [];
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions =
|
||||
comment.actions &&
|
||||
comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (flagActions && flagActions.length) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
const commentType = getCommentType(comment);
|
||||
|
||||
// since words are checked against word boundaries on the backend,
|
||||
// should be the behavior on the front end as well.
|
||||
// currently the highlighter plugin does not support out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter((w) => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(comment.body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
let selectionStateCSS;
|
||||
if (minimal) {
|
||||
selectionStateCSS = selected ? styles.minimalSelection : '';
|
||||
} else {
|
||||
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
}
|
||||
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
|
||||
const showSuspenUserDialog = () => props.showSuspendUserDialog({
|
||||
userId: comment.user.id,
|
||||
@@ -81,31 +54,21 @@ class Comment extends React.Component {
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal]: minimal, [styles.selected]: selected})}
|
||||
tabIndex={0}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.selected]: selected})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
{
|
||||
!minimal && (
|
||||
(
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.username}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
{timeago(comment.created_at)}
|
||||
</span>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
@@ -125,45 +88,27 @@ class Comment extends React.Component {
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
<CommentType type={commentType} className={styles.commentType}/>
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{t('comment.banned_user')}
|
||||
</span>
|
||||
: null}
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<CSSTransitionGroup
|
||||
component={'div'}
|
||||
style={{position: 'relative'}}
|
||||
transitionName={{
|
||||
enter: styles.bodyEnter,
|
||||
enterActive: styles.bodyEnterActive,
|
||||
leave: styles.bodyLeave,
|
||||
leaveActive: styles.bodyLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={3600}
|
||||
transitionLeaveTimeout={2800}
|
||||
>
|
||||
<div className={styles.itemBody} key={murmur3(comment.body)}>
|
||||
<CommentAnimatedEdit body={comment.body}>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
body={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
@@ -181,11 +126,11 @@ class Comment extends React.Component {
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
: null}
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
@@ -193,7 +138,6 @@ class Comment extends React.Component {
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
@@ -219,7 +163,7 @@ class Comment extends React.Component {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransitionGroup>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
@@ -240,7 +184,6 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
Comment.propTypes = {
|
||||
minimal: PropTypes.bool,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
@@ -251,7 +194,6 @@ Comment.propTypes = {
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
toggleSelect: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import {murmur3} from 'murmurhash-js';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
import styles from './styles.css';
|
||||
|
||||
export default ({children, body}) => {
|
||||
return (
|
||||
<CSSTransitionGroup
|
||||
component={'div'}
|
||||
transitionName={{
|
||||
enter: styles.bodyEnter,
|
||||
enterActive: styles.bodyEnterActive,
|
||||
leave: styles.bodyLeave,
|
||||
leaveActive: styles.bodyLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={3600}
|
||||
transitionLeaveTimeout={2800}
|
||||
>
|
||||
{React.cloneElement(React.Children.only(children), {key: murmur3(body)})}
|
||||
</CSSTransitionGroup>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,19 @@
|
||||
.commentType {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 11px;
|
||||
display: inline-block;
|
||||
color: white;
|
||||
background: grey;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
line-height: 29px;
|
||||
padding: 2px 8px 2px 26px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
i {
|
||||
> i {
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 8px;
|
||||
vertical-align: text-top;
|
||||
margin: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.premod {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
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={`${styles.commentType} ${styles[typeData.className]}`}>
|
||||
<span className={cn(styles.commentType, styles[typeData.className], props.className)}>
|
||||
<Icon name={typeData.icon}/>{typeData.text}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -176,13 +176,7 @@ export default class Moderation extends Component {
|
||||
{moderation.userDetailId && (
|
||||
<UserDetail
|
||||
id={moderation.userDetailId}
|
||||
hideUserDetail={hideUserDetail}
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment} />
|
||||
/>
|
||||
)}
|
||||
|
||||
<StorySearch
|
||||
|
||||
@@ -85,7 +85,6 @@ class ModerationQueue extends React.Component {
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={comment.id}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import Comment from './UserDetailComment';
|
||||
import styles from './UserDetail.css';
|
||||
import {Button, Drawer} from 'coral-ui';
|
||||
import {Slot} from 'coral-framework/components';
|
||||
@@ -15,8 +15,6 @@ export default class UserDetail extends React.Component {
|
||||
root: PropTypes.object.isRequired,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
showSuspendUserDialog: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
changeStatus: PropTypes.func.isRequired,
|
||||
@@ -53,18 +51,15 @@ export default class UserDetail extends React.Component {
|
||||
rejectedComments,
|
||||
comments: {nodes}
|
||||
},
|
||||
moderation: {
|
||||
userDetailActiveTab: tab,
|
||||
userDetailSelectedIds: selectedIds
|
||||
},
|
||||
activeTab,
|
||||
selectedIds,
|
||||
bannedWords,
|
||||
suspectWords,
|
||||
toggleSelect,
|
||||
bulkAccept,
|
||||
bulkReject,
|
||||
showBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideUserDetail
|
||||
hideUserDetail,
|
||||
viewUserDetail,
|
||||
} = this.props;
|
||||
const localProfile = user.profiles.find((p) => p.provider === 'local');
|
||||
|
||||
@@ -116,8 +111,8 @@ export default class UserDetail extends React.Component {
|
||||
selectedIds.length === 0
|
||||
? (
|
||||
<ul className={styles.commentStatuses}>
|
||||
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
|
||||
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
|
||||
</ul>
|
||||
)
|
||||
: (
|
||||
@@ -141,27 +136,23 @@ export default class UserDetail extends React.Component {
|
||||
|
||||
<div>
|
||||
{
|
||||
nodes.map((comment, i) => {
|
||||
nodes.map((comment) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
const selected = selectedIds.indexOf(comment.id) !== -1;
|
||||
return <Comment
|
||||
key={comment.id}
|
||||
index={i}
|
||||
user={user}
|
||||
comment={comment}
|
||||
selected={false}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
viewUserDetail={() => {}}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
showSuspendUserDialog={showSuspendUserDialog}
|
||||
acceptComment={this.acceptThenReload}
|
||||
rejectComment={this.rejectThenReload}
|
||||
selected={selected}
|
||||
toggleSelect={toggleSelect}
|
||||
currentAsset={null}
|
||||
currentUserId={this.props.id}
|
||||
minimal={true} />;
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
.root {
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
width: 100%;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rootSelected {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0px 14px;
|
||||
}
|
||||
|
||||
.story {
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.story > a {
|
||||
display: inline-block;
|
||||
color: #063b9a;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: .5px;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
margin-left: 5px;
|
||||
padding-bottom: 0px;
|
||||
border-bottom: solid 1px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.bodyContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-weight: 300;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.commentType {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.created {
|
||||
padding: 5px;
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
font-size: 16px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: .7em;
|
||||
text-decoration: none;
|
||||
color: #063b9a;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
> i {
|
||||
font-size: 12px;
|
||||
top: 2px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import FlagBox from './FlagBox';
|
||||
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 t, {timeago} from 'coral-framework/services/i18n';
|
||||
|
||||
class UserDetailComment extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
actions = [],
|
||||
comment,
|
||||
viewUserDetail,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
user,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
|
||||
const commentType = getCommentType(comment);
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at)}
|
||||
</span>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
<CommentType type={commentType} className={styles.commentType}/>
|
||||
</div>
|
||||
<div className={styles.story}>
|
||||
Story: {comment.asset.title}
|
||||
{<Link to={`/admin/moderate/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<CommentAnimatedEdit body={comment.body}>
|
||||
<div className={styles.bodyContainer}>
|
||||
<p className={styles.body}>
|
||||
<CommentBodyHighlighter
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
body={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}?commentId=${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<div className={styles.sideActions}>
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
<div className={styles.actions}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={true}
|
||||
key={i}
|
||||
type={action}
|
||||
user={user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
viewUserDetail={viewUserDetail}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserDetailComment.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
toggleSelect: PropTypes.func,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
actions: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
export default UserDetailComment;
|
||||
@@ -213,11 +213,12 @@ span {
|
||||
|
||||
.author {
|
||||
font-weight: 300;
|
||||
min-width: 230px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #262626;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,18 +458,6 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
.minimal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minimalSelection {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emptyCardContainer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -528,3 +517,8 @@ span {
|
||||
position: relative;
|
||||
top: .3em;
|
||||
}
|
||||
|
||||
.commentType {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
@@ -5,24 +5,25 @@ import {bindActionCreators} from 'redux';
|
||||
import UserDetail from '../components/UserDetail';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {viewUserDetail, hideUserDetail} from 'actions/moderation';
|
||||
import {
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
toggleSelectCommentInUserDetail,
|
||||
} from 'coral-admin/src/actions/moderation';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import Comment from './Comment';
|
||||
import UserDetailComment from './UserDetailComment';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(Comment.fragments.comment)}
|
||||
...${getDefinitionName(UserDetailComment.fragments.comment)}
|
||||
}
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
${Comment.fragments.comment}
|
||||
${UserDetailComment.fragments.comment}
|
||||
`;
|
||||
|
||||
const slots = [
|
||||
@@ -32,12 +33,11 @@ const slots = [
|
||||
class UserDetailContainer extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
hideUserDetail: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
|
||||
const changes = this.props.selectedIds.map((commentId) => {
|
||||
return this.props.setCommentStatus({commentId, status});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,14 @@ class UserDetailContainer extends React.Component {
|
||||
this.bulkSetCommentStatus('ACCEPTED');
|
||||
}
|
||||
|
||||
acceptComment = ({commentId}) => {
|
||||
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
|
||||
}
|
||||
|
||||
rejectComment = ({commentId}) => {
|
||||
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!('user' in this.props.root)) {
|
||||
return null;
|
||||
@@ -65,6 +73,8 @@ class UserDetailContainer extends React.Component {
|
||||
bulkAccept={this.bulkAccept}
|
||||
changeStatus={this.props.changeUserDetailStatuses}
|
||||
toggleSelect={this.props.toggleSelectCommentInUserDetail}
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
@@ -93,7 +103,7 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
|
||||
options: ({id, statuses}) => {
|
||||
return {
|
||||
variables: {author_id: id, statuses}
|
||||
};
|
||||
@@ -101,14 +111,20 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
moderation: state.moderation.toJS()
|
||||
selectedIds: state.moderation.toJS().userDetailSelectedIds,
|
||||
statuses: state.moderation.toJS().userDetailStatuses,
|
||||
activeTab: state.moderation.toJS().userDetailActiveTab,
|
||||
bannedWords: state.settings.toJS().wordlist.banned,
|
||||
suspectWords: state.settings.toJS().wordlist.suspect,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
...bindActionCreators({
|
||||
changeUserDetailStatuses,
|
||||
clearUserDetailSelections,
|
||||
toggleSelectCommentInUserDetail
|
||||
toggleSelectCommentInUserDetail,
|
||||
viewUserDetail,
|
||||
hideUserDetail,
|
||||
}, dispatch)
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import UserDetailComment from '../components/UserDetailComment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
|
||||
export default withFragments({
|
||||
comment: gql`
|
||||
fragment CoralAdmin_UserDetail_comment on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
actions {
|
||||
... on FlagAction {
|
||||
id
|
||||
reason
|
||||
message
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
}
|
||||
}
|
||||
`
|
||||
})(UserDetailComment);
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user