diff --git a/.gitignore b/.gitignore index a619f0988..f0c3b2a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,7 @@ plugins/* !plugins/coral-plugin-respect !plugins/coral-plugin-offtopic !plugins/coral-plugin-like +!plugins/coral-plugin-mod +!plugins/coral-plugin-love **/node_modules/* diff --git a/LICENSE b/LICENSE index 41cbbd5aa..597d8fa73 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2016 Mozilla Foundation +Copyright 2017 Mozilla Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index ffd4db2a6..55a00096d 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -1,16 +1,17 @@ import React, {PropTypes} from 'react'; import timeago from 'timeago.js'; -import Linkify from 'react-linkify'; -import Highlighter from 'react-highlight-words'; import {Link} from 'react-router'; +import Linkify from 'react-linkify'; -import styles from './styles.css'; 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 Slot from 'coral-framework/components/Slot'; +import {getActionSummary} from 'coral-framework/utils'; import ActionButton from 'coral-admin/src/components/ActionButton'; import BanUserButton from 'coral-admin/src/components/BanUserButton'; -import {getActionSummary} from 'coral-framework/utils'; const linkify = new Linkify(); @@ -18,11 +19,19 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations.json'; const lang = new I18n(translations); -const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) => { +const Comment = ({ + actions = [], + comment, + suspectWords, + bannedWords, + ...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'); + const flagActions = + comment.actions && + comment.actions.filter(a => a.__typename === 'FlagAction'); let commentType = ''; if (comment.status === 'PREMOD') { commentType = 'premod'; @@ -33,12 +42,17 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) = // since words are checked against word boundaries on the backend, // this should be the behavior on the front end as well. // currently the highlighter plugin does not support this out of the box. - const searchWords = [...suspectWords, ...bannedWords].filter(w => { - return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); - }).concat(linkText); + const searchWords = [...suspectWords, ...bannedWords] + .filter(w => { + return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); + }) + .concat(linkText); return ( -
  • +
  • @@ -46,53 +60,95 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) = {comment.user.name} - {timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {timeago().format( + comment.created_at || Date.now() - props.index * 60 * 1000, + lang.getLocale().replace('-', '_') + )} - props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} /> + + props.showBanUserDialog( + comment.user, + comment.id, + comment.status, + comment.status !== 'REJECTED' + )} + />
    - {comment.user.status === 'banned' ? - - - {lang.t('comment.banned_user')} - + {comment.user.status === 'banned' + ? + + {lang.t('comment.banned_user')} + : null} +
    Story: {comment.asset.title} - {!props.currentAsset && ( - Moderate → - )} + {!props.currentAsset && + Moderate →}

    {lang.t('comment.view_context')} + textToHighlight={comment.body} + /> + {' '} + + {lang.t('comment.view_context')} +

    +
    - {links ? Contains Link : null} + {links + ? + Contains Link + + : null}
    {actions.map((action, i) => { - const active = (action === 'REJECT' && comment.status === 'REJECTED') || - (action === 'APPROVE' && comment.status === 'ACCEPTED'); - return comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})} - rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />; + const active = + (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return ( + + (comment.status === 'ACCEPTED' + ? null + : props.acceptComment({commentId: comment.id}))} + rejectComment={() => + (comment.status === 'REJECTED' + ? null + : props.rejectComment({commentId: comment.id}))} + /> + ); })}
    +
    - { - flagActions && flagActions.length - ? - : null - } +
    + +
    + {flagActions && flagActions.length + ? + : null}
  • ); }; diff --git a/client/coral-framework/helpers/hoc.js b/client/coral-framework/helpers/hoc.js new file mode 100644 index 000000000..bb034b179 --- /dev/null +++ b/client/coral-framework/helpers/hoc.js @@ -0,0 +1,3 @@ +export function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 20aee7abb..d701b933f 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -21,7 +21,7 @@ export function getSlotElements(slot, props = {}) { .filter(o => o.module.slots[slot]) .map(o => o.module.slots[slot])); return components - .map((component, i) => React.createElement(component, {...props, key: i})); + .map((component, i) => React.createElement(component, {key: i, ...props})); } function getComponentFragments(components) { diff --git a/client/coral-framework/helpers/strings.js b/client/coral-framework/helpers/strings.js new file mode 100644 index 000000000..0a267da59 --- /dev/null +++ b/client/coral-framework/helpers/strings.js @@ -0,0 +1,4 @@ +export function capitalize(str) { + const newString = new String(str); + return newString.charAt(0).toUpperCase() + newString.slice(1); +} diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js new file mode 100644 index 000000000..831c356d2 --- /dev/null +++ b/client/coral-framework/hocs/index.js @@ -0,0 +1,7 @@ +import withFragments from './withFragments'; +import withReaction from './withReaction'; + +export default { + withFragments, + withReaction +}; diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index a2686d94b..a5060f27a 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,11 +1,7 @@ import React from 'react'; +import {getDisplayName} from '../helpers/hoc'; // TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. - -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component'; -} - export default fragments => WrappedComponent => { class WithFragments extends React.Component { render() { diff --git a/client/coral-framework/hocs/withReaction.js b/client/coral-framework/hocs/withReaction.js new file mode 100644 index 000000000..c383dbed8 --- /dev/null +++ b/client/coral-framework/hocs/withReaction.js @@ -0,0 +1,243 @@ +import React from 'react'; +import get from 'lodash/get'; +import uuid from 'uuid/v4'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getDisplayName} from '../helpers/hoc'; +import {compose, gql, graphql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {showSignInDialog} from 'coral-framework/actions/auth'; +import {capitalize} from 'coral-framework/helpers/strings'; +import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; + +export default reaction => WrappedComponent => { + if (typeof reaction !== 'string') { + console.error('Reaction must be a valid string'); + return null; + } + + reaction = reaction.toLowerCase(); + + class WithReactions extends React.Component { + render() { + const {comment} = this.props; + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const count = getTotalActionCount( + `${capitalize(reaction)}ActionSummary`, + comment + ); + + const alreadyReacted = () => !!reactionSummary; + + const withReactionProps = {reactionSummary, count, alreadyReacted}; + + return ; + } + } + + const isReaction = a => + a.__typename === `${capitalize(reaction)}ActionSummary`; + + const COMMENT_FRAGMENT = gql` + fragment ${capitalize(reaction)}Button_updateFragment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + } + `; + + const withDeleteReaction = graphql( + gql` + mutation deleteReaction($id: ID!) { + deleteAction(id:$id) { + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + deleteReaction: () => { + + const reactionSummary = getMyActionSummary( + `${capitalize(reaction)}ActionSummary`, + ownProps.comment + ); + + const reactionData = { + id: reactionSummary.current_user.id, + commentId: ownProps.comment.id + }; + + return mutate({ + variables: {id: reactionData.id}, + optimisticResponse: { + deleteAction: { + __typename: 'DeleteActionResponse', + errors: null + } + }, + update: proxy => { + const fragmentId = `Comment_${reactionData.commentId}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Check whether we liked this comment. + const idx = data.action_summaries.findIndex(isReaction); + if ( + idx < 0 || + get(data.action_summaries[idx], 'current_user.id') !== reactionData.id + ) { + return; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count - 1, + current_user: null + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const withPostReaction = graphql( + gql` + mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) { + create${capitalize(reaction)}(${reaction}: $${reaction}) { + ${reaction} { + id + } + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + postReaction: () => { + + const reactionData = { + item_id: ownProps.comment.id, + item_type: 'COMMENTS' + }; + + return mutate({ + variables: {[reaction]: reactionData}, + optimisticResponse: { + [`create${capitalize(reaction)}`]: { + __typename: `Create${capitalize(reaction)}Response`, + errors: null, + [reaction]: { + __typename: `${capitalize(reaction)}Action`, + id: uuid() + } + } + }, + update: (proxy, mutationResult) => { + const fragmentId = `Comment_${reactionData.item_id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Add our comment from the mutation to the end. + let idx = data.action_summaries.findIndex(isReaction); + + // Check whether we already reactioned this comment. + if (idx >= 0 && data.action_summaries[idx].current_user) { + return; + } + + if (idx < 0) { + + // Add initial action when it doesn't exist. + data.action_summaries.push({ + __typename: `${capitalize(reaction)}ActionSummary`, + count: 0, + current_user: null + }); + idx = data.action_summaries.length - 1; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count + 1, + current_user: mutationResult.data[ + `create${capitalize(reaction)}` + ][reaction] + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + } + }); + } + }) + } + ); + + const mapDispatchToProps = dispatch => + bindActionCreators({showSignInDialog}, dispatch); + + const enhance = compose( + withFragments({ + root: gql` + fragment ${capitalize(reaction)}Button_root on RootQuery { + me { + status + } + } + `, + comment: gql` + fragment ${capitalize(reaction)}Button_comment on Comment { + action_summaries { + ... on ${capitalize(reaction)}ActionSummary { + count + current_user { + id + } + } + } + }` + }), + connect(null, mapDispatchToProps), + withDeleteReaction, + withPostReaction + ); + + WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; + + return enhance(WithReactions); +}; diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index b85b69bcd..46ee704f4 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -1,6 +1,7 @@ import pym from './services/PymConnection'; import I18n from './modules/i18n/i18n'; import actions from './actions'; +import hocs from './hocs'; // TODO (bc): Deprecate old actions. Spreading actions is now needed. @@ -8,5 +9,6 @@ export default { pym, I18n, actions, + ...hocs, ...actions }; diff --git a/client/coral-plugin-api/index.js b/client/coral-plugin-api/index.js new file mode 100644 index 000000000..f0f5ad9cc --- /dev/null +++ b/client/coral-plugin-api/index.js @@ -0,0 +1 @@ +export {withReaction} from '../coral-framework/hocs'; diff --git a/plugins/coral-plugin-love/client/.babelrc b/plugins/coral-plugin-love/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/coral-plugin-love/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/coral-plugin-love/client/.eslintrc.json b/plugins/coral-plugin-love/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/coral-plugin-love/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/coral-plugin-love/client/LoveButton.js b/plugins/coral-plugin-love/client/LoveButton.js new file mode 100644 index 000000000..4364c4818 --- /dev/null +++ b/plugins/coral-plugin-love/client/LoveButton.js @@ -0,0 +1,52 @@ +import React from 'react'; +import {Icon} from 'coral-ui'; +import styles from './styles.css'; +import {I18n} from 'coral-framework'; +import translations from './translations.json'; +import {withReaction} from 'coral-plugin-api'; +const lang = new I18n(translations); + +class LoveButton extends React.Component { + handleClick = () => { + const { + postReaction, + deleteReaction, + showSignInDialog, + alreadyReacted + } = this.props; + const {root: {me}, comment} = this.props; + + // If the current user does not exist, trigger sign in dialog. + if (!me) { + showSignInDialog(); + return; + } + + // If the current user is banned, do nothing. + if (me.status === 'BANNED') { + return; + } + + if (alreadyReacted()) { + deleteReaction(); + } else { + postReaction(); + } + }; + + render() { + const {count, alreadyReacted} = this.props; + return ( + + ); + } +} + +export default withReaction('love')(LoveButton); diff --git a/plugins/coral-plugin-love/client/index.js b/plugins/coral-plugin-love/client/index.js new file mode 100644 index 000000000..f048963b4 --- /dev/null +++ b/plugins/coral-plugin-love/client/index.js @@ -0,0 +1,7 @@ +import LoveButton from './LoveButton'; + +export default { + slots: { + commentReactions: [LoveButton] + } +}; diff --git a/plugins/coral-plugin-love/client/styles.css b/plugins/coral-plugin-love/client/styles.css new file mode 100644 index 000000000..d48e7e28c --- /dev/null +++ b/plugins/coral-plugin-love/client/styles.css @@ -0,0 +1,26 @@ +.respect { + display: inline-block; +} + +.button { + color: #2a2a2a; + margin: 5px 10px 5px 0px; + background: none; + padding: 0px; + border: none; + font-size: inherit; + + &:hover { + color: #767676; + cursor: pointer; + } + + &.loved { + color: #e52338; + + &:hover { + color: #e52839; + cursor: pointer; + } + } +} diff --git a/plugins/coral-plugin-love/client/translations.json b/plugins/coral-plugin-love/client/translations.json new file mode 100644 index 000000000..a015efa77 --- /dev/null +++ b/plugins/coral-plugin-love/client/translations.json @@ -0,0 +1,10 @@ +{ + "en": { + "love": "Love", + "loved": "Loved" + }, + "es": { + "love": "Amo", + "loved": "Amé" + } +} diff --git a/plugins/coral-plugin-love/index.js b/plugins/coral-plugin-love/index.js new file mode 100644 index 000000000..48b4d16c0 --- /dev/null +++ b/plugins/coral-plugin-love/index.js @@ -0,0 +1,37 @@ +const {readFileSync} = require('fs'); +const path = require('path'); +const wrapResponse = require('../../graph/helpers/response'); + +module.exports = { + typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'), + resolvers: { + RootMutation: { + createLove(_, {love: {item_id, item_type}}, {mutators: {Action}}) { + return wrapResponse('love')(Action.create({item_id, item_type, action_type: 'LOVE'})); + } + } + }, + hooks: { + Action: { + __resolveType: { + post({action_type}) { + switch (action_type) { + case 'LOVE': + return 'LoveAction'; + } + } + } + }, + ActionSummary: { + __resolveType: { + post({action_type}) { + switch (action_type) { + case 'LOVE': + return 'LoveActionSummary'; + } + } + } + } + } +}; + diff --git a/plugins/coral-plugin-love/server/typeDefs.graphql b/plugins/coral-plugin-love/server/typeDefs.graphql new file mode 100644 index 000000000..e22b91a2d --- /dev/null +++ b/plugins/coral-plugin-love/server/typeDefs.graphql @@ -0,0 +1,70 @@ +enum ACTION_TYPE { + + # Represents a Love. + LOVE +} + +enum ASSET_METRICS_SORT { + + # Represents a LoveAction. + LOVE +} + +input CreateLoveInput { + + # The item's id for which we are to create a love. + item_id: ID! + + # The type of the item for which we are to create the love. + item_type: ACTION_ITEM_TYPE! +} + +# LoveAction is used by users who "love" a specific entity. +type LoveAction implements Action { + + # The ID of the action. + id: ID! + + # The author of the action. + user: User + + # The time when the Action was updated. + updated_at: Date + + # The time when the Action was created. + created_at: Date +} + +type LoveActionSummary implements ActionSummary { + + # The count of actions with this group. + count: Int + + # The current user's action. + current_user: LoveAction +} + +# A summary of counts related to all the Loves on an Asset. +type LoveAssetActionSummary implements AssetActionSummary { + + # Number of loves associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the loves. + actionableItemCount: Int +} + +type CreateLoveResponse implements Response { + + # The love that was created. + love: LoveAction + + # An array of errors relating to the mutation that occurred. + errors: [UserError] +} + +type RootMutation { + + # Creates a love on an entity. + createLove(love: CreateLoveInput!): CreateLoveResponse +} diff --git a/plugins/coral-plugin-mod/client/.babelrc b/plugins/coral-plugin-mod/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/coral-plugin-mod/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/coral-plugin-mod/client/.eslintrc.json b/plugins/coral-plugin-mod/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/coral-plugin-mod/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/coral-plugin-mod/client/components/Box.js b/plugins/coral-plugin-mod/client/components/Box.js new file mode 100644 index 000000000..15f5320b1 --- /dev/null +++ b/plugins/coral-plugin-mod/client/components/Box.js @@ -0,0 +1,8 @@ +import React from 'react'; +import styles from './styles.css' + +export default (props) => ( +
    + Comment Status: {props.comment.status} +
    +) \ No newline at end of file diff --git a/plugins/coral-plugin-mod/client/components/Container.js b/plugins/coral-plugin-mod/client/components/Container.js new file mode 100644 index 000000000..712031fdf --- /dev/null +++ b/plugins/coral-plugin-mod/client/components/Container.js @@ -0,0 +1,32 @@ +import React from 'react'; +import Box from './Box'; +import {Button} from 'coral-ui' +import styles from './styles.css'; + +export default class Footer extends React.Component { + constructor() { + super(); + + this.state = { + show: false + }; + } + + handleClick = () => { + this.setState(state => ({ + show: !state.show + })) + } + + render() { + const {show} = this.state; + return ( +
    + + {show ? : null} +
    + ); + } +} diff --git a/plugins/coral-plugin-mod/client/components/styles.css b/plugins/coral-plugin-mod/client/components/styles.css new file mode 100644 index 000000000..59bb63c3d --- /dev/null +++ b/plugins/coral-plugin-mod/client/components/styles.css @@ -0,0 +1,8 @@ +.container { + padding: 0 14px 10px; +} + +.box { + font-size: 12px; + padding: 10px 14px; +} \ No newline at end of file diff --git a/plugins/coral-plugin-mod/client/index.js b/plugins/coral-plugin-mod/client/index.js new file mode 100644 index 000000000..b0ea304f4 --- /dev/null +++ b/plugins/coral-plugin-mod/client/index.js @@ -0,0 +1,7 @@ +import Container from './components/Container'; + +export default { + slots: { + adminCommentDetailArea: [Container], + } +}; diff --git a/plugins/coral-plugin-mod/index.js b/plugins/coral-plugin-mod/index.js new file mode 100644 index 000000000..42d90e144 --- /dev/null +++ b/plugins/coral-plugin-mod/index.js @@ -0,0 +1,4 @@ +const {readFileSync} = require('fs'); +const path = require('path'); + +module.exports = {}; diff --git a/services/comments.js b/services/comments.js index 0ef15c552..e23e4e486 100644 --- a/services/comments.js +++ b/services/comments.js @@ -205,7 +205,7 @@ module.exports = class CommentsService { * @param {String} action the new action to the comment * @return {Promise} */ - static addAction(item_id, user_id, action_type, metadata) { + static addAction(item_id, user_id, action_type, metadata = {}) { return ActionsService.insertUserAction({ item_id, item_type: 'COMMENTS',