-
-
-
- {
- props.activeTab === 'all' &&
-
-
-
-
- }
-
+const ModerationQueue = props => {
+ return (
+
+
{
- props.enablePremodTab
- ?
- {
- props.activeTab === 'premod' &&
-
-
-
-
- }
-
- : null
+ props.data[props.activeTab].map((comment, i) => {
+ return ;
+ })
}
-
-
- {
- props.activeTab === 'account' &&
-
-
-
-
- }
-
-
- {
- props.activeTab === 'flagged' &&
-
-
-
-
- }
-
-
- {
- props.activeTab === 'rejected' &&
-
-
-
-
- }
-
-
-
+
-
-);
+ );
+};
ModerationQueue.propTypes = {
- enablePremodTab: PropTypes.bool.isRequired
+ data: PropTypes.object.isRequired
};
export default ModerationQueue;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js
new file mode 100644
index 000000000..ae70b303a
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import timeago from 'timeago.js';
+import Linkify from 'react-linkify';
+import Highlighter from 'react-highlight-words';
+import {Link} from 'react-router';
+
+import styles from './styles.css';
+import {Icon} from 'coral-ui';
+import ActionButton from '../../../components/ActionButton';
+import FlagBox from './FlagBox';
+
+const linkify = new Linkify();
+
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from 'coral-admin/src/translations.json';
+const lang = new I18n(translations);
+
+const Comment = ({actions = [], ...props}) => {
+ const links = linkify.getMatches(props.comment.body);
+ const actionSumaries = props.comment.action_summaries;
+ return (
+
+
+
+
{props.comment.user.name}
+
+ {timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
+
+ {props.comment.action_summaries ?
{lang.t('comment.flagged')}
: null}
+
+
+ {links ?
Contains Link : null}
+
+ {actions.map((action, i) =>
+
props.acceptComment({commentId: props.comment.id})}
+ rejectComment={() => props.rejectComment({commentId: props.comment.id})}
+ showBanUserDialog={() => props.showBanUserDialog(props.comment.user, props.comment.id)}
+ />
+ )}
+
+ {props.comment.user.banned === 'banned' ?
+
+
+ {lang.t('comment.banned_user')}
+
+ : null}
+
+
+ {!props.currentAsset && (
+
+ Article: {props.comment.asset.title} Moderate Article
+
+ )}
+
+ {actionSumaries && }
+
+ {/* */}
+ {/* View context*/}
+ {/* */}
+
+ );
+};
+
+const linkStyles = {
+ backgroundColor: 'rgb(255, 219, 135)',
+ padding: '1px 2px'
+};
+
+export default Comment;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js
new file mode 100644
index 000000000..bf5a34f29
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/FlagBox.js
@@ -0,0 +1,19 @@
+import React, {PropTypes} from 'react';
+import styles from './styles.css';
+
+const FlagBox = props => (
+
+
Flags:
+
+ {props.actionSumaries.map((action, i) =>
+ - {!action.reason ? No reason provided : action.reason} ({action.count})
+ )}
+
+
+);
+
+FlagBox.propTypes = {
+ actionSumaries: PropTypes.array.isRequired
+};
+
+export default FlagBox;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
new file mode 100644
index 000000000..474d8fd27
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import {Link} from 'react-router';
+import styles from './styles.css';
+
+const ModerationHeader = props => (
+
+
+ {
+ props.asset ?
+
+ :
+
+ }
+
+
+);
+export default ModerationHeader;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js
new file mode 100644
index 000000000..51605c443
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js
@@ -0,0 +1,59 @@
+import React, {PropTypes} from 'react';
+import styles from './styles.css';
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from 'coral-admin/src/translations.json';
+
+const lang = new I18n(translations);
+
+const ModerationMenu = (props) => (
+
+);
+
+ModerationMenu.propTypes = {
+ activeTab: PropTypes.string.isRequired,
+ enablePremodTab: PropTypes.bool
+};
+
+export default ModerationMenu;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js
new file mode 100644
index 000000000..ffa1adfcd
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import {Link} from 'react-router';
+import styles from './styles.css';
+
+const NotFound = props => (
+
+
+ The provided asset id {props.assetId} does not exist.
+ Go to Streams
+
+
+);
+
+export default NotFound;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
new file mode 100644
index 000000000..658c9a059
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
@@ -0,0 +1,327 @@
+@custom-media --big-viewport (min-width: 780px);
+
+.listContainer {
+ max-width: 860px;
+ margin: 0 auto;
+}
+
+.tabBar {
+ background-color: rgba(44, 44, 44, 0.89);
+ z-index: 5;
+}
+
+.tab {
+ flex: 1;
+ color: white;
+ text-transform: capitalize;
+ font-weight: 500;
+ font-size: 15px;
+ letter-spacing: 1px;
+ transition: border-bottom 200ms;
+}
+
+.active {
+ color: white;
+ box-sizing: border-box;
+ border-bottom: solid 5px #F36451;
+}
+
+.active > span {
+ color: white;
+}
+
+.active:after {
+ background: transparent !important;
+}
+
+.showShortcuts {
+ position: absolute;
+ right: 130px;
+ display: flex;
+ align-items: center;
+ font-size: 13px;
+
+span {
+ margin-left: 7px;
+}
+}
+
+@media (--big-viewport) {
+ .tab {
+ flex: none;
+ }
+}
+
+.notFound {
+ position: relative;
+ margin: 20px auto;
+ text-align: center;
+ padding: 68px 45px;
+ vertical-align: middle;
+ min-width: 500px;
+
+ a {
+ color: rgb(244, 126, 107);
+ font-weight: 500;
+
+ &.goToStreams {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ }
+ }
+}
+
+.header {
+ background-color: #2c2c2c;
+ color: white;
+ margin-bottom: -1px;
+
+ .moderateAsset {
+ a {
+ -webkit-box-flex: 1;
+ -ms-flex: 1;
+ flex: 1;
+ color: white;
+ text-transform: capitalize;
+ font-weight: 500;
+ font-size: 15px;
+ letter-spacing: 1px;
+ transition: opacity 200ms;
+ opacity: 1;
+
+ &:hover {
+ opacity: .8;
+ cursor: pointer;
+ }
+
+ &:first-child {
+ text-align: left;
+ }
+
+ &:nth-child(2) {
+ text-align: center;
+ }
+
+ &:last-child {
+ text-align: right;
+ }
+ }
+ }
+}
+
+
+@custom-media --big-viewport (min-width: 780px);
+
+.list {
+ padding: 8px 0;
+ list-style: none;
+ display: block;
+
+ &.singleView .listItem {
+ display: none;
+ }
+
+ &.singleView .listItem.activeItem {
+ display: block;
+ height: 100%;
+ font-size: 1.5em;
+ line-height: 1.5em;
+ border: none;
+
+ .actions {
+ position: fixed;
+ bottom: 60px;
+ left: 25%;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-around;
+ width: 50%;
+ margin: 0;
+ }
+
+ .actionButton {
+ transform: scale(1.4);
+ }
+ }
+}
+
+.listItem {
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 16px;
+ width: 100%;
+ max-width: 660px;
+ min-width: 400px;
+ margin: 0 auto;
+ padding: 16px 14px;
+ position: relative;
+ transition: box-shadow 200ms;
+ margin-top: 0;
+
+
+ &:hover {
+ box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .context {
+ a {
+ color: #f36451;
+ text-decoration: underline;
+ float: right;
+ }
+ }
+
+ .sideActions {
+ position: absolute;
+ right: 0;
+ height: 100%;
+ top: 0;
+ padding: 40px 18px;
+ box-sizing: border-box;
+ }
+
+ .itemHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ .author {
+ min-width: 230px;
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .itemBody {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .avatar {
+ margin-right: 16px;
+ height: 40px;
+ width: 40px;
+ border-radius: 50%;
+ background-color: #757575;
+ font-size: 40px;
+ color: #fff;
+ }
+
+ .created {
+ color: #666;
+ font-size: 13px;
+ margin-left: 40px;
+ }
+
+ .actionButton {
+ transform: scale(.8);
+ margin: 0;
+ }
+
+ .body {
+ margin-top: 0px;
+ flex: 1;
+ font-size: 0.88em;
+ color: black;
+ max-width: 500px;
+ word-wrap: break-word;
+ }
+
+ .flagged {
+ color: rgba(255, 0, 0, .5);
+ padding-top: 15px;
+ padding-left: 10px;
+ }
+
+ .flagCount{
+ font-size: 12px;
+ color: #d32f2f;
+ }
+
+}
+
+.empty {
+ color: #444;
+ margin-top: 50px;
+ text-align: center;
+}
+
+
+@media (--big-viewport) {
+ .listItem {
+ border: 1px solid #e0e0e0;
+ margin-bottom: 30px;
+
+ &:last-child {
+ border-bottom: 1px solid #e0e0e0;
+ }
+
+ &.activeItem {
+ border: 2px solid #333;
+ }
+ }
+
+}
+
+.hasLinks {
+ color: #f00;
+ text-align: right;
+ display: flex;
+ align-items: center;
+
+ i {
+ margin-right: 5px;
+ }
+}
+
+.banned {
+ color: #f00;
+ text-align: left;
+ display: flex;
+ align-items: center;
+
+ i {
+ margin-right: 5px;
+ }
+}
+
+.ban {
+ display: block;
+ text-align: center;
+ margin-top: 5px;
+}
+
+.Comment {
+ .moderateArticle {
+ font-size: 12px;
+ a {
+ display: inline-block;
+ color: #679af3;
+ text-decoration: none;
+ font-size: 1em;
+ font-weight: 400;
+ letter-spacing: .5px;
+ font-size: 12px;
+ margin-left: 10px;
+
+ &:hover {
+ text-decoration: underline;
+ opacity: .9;
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+.flagBox {
+ border-top: 1px solid rgba(66, 66, 66, 0.12);
+ h3 {
+ font-size: 14px;
+ margin: 0;
+ font-weight: 500;
+ }
+}
diff --git a/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js
new file mode 100644
index 000000000..0da93e898
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/helpers/moderationQueueActionsMap.js
@@ -0,0 +1,13 @@
+export const actionsMap = {
+ PREMOD: ['REJECT', 'APPROVE', 'BAN'],
+ FLAGGED: ['REJECT', 'APPROVE'],
+ REJECTED: ['APPROVE']
+};
+
+export const menuActionsMap = {
+ 'REJECT': {status: 'REJECTED', icon: 'close', key: 'r'},
+ 'APPROVE': {status: 'ACCEPTED', icon: 'done', key: 't'},
+ 'FLAGGED': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
+ 'BAN': {status: 'BANNED', icon: 'not interested'},
+ '': {icon: 'done'}
+};
diff --git a/client/coral-admin/src/containers/Streams/Streams.css b/client/coral-admin/src/containers/Streams/Streams.css
index 26fb4f74f..d8a75a4ce 100644
--- a/client/coral-admin/src/containers/Streams/Streams.css
+++ b/client/coral-admin/src/containers/Streams/Streams.css
@@ -55,6 +55,12 @@
border-left: none;
border-right: none;
+ a {
+ color: rgb(44, 44, 44);
+ font-weight: 500;
+ text-decoration: none;
+ }
+
th {
font-size: 1.1em;
}
diff --git a/client/coral-admin/src/containers/Streams/Streams.js b/client/coral-admin/src/containers/Streams/Streams.js
index 1937f82c0..62f2f009e 100644
--- a/client/coral-admin/src/containers/Streams/Streams.js
+++ b/client/coral-admin/src/containers/Streams/Streams.js
@@ -4,14 +4,10 @@ import {connect} from 'react-redux';
import I18n from 'coral-framework/modules/i18n/i18n';
import {fetchAssets, updateAssetState} from '../../actions/assets';
import translations from '../../translations.json';
-import {
- RadioGroup,
- Radio,
- Icon,
- DataTable,
- TableHeader
-} from 'react-mdl';
-import Pager from 'coral-ui/components/Pager';
+import {Link} from 'react-router';
+
+import {Pager, Icon} from 'coral-ui';
+import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl';
const limit = 25;
@@ -74,6 +70,8 @@ class Streams extends Component {
}
}
+ renderTitle = (title, {id}) =>
{title}
+
renderStatus = (closedAt, {id}) => {
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
const statusMenuOpen = this.state.statusMenus[id];
@@ -104,6 +102,9 @@ class Streams extends Component {
render () {
const {search, sort, filter} = this.state;
const {assets} = this.props;
+
+ const assetsIds = assets.ids.map((id) => assets.byId[id]);
+
return (
@@ -142,16 +143,14 @@ class Streams extends Component {
-
assets.byId[id])}>
- {lang.t('streams.article')}
-
- {lang.t('streams.pubdate')}
-
-
- {lang.t('streams.status')}
-
+
+ {lang.t('streams.article')}
+
+ {lang.t('streams.pubdate')}
+
+
+ {lang.t('streams.status')}
+
{
assets: assets.toJS()
};
};
+
const mapDispatchToProps = (dispatch) => {
return {
fetchAssets: (...args) => {
diff --git a/client/coral-admin/src/graphql/fragments/commentView.graphql b/client/coral-admin/src/graphql/fragments/commentView.graphql
new file mode 100644
index 000000000..e78c28a28
--- /dev/null
+++ b/client/coral-admin/src/graphql/fragments/commentView.graphql
@@ -0,0 +1,15 @@
+fragment commentView on Comment {
+ id
+ body
+ created_at
+ status
+ user {
+ id
+ name: username
+ status
+ }
+ asset {
+ id
+ title
+ }
+}
diff --git a/client/coral-admin/src/graphql/mutations/index.js b/client/coral-admin/src/graphql/mutations/index.js
new file mode 100644
index 000000000..fe3a1faf9
--- /dev/null
+++ b/client/coral-admin/src/graphql/mutations/index.js
@@ -0,0 +1,38 @@
+import {graphql} from 'react-apollo';
+import SET_USER_STATUS from './setUserStatus.graphql';
+import SET_COMMENT_STATUS from './setCommentStatus.graphql';
+
+export const banUser = graphql(SET_USER_STATUS, {
+ props: ({mutate}) => ({
+ banUser: ({userId}) => {
+ return mutate({
+ variables: {
+ userId,
+ status: 'BANNED'
+ }
+ });
+ }}),
+});
+
+export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
+ props: ({mutate}) => ({
+ acceptComment: ({commentId}) => {
+ return mutate({
+ variables: {
+ commentId,
+ status: 'ACCEPTED'
+ },
+ refetchQueries: ['ModQueue']
+ });
+ },
+ rejectComment: ({commentId}) => {
+ return mutate({
+ variables: {
+ commentId,
+ status: 'REJECTED'
+ },
+ refetchQueries: ['ModQueue']
+ });
+ }
+ })
+});
diff --git a/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql b/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql
new file mode 100644
index 000000000..7ff6173a8
--- /dev/null
+++ b/client/coral-admin/src/graphql/mutations/setCommentStatus.graphql
@@ -0,0 +1,7 @@
+mutation setCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){
+ setCommentStatus(id: $commentId, status: $status) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-admin/src/graphql/mutations/setUserStatus.graphql b/client/coral-admin/src/graphql/mutations/setUserStatus.graphql
new file mode 100644
index 000000000..32fcf7e20
--- /dev/null
+++ b/client/coral-admin/src/graphql/mutations/setUserStatus.graphql
@@ -0,0 +1,7 @@
+mutation setUserStatus($userId: ID!, $status: USER_STATUS!) {
+ setUserStatus(id: $userId, status: $status) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-admin/src/graphql/queries/assetsQuery.graphql b/client/coral-admin/src/graphql/queries/assetsQuery.graphql
new file mode 100644
index 000000000..37950692d
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/assetsQuery.graphql
@@ -0,0 +1,6 @@
+query Assets {
+ assets {
+ id
+ title
+ }
+}
diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js
new file mode 100644
index 000000000..fc59f8f84
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/index.js
@@ -0,0 +1,12 @@
+import {graphql} from 'react-apollo';
+import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
+
+export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
+ options: ({params: {id = ''}}) => {
+ return {
+ variables: {
+ asset_id: id
+ }
+ };
+ }
+});
diff --git a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql
new file mode 100644
index 000000000..9eba7a971
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql
@@ -0,0 +1,38 @@
+#import "../fragments/commentView.graphql"
+
+query ModQueue ($asset_id: ID!) {
+ all: comments(query: {
+ statuses: [REJECTED, PREMOD],
+ asset_id: $asset_id
+ }) {
+ ...commentView
+ }
+ premod: comments(query: {
+ statuses: [PREMOD],
+ asset_id: $asset_id
+ }) {
+ ...commentView
+ }
+ flagged: comments(query: {
+ action_type: FLAG,
+ asset_id: $asset_id
+ }) {
+ ...commentView
+ action_summaries {
+ count
+ ... on FlagActionSummary {
+ reason
+ }
+ }
+ }
+ rejected: comments(query: {
+ statuses: [REJECTED],
+ asset_id: $asset_id
+ }) {
+ ...commentView
+ }
+ assets: assets {
+ id
+ title
+ }
+}
diff --git a/client/coral-admin/src/reducers/actions.js b/client/coral-admin/src/reducers/actions.js
deleted file mode 100644
index 284e41c72..000000000
--- a/client/coral-admin/src/reducers/actions.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {Map, Set, fromJS} from 'immutable';
-import * as types from '../constants/actions';
-
-const initialState = Map({
- ids: Set(),
- byId: Map()
-});
-
-export default (state = initialState, action) => {
- switch (action.type) {
- case types.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS: return addActions(state, action);
- default:
- return state;
- }
-};
-
-const addActions = (state, action) => {
-
- // Make ids that are unique by item_id and by action type
- const typeId = (action) => `${action.action_type}_${action.item_id}`;
- const ids = action.actions.map(action => typeId(action));
- const map = action.actions.reduce((memo, action) => {
- memo[typeId(action)] = action;
- return memo;
- }, {});
- return state.set('byId', fromJS(map)).set('ids', new Set(ids));
-};
diff --git a/client/coral-admin/src/reducers/assets.js b/client/coral-admin/src/reducers/assets.js
index 77d0bf081..c9a82f1c5 100644
--- a/client/coral-admin/src/reducers/assets.js
+++ b/client/coral-admin/src/reducers/assets.js
@@ -1,20 +1,26 @@
import {Map, List, fromJS} from 'immutable';
-import {FETCH_ASSETS_SUCCESS, UPDATE_ASSET_STATE_REQUEST} from '../constants/assets';
+import * as actions from '../constants/assets';
const initialState = Map({
byId: Map(),
- ids: List()
+ ids: List(),
+ assets: List()
});
-export default (state = initialState, action) => {
+export default function assets (state = initialState, action) {
switch (action.type) {
- case FETCH_ASSETS_SUCCESS:
+ case actions.FETCH_ASSETS_SUCCESS:
return replaceAssets(action, state);
- case UPDATE_ASSET_STATE_REQUEST:
- return state.setIn(['byId', action.id, 'closedAt'], action.closedAt);
- default: return state;
+ case actions.UPDATE_ASSET_STATE_REQUEST:
+ return state
+ .setIn(['byId', action.id, 'closedAt'], action.closedAt);
+ case actions.UPDATE_ASSETS:
+ return state
+ .set('assets', List(action.assets));
+ default:
+ return state;
}
-};
+}
const replaceAssets = (action, state) => {
const assets = fromJS(action.assets.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
diff --git a/client/coral-admin/src/reducers/comments.js b/client/coral-admin/src/reducers/comments.js
deleted file mode 100644
index 034016088..000000000
--- a/client/coral-admin/src/reducers/comments.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import * as actions from '../constants/comments';
-import * as userActions from '../constants/users';
-import {Map, List, fromJS} from 'immutable';
-
-/**
- * Comments state is stored using 2 structures:
- * - byId is a Map holding the comments using the item_id property as keys
- * - ids is a List of item_id, this allows us to order and iterate easily
- * since maps are unordered and some times we just need a list of things
- */
-
-const initialState = Map({
- byId: Map(),
- ids: List(),
- loading: false,
- showBanUserDialog: false,
- banUser: {
- 'userName': '',
- 'userId': '',
- 'commentId': ''
- }
-});
-
-// Handle the comment actions
-export default (state = initialState, action) => {
- switch (action.type) {
- case actions.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST: return state.set('loading', true);
- case actions.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS: return replaceComments(action, state);
- case actions.COMMENTS_MODERATION_QUEUE_FAILED: return state.set('loading', false);
- case actions.COMMENT_STATUS_UPDATE_REQUEST: return updateStatus(state, action);
- case actions.COMMENT_FLAG: return flag(state, action);
- case actions.COMMENT_CREATE_SUCCESS: return addComment(state, action);
- case actions.COMMENT_STREAM_FETCH_SUCCESS: return replaceComments(action, state);
- case actions.SHOW_BANUSER_DIALOG: return setBanUser(state, true, action);
- case actions.HIDE_BANUSER_DIALOG: return setBanUser(state, false, action);
- case actions.USER_BAN_SUCCESS: return setBanUser(state, false, action);
- case userActions.UPDATE_STATUS_SUCCESS: return setBanUser(state, false, action);
- default: return state;
- }
-};
-
-// hide or show the UI for the dialog confirming the ban
-// set the user that is going to set and the comment that is the reason
-const setBanUser = (state, showBanUser, action) => {
- const banUser = {'userName': action.userName, 'userId': action.userId, 'commentId': action.commentId};
- return state.set('showBanUserDialog', showBanUser)
- .set('banUser', banUser);
-};
-
-// Update a comment status
-const updateStatus = (state, action) => {
- const byId = state.get('byId');
- const data = byId.get(action.id).set('status', action.status.toLowerCase());
- return state.set('byId', byId.set(action.id, data));
-};
-
-// Flag a comment
-const flag = (state, action) => {
- const byId = state.get('byId');
- const data = byId.get(action.id).set('flagged', true);
- const comment = byId.get(action.id).set('data', data);
- return state.set('byId', byId.set(action.id, comment));
-};
-
-// Replace the comment list with a new one
-const replaceComments = (action, state) => {
- const comments = fromJS(action.comments.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
- return state.set('byId', comments).set('loading', false)
- .set('ids', List(comments.keys()));
-};
-
-// Add a new comment
-const addComment = (state, action) => {
- const comment = fromJS(action.comment);
- return state.set('byId', state.get('byId').set(comment.get('item_id'), comment))
- .set('ids', state.get('ids').unshift(comment.get('item_id')));
-};
diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js
index f12de96ba..837ad507d 100644
--- a/client/coral-admin/src/reducers/index.js
+++ b/client/coral-admin/src/reducers/index.js
@@ -1,19 +1,15 @@
-import auth from 'reducers/auth';
-import users from 'reducers/users';
-import assets from 'reducers/assets';
-import actions from 'reducers/actions';
-import install from 'reducers/install';
-import comments from 'reducers/comments';
-import settings from 'reducers/settings';
-import community from 'reducers/community';
+import auth from './auth';
+import assets from './assets';
+import settings from './settings';
+import community from './community';
+import moderation from './moderation';
+import install from './install';
export default {
- settings,
- comments,
- community,
auth,
- actions,
assets,
- users,
+ settings,
+ community,
+ moderation,
install
};
diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js
new file mode 100644
index 000000000..3131f6096
--- /dev/null
+++ b/client/coral-admin/src/reducers/moderation.js
@@ -0,0 +1,37 @@
+import {Map} from 'immutable';
+import * as actions from '../constants/moderation';
+
+const initialState = Map({
+ activeTab: 'all',
+ singleView: false,
+ modalOpen: false,
+ user: Map({}),
+ commentId: null,
+ banDialog: false
+});
+
+export default function moderation (state = initialState, action) {
+ switch (action.type) {
+ case actions.HIDE_BANUSER_DIALOG:
+ return state
+ .set('banDialog', false);
+ case actions.SHOW_BANUSER_DIALOG:
+ return state
+ .merge({
+ user: Map(action.user),
+ commentId: action.commentId,
+ banDialog: true
+ });
+ case actions.SET_ACTIVE_TAB:
+ return state
+ .set('activeTab', action.activeTab);
+ case actions.TOGGLE_MODAL:
+ return state
+ .set('modalOpen', action.open);
+ case actions.SINGLE_VIEW:
+ return state
+ .set('singleView', !state.get('singleView'));
+ default :
+ return state;
+ }
+}
diff --git a/client/coral-admin/src/reducers/settings.js b/client/coral-admin/src/reducers/settings.js
index 4f743bc0a..70c52f028 100644
--- a/client/coral-admin/src/reducers/settings.js
+++ b/client/coral-admin/src/reducers/settings.js
@@ -1,5 +1,5 @@
import {Map, List} from 'immutable';
-import * as types from '../actions/settings';
+import * as actions from '../actions/settings';
const initialState = Map({
settings: Map({
@@ -16,48 +16,49 @@ const initialState = Map({
fetchingSettings: false
});
-// Handle the comment actions
-export default (state = initialState, action) => {
+export default function settings (state = initialState, action) {
switch (action.type) {
- case types.SETTINGS_LOADING: return state.set('fetchingSettings', true).set('fetchSettingsError', null);
- case types.SETTINGS_RECEIVED: return updateSettings(state, action);
- case types.SETTINGS_FETCH_ERROR: return settingsFetchFailed(state, action);
- case types.SETTINGS_UPDATED: return updateSettings(state, action);
- case types.SAVE_SETTINGS_LOADING: return state.set('fetchingSettings', true).set('saveSettingsError', null);
- case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action);
- case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action);
- case types.WORDLIST_UPDATED: return updateWordlist(state, action);
- case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action);
- default: return state;
+ case actions.SETTINGS_LOADING:
+ return state
+ .set('fetchingSettings', true)
+ .set('fetchSettingsError', null);
+ case actions.SETTINGS_RECEIVED:
+ return state.merge({
+ fetchingSettings: null,
+ fetchSettingsError: null,
+ ...action.settings
+ });
+ case actions.SETTINGS_FETCH_ERROR:
+ return state
+ .set('fetchingSettings', false)
+ .set('fetchSettingsError', action.error);
+ case actions.SETTINGS_UPDATED:
+ return state.merge({
+ fetchingSettings: null,
+ fetchSettingsError: null,
+ ...action.settings
+ });
+ case actions.SAVE_SETTINGS_LOADING:
+ return state
+ .set('fetchingSettings', true)
+ .set('saveSettingsError', null);
+ case actions.SAVE_SETTINGS_SUCCESS:
+ return state.merge({
+ fetchingSettings: false,
+ fetchSettingsError: null,
+ ...action.settings
+ });
+ case actions.SAVE_SETTINGS_FAILED:
+ return state
+ .set('fetchingSettings', false)
+ .set('fetchSettingsError', action.error);
+ case actions.WORDLIST_UPDATED:
+ return state
+ .setIn(['settings', 'wordlist', action.listName], action.list);
+ case actions.DOMAINLIST_UPDATED:
+ return state
+ .setIn(['settings', 'domains', action.listName], action.list);
+ default:
+ return state;
}
-};
-
-// only for updating top-level settings
-const updateSettings = (state, action) => {
- const s = state.set('fetchingSettings', false).set('fetchSettingsError', null);
- const settings = s.get('settings').merge(action.settings);
- return s.set('settings', settings);
-};
-
-// any nested settings must have a specialized setter
-const updateWordlist = (state, action) => {
- return state.setIn(['settings', 'wordlist', action.listName], action.list);
-};
-
-const updateDomainlist = (state, action) => {
- return state.setIn(['settings', 'domains', action.listName], action.list);
-};
-
-const saveComplete = (state, action) => {
- const s = state.set('fetchingSettings', false).set('saveSettingsError', null);
- const settings = s.get('settings').merge(action.settings);
- return s.set('settings', settings);
-};
-
-const settingsFetchFailed = (state, action) => {
- return state.set('fetchingSettings', false).set('fetchSettingsError', action.error);
-};
-
-const settingsSaveFailed = (state, action) => {
- return state.set('fetchingSettings', false).set('fetchSettingsError', action.error);
-};
+}
diff --git a/client/coral-admin/src/reducers/users.js b/client/coral-admin/src/reducers/users.js
deleted file mode 100644
index ef589c155..000000000
--- a/client/coral-admin/src/reducers/users.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import {Map, List, fromJS} from 'immutable';
-
-const initialState = Map({
- byId: Map(),
- ids: List()
-});
-
-export default (state = initialState, action) => {
- switch (action.type) {
- case 'USERS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceUsers(action, state);
- case 'USER_STATUS_UPDATE': return updateUserStatus(state, action);
- default: return state;
- }
-};
-
-// Replace the comment list with a new one
-const replaceUsers = (action, state) => {
- const users = fromJS(action.users.reduce((prev, curr) => { prev[curr.id] = curr; return prev; }, {}));
- return state.set('byId', users)
- .set('ids', List(users.keys()));
-};
-
-// Update a user status
-const updateUserStatus = (state, action) => {
- const byId = state.get('byId');
- const data = byId.get(action.author_id).set('status', action.status.toLowerCase());
- return state.set('byId', byId.set(action.author_id, data));
-};
diff --git a/client/coral-admin/src/services/PymConnection.js b/client/coral-admin/src/services/PymConnection.js
new file mode 100644
index 000000000..ca592b824
--- /dev/null
+++ b/client/coral-admin/src/services/PymConnection.js
@@ -0,0 +1,9 @@
+import Pym from '../../node_modules/pym.js';
+
+const pym = new Pym.Child({polling: 100});
+export default pym;
+
+export const link = (url) => (e) => {
+ e.preventDefault();
+ pym.sendMessage('navigate', url);
+};
diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js
index b4a7a38df..40a539634 100644
--- a/client/coral-admin/src/services/client.js
+++ b/client/coral-admin/src/services/client.js
@@ -2,7 +2,6 @@ import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
export const client = new ApolloClient({
- connectToDevTools: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js
index 405c75c5b..9fa4fc77c 100644
--- a/client/coral-embed-stream/src/Embed.js
+++ b/client/coral-embed-stream/src/Embed.js
@@ -17,6 +17,7 @@ import {Notification, notificationActions, authActions, assetActions, pym} from
import Stream from './Stream';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
+import {ModerationLink} from 'coral-plugin-moderation';
import Count from 'coral-plugin-comment-count/CommentCount';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import UserBox from 'coral-sign-in/components/UserBox';
@@ -139,6 +140,7 @@ class Embed extends Component {
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
: null
}
+
:
{asset.settings.closedMessage}
diff --git a/client/coral-plugin-author-name/styles.css b/client/coral-plugin-author-name/styles.css
index 608db203c..b7870a862 100644
--- a/client/coral-plugin-author-name/styles.css
+++ b/client/coral-plugin-author-name/styles.css
@@ -1,7 +1,7 @@
.authorName {
color: black;
display: inline-block;
- margin: 10px 5px 10px 0;
+ margin: 10px 8px 10px 0;
}
.hasBio {
diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js
index 145080469..9b3abfb00 100644
--- a/client/coral-plugin-likes/LikeButton.js
+++ b/client/coral-plugin-likes/LikeButton.js
@@ -8,7 +8,7 @@ class LikeButton extends Component {
static propTypes = {
like: PropTypes.shape({
- current: PropTypes.obect,
+ current: PropTypes.object,
count: PropTypes.number
}),
id: PropTypes.string,
diff --git a/client/coral-plugin-moderation/ModerationLink.js b/client/coral-plugin-moderation/ModerationLink.js
new file mode 100644
index 000000000..808f79839
--- /dev/null
+++ b/client/coral-plugin-moderation/ModerationLink.js
@@ -0,0 +1,22 @@
+import React, {PropTypes} from 'react';
+import styles from './styles.css';
+
+import {I18n} from '../coral-framework';
+import translations from './translations.json';
+
+const ModerationLink = props => props.isAdmin ? (
+
+ ) : null;
+
+ModerationLink.propTypes = {
+ assetId: PropTypes.string.isRequired,
+ isAdmin: PropTypes.bool.isRequired
+};
+
+const lang = new I18n(translations);
+
+export default ModerationLink;
diff --git a/client/coral-plugin-moderation/index.js b/client/coral-plugin-moderation/index.js
new file mode 100644
index 000000000..4543750c4
--- /dev/null
+++ b/client/coral-plugin-moderation/index.js
@@ -0,0 +1 @@
+export {default as ModerationLink} from './ModerationLink';
diff --git a/client/coral-plugin-moderation/styles.css b/client/coral-plugin-moderation/styles.css
new file mode 100644
index 000000000..2e928d3df
--- /dev/null
+++ b/client/coral-plugin-moderation/styles.css
@@ -0,0 +1,9 @@
+.moderationLink {
+ a {
+ color: #679af3;
+ text-decoration: none;
+ font-size: 1em;
+ font-weight: 600;
+ letter-spacing: .3px;
+ }
+}
diff --git a/client/coral-plugin-moderation/translations.json b/client/coral-plugin-moderation/translations.json
new file mode 100644
index 000000000..331c6e392
--- /dev/null
+++ b/client/coral-plugin-moderation/translations.json
@@ -0,0 +1,8 @@
+{
+ "en": {
+ "MODERATE_THIS_STREAM": "Moderate this stream"
+ },
+ "es": {
+ "MODERATE_THIS_STREAM": "Modera este stream"
+ }
+}
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 6214c9df8..98891953c 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -162,22 +162,37 @@ const createPublicComment = (context, commentInput) => {
}));
};
+/**
+ * Sets the status of a comment
+ * @param {String} comment comment in graphql context
+ * @param {String} id identifier of the comment (uuid)
+ * @param {String} status the new status of the comment
+ */
+
+const setCommentStatus = ({comment}, {id, status}) => {
+ return CommentsService.setStatus(id, status)
+ .then(res => res);
+};
+
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
- if (context.user && context.user.can('mutation:createComment')) {
- return {
- Comment: {
- create: (comment) => createPublicComment(context, comment)
- }
- };
- }
-
- return {
+ let mutators = {
Comment: {
- create: () => Promise.reject(errors.ErrNotAuthorized)
+ create: () => Promise.reject(errors.ErrNotAuthorized),
+ setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized)
}
};
+
+ if (context.user && context.user.can('mutation:createComment')) {
+ mutators.Comment.create = (comment) => createPublicComment(context, comment);
+ }
+
+ if (context.user && context.user.can('mutation:setCommentStatus')) {
+ mutators.Comment.setCommentStatus = (action) => setCommentStatus(context, action);
+ }
+
+ return mutators;
};
diff --git a/graph/mutators/index.js b/graph/mutators/index.js
index 58d0ed62c..b799cf83d 100644
--- a/graph/mutators/index.js
+++ b/graph/mutators/index.js
@@ -2,6 +2,7 @@ const _ = require('lodash');
const Comment = require('./comment');
const Action = require('./action');
+const User = require('./user');
module.exports = (context) => {
@@ -9,6 +10,7 @@ module.exports = (context) => {
return _.merge(...[
Comment,
Action,
+ User,
].map((mutators) => {
// Each set of mutators is a function which takes the context.
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
new file mode 100644
index 000000000..2c43f11be
--- /dev/null
+++ b/graph/mutators/user.js
@@ -0,0 +1,27 @@
+const errors = require('../../errors');
+const UsersService = require('../../services/users');
+
+const setUserStatus = ({user}, {id, status}) => {
+ return UsersService.setStatus(id, status)
+ .then(res => res);
+};
+
+module.exports = (context) => {
+
+ // TODO: refactor to something that'll return an error in the event an attempt
+ // is made to mutate state while not logged in. There's got to be a better way
+ // to do this.
+ if (context.user && context.user.can('mutation:setUserStatus')) {
+ return {
+ User: {
+ setUserStatus: (action) => setUserStatus(context, action)
+ }
+ };
+ }
+
+ return {
+ User: {
+ setUserStatus: () => Promise.reject(errors.ErrNotAuthorized)
+ }
+ };
+};
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index 8369b7281..4285f900d 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -27,6 +27,12 @@ const RootMutation = {
deleteAction(_, {id}, {mutators: {Action}}) {
return wrapResponse(null)(Action.delete({id}));
},
+ setUserStatus(_, {id, status}, {mutators: {User}}) {
+ return wrapResponse(null)(User.setUserStatus({id, status}));
+ },
+ setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
+ return wrapResponse(null)(Comment.setCommentStatus({id, status}));
+ }
};
module.exports = RootMutation;
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index 8aa14d5c6..ec3ac5b08 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -29,12 +29,7 @@ const RootQuery = {
if (user != null && user.hasRoles('ADMIN') && action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
- .then((actions) => {
-
- // Map the actions from the items referenced byt this query. The actions
- // returned by this query are explicitly going to be distinct by their
- // `item_id`'s.
- let ids = actions.map((action) => action.item_id);
+ .then((ids) => {
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index 13a1698ec..175541f80 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -44,6 +44,9 @@ type User {
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment]
+
+ # returns user status
+ status: USER_STATUS
}
type Tag {
@@ -362,6 +365,13 @@ enum SORT_ORDER {
}
# All queries that can be executed.
+enum USER_STATUS {
+ ACTIVE
+ BANNED
+ PENDING
+ APPROVED
+}
+
type RootQuery {
# Site wide settings and defaults.
@@ -468,6 +478,22 @@ type DeleteActionResponse implements Response {
errors: [UserError]
}
+# SetUserStatusResponse is the response returned with possibly some errors
+# relating to the delete action attempt.
+type SetUserStatusResponse implements Response {
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
+# SetCommentStatusResponse is the response returned with possibly some errors
+# relating to the delete action attempt.
+type SetCommentStatusResponse implements Response {
+
+ # An array of errors relating to the mutation that occured.
+ errors: [UserError]
+}
+
# All mutations for the application are defined on this object.
type RootMutation {
@@ -482,6 +508,12 @@ type RootMutation {
# Delete an action based on the action id.
deleteAction(id: ID!): DeleteActionResponse
+
+ # Sets User status
+ setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
+
+ # Sets Comment status
+ setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
}
################################################################################
diff --git a/models/user.js b/models/user.js
index 43a34a565..d56f030f3 100644
--- a/models/user.js
+++ b/models/user.js
@@ -154,7 +154,9 @@ const USER_GRAPH_OPERATIONS = [
'mutation:createComment',
'mutation:createAction',
'mutation:deleteAction',
- 'mutation:editName'
+ 'mutation:editName',
+ 'mutation:setUserStatus',
+ 'mutation:setCommentStatus'
];
/**
@@ -170,6 +172,10 @@ UserSchema.method('can', function(...actions) {
return false;
}
+ if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
+ return false;
+ }
+
return true;
});
diff --git a/services/comments.js b/services/comments.js
index fc05341cb..bcc534c51 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -7,6 +7,12 @@ const ALLOWED_TAGS = [
{name: 'STAFF'}
];
+const STATUSES = [
+ 'ACCEPTED',
+ 'REJECTED',
+ 'PREMOD',
+];
+
module.exports = class CommentsService {
/**
@@ -249,4 +255,25 @@ module.exports = class CommentsService {
return CommentModel.find(query);
}
+
+ /**
+ * Sets Comment Status
+ * @param {String} id identifier of the comment (uuid)
+ * @param {String} status the new status of the comment
+ * @return {Promise}
+ */
+
+ static setStatus(id, status) {
+
+ // Check to see if the comment status is in the allowable set of statuses.
+ if (STATUSES.indexOf(status) === -1) {
+
+ // Comment status is not supported! Error out here.
+ return Promise.reject(new Error(`status ${status} is not supported`));
+ }
+
+ return CommentModel.update({id}, {
+ $set: {status}
+ });
+ }
};