diff --git a/.eslintignore b/.eslintignore index d23f5dfb4..c37217709 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 405a0248a..58683aec1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 937e205f1..2b8c565da 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -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 ; + } if (this.commentIsIgnored(comment)) { - return ; + return ; } const view = this.getVisibileReplies(); diff --git a/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js b/client/coral-embed-stream/src/components/CommentTombstone.js similarity index 50% rename from client/coral-embed-stream/src/components/IgnoredCommentTombstone.js rename to client/coral-embed-stream/src/components/CommentTombstone.js index bbcc5d5f6..1d7e8d7e5 100644 --- a/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js +++ b/client/coral-embed-stream/src/components/CommentTombstone.js @@ -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 +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 (
@@ -14,11 +27,11 @@ class IgnoredCommentTombstone extends React.Component { padding: '1em', color: '#3E4F71', }}> - {t('framework.comment_is_ignored')} + {this.getCopy()}

); } } -export default IgnoredCommentTombstone; +export default CommentTombstone; diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js deleted file mode 100644 index fd4587356..000000000 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {IgnoreUserWizard} from './IgnoreUserWizard'; -import Toggleable from './Toggleable'; - -// TopRightMenu appears as a dropdown in the top right of the comment. -// when you click the down cehvron, it expands and shows IgnoreUserWizard -// when you click 'cancel' in the wizard, it closes the menu -export class TopRightMenu extends React.Component { - static propTypes = { - - // comment on which this menu appears - comment: PropTypes.shape({ - user: PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string.isRequired - }).isRequired - }).isRequired, - ignoreUser: PropTypes.func, - - // show notification to the user (e.g. for errors) - notify: PropTypes.func.isRequired, - } - constructor(props) { - super(props); - this.state = { - timesReset: 0 - }; - } - render() { - const {comment, ignoreUser, notify} = this.props; - - // timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable) - const reset = () => this.setState({timesReset: this.state.timesReset + 1}); - const ignoreUserAndCloseMenuAndNotifyOnError = async ({id}) => { - - // close menu - reset(); - - // ignore user - try { - await ignoreUser({id}); - } catch (error) { - notify('error', 'Failed to ignore user'); - throw error; - } - }; - return ( - -
- -
-
- ); - } -} - diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index c09ce8751..fb004956b 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -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}); + } }); } }) diff --git a/client/coral-settings/components/IgnoredUsers.js b/client/coral-settings/components/IgnoredUsers.js deleted file mode 100644 index 9cb314373..000000000 --- a/client/coral-settings/components/IgnoredUsers.js +++ /dev/null @@ -1,44 +0,0 @@ -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import t from 'coral-framework/services/i18n'; -import styles from './IgnoredUsers.css'; - -export class IgnoredUsers extends Component { - static propTypes = { - users: PropTypes.arrayOf(PropTypes.shape({ - username: PropTypes.string, - id: PropTypes.string, - })).isRequired, - - // accepts { id } - stopIgnoring: PropTypes.func.isRequired, - } - render() { - const {users, stopIgnoring} = this.props; - return ( -
- { - users.length - ?

{t('framework.because_you_ignored')}

- : null - } -
- { - users.map(({username, id}) => ( - -
{ username }
-
- stopIgnoring({id})} - className={styles.link}>{t('framework.stop_ignoring')} -
-
- )) - } -
-
- ); - } -} - -export default IgnoredUsers; diff --git a/docs/index.md b/docs/index.md index 35cb5a158..2c106394d 100755 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ sidebar: nav: "docs" --- -Online comments are broken. Mozilla's open-source, plugin-based tool Talk rethinks how moderation, comment display, and conversation function online, creating the opportunity for better, smarter discussions designed to fit your needs. [Read more about Talk here.](https://coralproject.net/products/talk) +Online comments are broken. Mozilla's open-source, plugin-based tool Talk rethinks how moderation, comment display, and conversation function online, creating the opportunity for better, smarter discussions designed to fit your needs. [Read more about Talk here.](https://coralproject.net/products/talk.html) Third party licenses are available via the `/client/3rdpartylicenses.txt` endpoint when the server is running with built assets. @@ -32,11 +32,7 @@ See our [Contribution Guide]({{ "/docs/development/contributing" | absolute_url - iPad - iPad Pro -- iPhone 7 Plus -- iPhone 7 -- iPhone 6 Plus -- iPhone 6 -- iPhone 5 +- iPhone 5 and later ### iOS Browsers @@ -46,9 +42,8 @@ See our [Contribution Guide]({{ "/docs/development/contributing" | absolute_url ### Android Devices -- Galaxy S5 -- Nexus 5X -- Nexus 6P +- Galaxy S5 and later +- Nexus 5X and later ### Android Browsers diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 9142687f6..c6e335018 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -194,16 +194,32 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu * @param {String} [asset_id] id of asset comment is posted on * @return {Object} resolves to the wordlist results */ -const filterNewComment = (context, {body, asset_id}) => { +const filterNewComment = async (context, {body, asset_id}) => { + + // Load the settings. + const [ + settings, + asset, + ] = await Promise.all([ + context.loaders.Settings.load(), + context.loaders.Assets.getByID.load(asset_id), + ]); // Create a new instance of the Wordlist. const wl = new Wordlist(); + // Load the wordlist. + wl.upsert(settings.wordlist); + // Load the wordlist and filter the comment content. - return Promise.all([ - wl.load().then(() => wl.scan('body', body)), - asset_id && AssetsService.rectifySettings(AssetsService.findById(asset_id)) - ]); + return [ + + // Scan the word. + wl.scan('body', body), + + // Return the asset's settings. + await AssetsService.rectifySettings(asset, settings) + ]; }; /** @@ -247,7 +263,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body, status}, wordli // Return `premod` if pre-moderation is enabled and an empty "new" status // in the event that it is not in pre-moderation mode. - let {moderation, charCountEnable, charCount} = await AssetsService.rectifySettings(asset); + let {moderation, charCountEnable, charCount} = await AssetsService.rectifySettings(asset, settings); // Reject if the comment is too long if (charCountEnable && body.length > charCount) { @@ -365,7 +381,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings); // Execute the edit. - const comment = await CommentsService.edit(id, context.user.id, {body, status}); + const comment = await CommentsService.edit({id, author_id: context.user.id, body, status}); // Publish the edited comment via the subscription. context.pubsub.publish('commentEdited', comment); diff --git a/locales/en.yml b/locales/en.yml index 4d215fd06..703de4b28 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/locales/es.yml b/locales/es.yml index f9b675a10..db25ab527 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -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" diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 15c8b3544..d6d5a67a0 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -8,4 +8,5 @@ export {default as withEmit} from 'coral-framework/hocs/withEmit'; export { withIgnoreUser, withStopIgnoringUser, + withSetCommentStatus, } from 'coral-framework/graphql/mutations'; diff --git a/plugins/talk-plugin-featured-comments/client/components/Button.css b/plugins/talk-plugin-featured-comments/client/components/Button.css index daaa329c9..09432af70 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Button.css +++ b/plugins/talk-plugin-featured-comments/client/components/Button.css @@ -14,4 +14,4 @@ .icon { font-size: 18px; vertical-align: top; -} +} \ No newline at end of file diff --git a/plugins/talk-plugin-featured-comments/client/components/Button.js b/plugins/talk-plugin-featured-comments/client/components/Button.js index b9d0cc95f..c581ce027 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Button.js +++ b/plugins/talk-plugin-featured-comments/client/components/Button.js @@ -24,4 +24,3 @@ const Button = (props) => { }; export default withTags('featured')(Button); - diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.js b/plugins/talk-plugin-featured-comments/client/components/Comment.js index ada7ae7b6..1c2045110 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Comment.js +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.js @@ -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 /> + + + ); + } +} + +export default withTags('featured')(Button); + diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.css b/plugins/talk-plugin-featured-comments/client/components/Tag.css index 7ade3d968..0e8c8ae60 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Tag.css +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.css @@ -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; } \ No newline at end of file diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.js b/plugins/talk-plugin-featured-comments/client/components/Tag.js index 562606502..e7e75a0e6 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Tag.js +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.js @@ -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( -
- { - isTagged(this.props.comment.tags, 'FEATURED') ? ( - - {t('talk-plugin-featured-comments.featured')} - - ) : null - } + onTouchEnd={this.hideTooltip} > + + {t('talk-plugin-featured-comments.featured')} + {tooltip && } -
+ ); } } diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js b/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js new file mode 100644 index 000000000..e6ccc0f72 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/ModActionButton.js @@ -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); diff --git a/plugins/talk-plugin-featured-comments/client/containers/Tag.js b/plugins/talk-plugin-featured-comments/client/containers/Tag.js index 60d9e1e2b..902591875 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/Tag.js +++ b/plugins/talk-plugin-featured-comments/client/containers/Tag.js @@ -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); diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js index c9598b1dd..765951652 100644 --- a/plugins/talk-plugin-featured-comments/client/index.js +++ b/plugins/talk-plugin-featured-comments/client/index.js @@ -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; }, diff --git a/plugins/talk-plugin-moderation-actions/client/.babelrc b/plugins/talk-plugin-moderation-actions/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/.babelrc @@ -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" + ] +} \ No newline at end of file diff --git a/plugins/talk-plugin-moderation-actions/client/.eslintrc.json b/plugins/talk-plugin-moderation-actions/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/.eslintrc.json @@ -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"] }] + } +} diff --git a/plugins/talk-plugin-moderation-actions/client/components/ApproveCommentAction.js b/plugins/talk-plugin-moderation-actions/client/components/ApproveCommentAction.js new file mode 100644 index 000000000..f75a98c27 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/ApproveCommentAction.js @@ -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}}) => ( + +); diff --git a/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.css b/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.css new file mode 100644 index 000000000..1138d2ffc --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.css @@ -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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.js b/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.js new file mode 100644 index 000000000..3a3b54156 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/ModerationActions.js @@ -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( + +
+ + {tooltip ? : + } + + {tooltip && ( + + + + + + + + )} +
+
+ ); + } +} diff --git a/plugins/talk-plugin-moderation-actions/client/components/RejectCommentAction.js b/plugins/talk-plugin-moderation-actions/client/components/RejectCommentAction.js new file mode 100644 index 000000000..827820a7a --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/RejectCommentAction.js @@ -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}) => ( + +); diff --git a/plugins/talk-plugin-moderation-actions/client/components/Tooltip.css b/plugins/talk-plugin-moderation-actions/client/components/Tooltip.css new file mode 100644 index 000000000..def7cac82 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/Tooltip.css @@ -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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-moderation-actions/client/components/Tooltip.js b/plugins/talk-plugin-moderation-actions/client/components/Tooltip.js new file mode 100644 index 000000000..db7e56c1e --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/Tooltip.js @@ -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}) => ( +
+

+ {t('talk-plugin-moderation-actions.moderation_actions')} +

+ {children} +
+); diff --git a/plugins/talk-plugin-moderation-actions/client/components/styles.css b/plugins/talk-plugin-moderation-actions/client/components/styles.css new file mode 100644 index 000000000..12ae4bad5 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/components/styles.css @@ -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; +} \ No newline at end of file diff --git a/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js b/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js new file mode 100644 index 000000000..c75711a56 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/containers/ApproveCommentAction.js @@ -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 ; + } +} + +export default withSetCommentStatus(ApproveCommentActionContainer); diff --git a/plugins/talk-plugin-moderation-actions/client/containers/ModerationActions.js b/plugins/talk-plugin-moderation-actions/client/containers/ModerationActions.js new file mode 100644 index 000000000..ffb711e18 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/containers/ModerationActions.js @@ -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); diff --git a/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js b/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js new file mode 100644 index 000000000..5497d0e8e --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/containers/RejectCommentAction.js @@ -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 ; + } +} + +export default withSetCommentStatus(RejectCommentActionContainer); diff --git a/plugins/talk-plugin-moderation-actions/client/index.js b/plugins/talk-plugin-moderation-actions/client/index.js new file mode 100644 index 000000000..3343505f3 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/index.js @@ -0,0 +1,9 @@ +import ModerationActions from './containers/ModerationActions'; +import translations from './translations.yml'; + +export default { + slots: { + commentInfoBar: [ModerationActions], + }, + translations +}; diff --git a/plugins/talk-plugin-moderation-actions/client/translations.yml b/plugins/talk-plugin-moderation-actions/client/translations.yml new file mode 100644 index 000000000..425c917f3 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/client/translations.yml @@ -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" \ No newline at end of file diff --git a/plugins/talk-plugin-moderation-actions/index.js b/plugins/talk-plugin-moderation-actions/index.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/plugins/talk-plugin-moderation-actions/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/services/assets.js b/services/assets.js index 34c8ff465..8f93de009 100644 --- a/services/assets.js +++ b/services/assets.js @@ -3,6 +3,7 @@ const AssetModel = require('../models/asset'); const SettingsService = require('./settings'); const domainlist = require('./domainlist'); const errors = require('../errors'); +const merge = require('lodash/merge'); module.exports = class AssetsService { @@ -28,19 +29,23 @@ module.exports = class AssetsService { * @param {Promise} assetQuery an asset query that returns a single asset. * @return {Promise} */ - static rectifySettings(assetQuery) { - return Promise.all([ - SettingsService.retrieve(), - assetQuery - ]).then(([settings, asset]) => { + static async rectifySettings(assetQuery, settings = null) { + const [ + globalSettings, + asset, + ] = await Promise.all([ + settings !== null ? settings : SettingsService.retrieve(), + assetQuery, + ]); - // If the asset exists and has settings then return the merged object. - if (asset && asset.settings) { - settings.merge(asset.settings); - } + // If the asset exists and has settings then return the merged object. + if (asset && asset.settings) { + settings = merge({}, globalSettings, asset.settings); + } else { + settings = globalSettings; + } - return settings; - }); + return settings; } /** diff --git a/services/comments.js b/services/comments.js index db0714233..82914bcd0 100644 --- a/services/comments.js +++ b/services/comments.js @@ -5,6 +5,7 @@ const ActionsService = require('./actions'); const SettingsService = require('./settings'); const sc = require('snake-case'); +const cloneDeep = require('lodash/cloneDeep'); const errors = require('../errors'); const events = require('./events'); const { @@ -52,13 +53,34 @@ module.exports = class CommentsService { } /** - * Edit a Comment - * @param {String} id comment.id you want to edit (or its ID) - * @param {String} author_id user.id of the user trying to edit the comment (will err if not comment author) + * lastUnmoderatedStatus will retrieve the last status before this one. + * + * @param {Object} comment the comment to get the last status of + */ + static lastUnmoderatedStatus(comment) { + const UNMODERATED_STATUSES = [ + 'NONE', + 'PREMOD', + ]; + + for (let i = comment.status_history.length - 1; i >= 0; i--) { + const {type} = comment.status_history[i]; + + if (UNMODERATED_STATUSES.includes(type)) { + return type; + } + } + } + + /** + * Edit a Comment. + * + * @param {String} id comment.id you want to edit (or its ID) + * @param {String} author_id user.id of the user trying to edit the comment (will err if not comment author) * @param {String} body the new Comment body * @param {String} status the new Comment status */ - static async edit(id, author_id, {body, status, ignoreEditWindow = false}) { + static async edit({id, author_id, body, status}) { const query = { id, author_id, @@ -69,14 +91,11 @@ module.exports = class CommentsService { // Establish the edit window (if it exists) and add the condition to the // original query. - let lastEditableCommentCreatedAt; - if (!ignoreEditWindow) { - const {editCommentWindowLength: editWindowMs} = await SettingsService.retrieve(); - lastEditableCommentCreatedAt = new Date((new Date()).getTime() - editWindowMs); - query.created_at = { - $gt: lastEditableCommentCreatedAt, - }; - } + const {editCommentWindowLength: editWindowMs} = await SettingsService.retrieve(); + const lastEditableCommentCreatedAt = new Date(Date.now() - editWindowMs); + query.created_at = { + $gt: lastEditableCommentCreatedAt, + }; const originalComment = await CommentModel.findOneAndUpdate(query, { $set: { @@ -117,7 +136,7 @@ module.exports = class CommentsService { } // Check to see if the edit window expired. - if (!ignoreEditWindow && comment.created_at <= lastEditableCommentCreatedAt) { + if (comment.created_at <= lastEditableCommentCreatedAt) { debug('rejecting comment edit because outside edit time window'); throw errors.ErrEditWindowHasEnded; } @@ -126,7 +145,7 @@ module.exports = class CommentsService { } // Mutate the comment like Mongo would have. - const editedComment = originalComment; + const editedComment = cloneDeep(originalComment); editedComment.status = status; editedComment.body = body; editedComment.body_history.push({ @@ -138,6 +157,43 @@ module.exports = class CommentsService { created_at: new Date(), }); + // We should adjust the comment's status such that if it was approved + // previously, we should mark the comment as 'NONE' or 'PREMOD', which ever + // was most recent if the new comment is destined to be `NONE` or `PREMOD`. + if (originalComment.status === 'ACCEPTED' && status === 'NONE') { + + const lastUnmoderatedStatus = CommentsService.lastUnmoderatedStatus(originalComment); + + // If the last moderated status was found and the current comment doesn't + // match this already. + if (lastUnmoderatedStatus && status !== lastUnmoderatedStatus) { + + // Update the comment model (if at this point, the status is still + // accepted) with the previously unmoderated status + await CommentModel.update({ + id, + status, + }, { + $set: { + status: lastUnmoderatedStatus, + }, + $push: { + status_history: { + type: lastUnmoderatedStatus, + created_at: new Date(), + } + }, + }); + + // Update the returned comment. + editedComment.status = lastUnmoderatedStatus; + editedComment.status_history.push({ + type: lastUnmoderatedStatus, + created_at: new Date(), + }); + } + } + await events.emitAsync(COMMENTS_EDIT, originalComment, editedComment); return editedComment; @@ -215,23 +271,6 @@ module.exports = class CommentsService { return CommentModel.find({status}); } - /** - * Find comments that need to be moderated (aka moderation queue). - * @param {String} asset_id - * @return {Promise} - */ - static moderationQueue(status = 'NONE', asset_id = null) { - - // Fetch the comments with statuses. - let comments = CommentModel.find({status}); - - if (asset_id) { - comments = comments.where({asset_id}); - } - - return comments; - } - /** * Pushes a new status in for the user. * @param {String} id identifier of the comment (uuid) @@ -356,7 +395,7 @@ events.on(COMMENTS_NEW, async (comment) => { if ( !comment || // Check that the comment is defined. (!comment.parent_id || comment.parent_id.length === 0) || // Check that the comment has a parent (is a reply). - !(comment.status === 'NONE' || comment.status === 'APPROVED') // Check that the comment is visible. + !(comment.status === 'NONE' || comment.status === 'ACCEPTED') // Check that the comment is visible. ) { return; } diff --git a/test/server/services/comments.js b/test/server/services/comments.js index e33089325..77711d321 100644 --- a/test/server/services/comments.js +++ b/test/server/services/comments.js @@ -10,7 +10,6 @@ const CommentsService = require('../../../services/comments'); const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; const chai = require('chai'); -chai.use(require('chai-as-promised')); chai.use(require('sinon-chai')); const expect = chai.expect; @@ -96,102 +95,118 @@ describe('services.CommentsService', () => { user_id: '456' }]; - beforeEach(() => { - return SettingsService.init(settings).then(() => { - return Promise.all([ - CommentModel.create(comments), - UsersService.createLocalUsers(users), - ActionModel.create(actions) - ]); - }); + beforeEach(async () => { + await SettingsService.init(settings); + + await Promise.all([ + CommentModel.create(comments), + UsersService.createLocalUsers(users), + ActionModel.create(actions) + ]); }); describe('#publicCreate()', () => { - it('creates a new comment', () => { - return CommentsService - .publicCreate({ - body: 'This is a comment!', - status: 'ACCEPTED' - }).then((c) => { - expect(c).to.not.be.null; - expect(c.id).to.not.be.null; - expect(c.id).to.be.uuid; - expect(c.status).to.be.equal('ACCEPTED'); - }); + it('creates a new comment', async () => { + const c = await CommentsService.publicCreate({ + body: 'This is a comment!', + status: 'ACCEPTED' + }); + + expect(c).to.not.be.null; + expect(c.id).to.not.be.null; + expect(c.id).to.be.uuid; + expect(c.status).to.be.equal('ACCEPTED'); }); - it('creates many new comments', () => { - return CommentsService - .publicCreate([{ - body: 'This is a comment!', - status: 'ACCEPTED' - }, { - body: 'This is another comment!' - }, { - body: 'This is a rejected comment!', - status: 'REJECTED' - }]).then(([c1, c2, c3]) => { - expect(c1).to.not.be.null; - expect(c1.id).to.be.uuid; - expect(c1.status).to.be.equal('ACCEPTED'); + it('creates many new comments', async () => { + const [ + c1, + c2, + c3, + ] = await CommentsService.publicCreate([{ + body: 'This is a comment!', + status: 'ACCEPTED' + }, { + body: 'This is another comment!' + }, { + body: 'This is a rejected comment!', + status: 'REJECTED' + }]); - expect(c2).to.not.be.null; - expect(c2.id).to.be.uuid; - expect(c2.status).to.be.equal('NONE'); + expect(c1).to.not.be.null; + expect(c1.status).to.be.equal('ACCEPTED'); - expect(c3).to.not.be.null; - expect(c3.id).to.be.uuid; - expect(c3.status).to.be.equal('REJECTED'); - }); + expect(c2).to.not.be.null; + expect(c2.status).to.be.equal('NONE'); + + expect(c3).to.not.be.null; + expect(c3.status).to.be.equal('REJECTED'); }); + }); + describe('#edit', () => { + it('changes the comment status back to premod if it was accepted', async () => { + const originalComment = await CommentsService.publicCreate({ + body: 'this is a body!', + status: 'PREMOD', + author_id: '123', + }); + + expect(originalComment.status_history).to.have.length(1); + + await CommentsService.pushStatus(originalComment.id, 'ACCEPTED'); + + let retrivedComment = await CommentsService.findById(originalComment.id); + + expect(retrivedComment).to.have.property('status', 'ACCEPTED'); + expect(retrivedComment.status_history).to.have.length(2); + expect(retrivedComment.status_history[1]).to.have.property('type', 'ACCEPTED'); + + const editedComment = await CommentsService.edit({ + id: originalComment.id, + author_id: '123', + body: 'This is a body!', + status: 'NONE', + }); + + expect(editedComment).to.have.property('status', 'PREMOD'); + expect(editedComment.status_history).to.have.length(4); + expect(editedComment.status_history[3]).to.have.property('type', 'PREMOD'); + + retrivedComment = await CommentsService.findById(originalComment.id); + + expect(retrivedComment).to.have.property('status', 'PREMOD'); + expect(retrivedComment.status_history).to.have.length(4); + expect(retrivedComment.status_history[3]).to.have.property('type', 'PREMOD'); + }); }); describe('#findById()', () => { - it('should find a comment by id', () => { - return CommentsService - .findById('1') - .then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.property('body', 'comment 10'); - }); + it('should find a comment by id', async () => { + const comment = await CommentsService.findById('1'); + expect(comment).to.not.be.null; + expect(comment).to.have.property('body', 'comment 10'); }); }); describe('#findByAssetId()', () => { - it('should find an array of all comments by asset id', () => { - return CommentsService - .findByAssetId('123') - .then((result) => { - expect(result).to.have.length(3); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 10'); - expect(result[1]).to.have.property('body', 'comment 20'); - expect(result[2]).to.have.property('body', 'comment 40'); - }); + it('should find an array of all comments by asset id', async () => { + const comments = await CommentsService.findByAssetId('123'); + expect(comments).to.have.length(3); + comments.sort((a, b) => { + if (a.body < b.body) {return -1;} + else {return 1;} + }); + expect(comments[0]).to.have.property('body', 'comment 10'); + expect(comments[1]).to.have.property('body', 'comment 20'); + expect(comments[2]).to.have.property('body', 'comment 40'); }); }); - describe('#moderationQueue()', () => { - - it('should find an array of new comments to moderate when pre-moderation', () => { - return CommentsService - .moderationQueue('PREMOD') - .then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.lengthOf(2); - }); - }); - - }); - describe('#changeStatus', () => { it('should change the status of a comment from no status', async () => { @@ -220,20 +235,18 @@ describe('services.CommentsService', () => { expect(c3.status_history[0]).to.have.property('assigned_by', '123'); }); - it('should change the status of a comment from accepted', () => { - return CommentsService.pushStatus(comments[1].id, 'REJECTED', '123') - .then(() => CommentsService.findById(comments[1].id)) - .then((c) => { - expect(c).to.have.property('status_history'); - expect(c).to.have.property('status'); - expect(c.status).to.equal('REJECTED'); - expect(c.status_history).to.have.length(2); - expect(c.status_history[0]).to.have.property('type', 'ACCEPTED'); - expect(c.status_history[0]).to.have.property('assigned_by', null); + it('should change the status of a comment from accepted', async () => { + await CommentsService.pushStatus(comments[1].id, 'REJECTED', '123'); + const c = await CommentsService.findById(comments[1].id); + expect(c).to.have.property('status_history'); + expect(c).to.have.property('status'); + expect(c.status).to.equal('REJECTED'); + expect(c.status_history).to.have.length(2); + expect(c.status_history[0]).to.have.property('type', 'ACCEPTED'); + expect(c.status_history[0]).to.have.property('assigned_by', null); - expect(c.status_history[1]).to.have.property('type', 'REJECTED'); - expect(c.status_history[1]).to.have.property('assigned_by', '123'); - }); + expect(c.status_history[1]).to.have.property('type', 'REJECTED'); + expect(c.status_history[1]).to.have.property('assigned_by', '123'); }); }); });