diff --git a/bin/cli-jobs b/bin/cli-jobs index c5aefc518..6048a69db 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); util.onshutdown([ - () => mongoose.disconnect() + () => mongoose.disconnect(), ]); /** @@ -20,17 +20,17 @@ util.onshutdown([ */ function processJobs() { - // Start the scraper processor. - scraper.process(); - - // Start the mail processor. - mailer.process(); - // The scraper only needs to shutdown when the scraper has actually been // started. util.onshutdown([ () => kue.Task.shutdown() ]); + + // Start the scraper processor. + scraper.process(); + + // Start the mail processor. + mailer.process(); } /** @@ -48,22 +48,13 @@ function removeJob(job) { })); } -/** - * Removes the jobs passed in and returns a promise. - * @param {Array} jobs array of jobs - * @return {Promise} - */ -function removeJobs(jobs) { - return Promise.all(jobs.map(removeJob)); -} - /** * Get the top n jobs with a specific state. * @param {String} [state='complete'] state to list jobs by * @param {Number} limit limit of jobs to load * @return {Promise} */ -function rangeJobsByState(state = 'complete', limit) { +function rangeJobsByState(state, limit) { return new Promise((resolve, reject) => { kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => { if (err) { @@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) { }); } +async function getJobBatch(n, includeStuck) { + let jobs = []; + + jobs = await rangeJobsByState('complete', n); + + if (includeStuck) { + jobs = jobs.concat(await rangeJobsByState('failed', n)); + } + + return jobs; +} + /** * Cleans up the jobs that are in the queue. */ async function cleanupJobs(options) { + + // The scraper only needs to shutdown when the scraper has actually been + // started. + util.onshutdown([ + () => kue.Task.shutdown() + ]); + const n = 100; try { - const joblists = await Promise.all([ - rangeJobsByState('complete', n), - options.stuck ? rangeJobsByState('failed', n) : false - ]); - await joblists.filter((jobs) => jobs).map(removeJobs); + // Connect to redis by establishing a queue. + kue.Task.connect(); + + let jobCount = 0; + let jobs = await getJobBatch(n, options.stuck); + + while (jobs.length > 0) { + + // Remove all the jobs. + await Promise.all(jobs.map((job) => removeJob(job))); + + jobCount += jobs.length; + + // Get the next batch of jobs. + jobs = await getJobBatch(n, options.stuck); + } util.shutdown(); - console.log('Removed old jobs'); + console.log(`Removed ${jobCount} jobs`); } catch (err) { console.error(err); util.shutdown(1); diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index d8e1b5377..362143d38 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import {Router, Route, IndexRedirect, IndexRoute} from 'react-router'; import Configure from 'routes/Configure'; -import Dashboard from 'routes/Dashboard'; import Install from 'routes/Install'; import Stories from 'routes/Stories'; import Community from 'routes/Community/containers/Community'; @@ -18,7 +17,6 @@ const routes = ( - {/* Community Routes */} diff --git a/client/coral-admin/src/components/Drawer.js b/client/coral-admin/src/components/Drawer.js index 70da8ad5e..29ee8d17e 100644 --- a/client/coral-admin/src/components/Drawer.js +++ b/client/coral-admin/src/components/Drawer.js @@ -12,20 +12,14 @@ const CoralDrawer = ({handleLogout, auth = {}}) => ( { auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
- - {t('configure.dashboard')} - { can(auth.user, 'MODERATE_COMMENTS') && ( - {t('configure.moderate')} - + ) } - - {t('configure.dashboard')} - { can(auth.user, 'MODERATE_COMMENTS') && ( - {t('configure.moderate')} {(root.premodCount !== 0 || root.reportedCount !== 0) && } - + ) } { - return ( -
-

{t('dashboard.most_conversations')}

-
-

{t('streams.article')}

-

{t('dashboard.comment_count')}

-
-
- { - assets.length - ? assets.map((asset) => { - return ( -
- Moderate -

{asset.commentCount}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_activity')}
- } -
-
- ); -}; - -ActivityWidget.propTypes = { - assets: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - url: PropTypes.string, - commentCount: PropTypes.number, - author: PropTypes.string, - created_at: PropTypes.string - })).isRequired -}; - -export default ActivityWidget; diff --git a/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js b/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js deleted file mode 100644 index a2e6ea9f1..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/CountdownTimer.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './Dashboard.css'; -import {Icon} from 'coral-ui'; - -import t from 'coral-framework/services/i18n'; -const refreshIntervalSeconds = 60 * 5; - -// TODO: refactor out storage code into redux. - -class CountdownTimer extends React.Component { - - static contextTypes = { - storage: PropTypes.object, - }; - - static propTypes = { - handleTimeout: PropTypes.func.isRequired - } - - constructor (props, context) { - super(props, context); - const {storage} = context; - try { - if (storage && storage.getItem('coral:dashboardNote') === null) { - storage.setItem('coral:dashboardNote', 'show'); - } - } catch (e) { - - // above will fail in Private Mode in some browsers. - } - - this.state = { - secondsUntilRefresh: refreshIntervalSeconds, - dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show' - }; - } - - componentWillMount () { - this.interval = setInterval(() => { // the countdown timer - let nextCount = this.state.secondsUntilRefresh - 1; - if (nextCount < 0) { - nextCount = refreshIntervalSeconds; - return this.props.handleTimeout(); - } - this.setState({secondsUntilRefresh: nextCount}); - }, 1000); - } - - componentWillUnmount () { - window.clearInterval(this.interval); - } - - formatTime = () => { - const minutes = Math.floor(this.state.secondsUntilRefresh / 60); - let seconds = (this.state.secondsUntilRefresh % 60).toString(); - if (seconds.length < 2) { - seconds = `0${seconds}`; - } - - return `${minutes}:${seconds}`; - } - - dismissNote = () => { - const {storage} = this.context; - try { - if (storage) { - storage.setItem('coral:dashboardNote', 'hide'); - } - } catch (e) { - - // when setItem fails in Safari Private mode - this.setState({dashboardNote: 'hide'}); - } - } - - render () { - const {storage} = this.context; - const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') || - this.state.dashboardNote === 'hide'; // for Safari Incognito - return ( -

- × - {t('dashboard.next_update', this.formatTime())} {t('dashboard.auto_update')} -

- ); - } -} - -export default CountdownTimer; diff --git a/client/coral-admin/src/routes/Dashboard/components/Dashboard.css b/client/coral-admin/src/routes/Dashboard/components/Dashboard.css deleted file mode 100644 index aee7e702b..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/Dashboard.css +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @TODO: deprecated as this file contains styles from multiple components. Please refactor. - */ - -.Dashboard { - display: flex; - max-width: 1280px; - margin: 0 auto; -} - -.heading { - margin: 0; - font-size: 1.5rem; - font-weight: bold; -} - -.autoUpdate { - background-color: #d5d5d5; - padding: 3px 10px 10px 10px; - margin-bottom: 0; - - i { - position: relative; - top: 7px; - } - - b { - float: right; - border-radius: 20px; - cursor: pointer; - background-color: #c0c0c0; - width: 30px; - height: 30px; - text-align: center; - top: 4px; - position: relative; - line-height: 1.7em; - font-size: 1.3em; - } -} diff --git a/client/coral-admin/src/routes/Dashboard/components/Dashboard.js b/client/coral-admin/src/routes/Dashboard/components/Dashboard.js deleted file mode 100644 index ba224a3b9..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/Dashboard.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import FlagWidget from './FlagWidget'; -import ActivityWidget from './ActivityWidget'; -import CountdownTimer from './CountdownTimer'; -import styles from './Dashboard.css'; - -export default ({root: {assetsByActivity, assetsByFlag}, reloadData}) => ( -
- -
- - -
-
-); diff --git a/client/coral-admin/src/routes/Dashboard/components/FlagWidget.js b/client/coral-admin/src/routes/Dashboard/components/FlagWidget.js deleted file mode 100644 index 2f3548cf1..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/FlagWidget.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {Link} from 'react-router'; -import styles from './Widget.css'; - -import t from 'coral-framework/services/i18n'; - -const FlagWidget = ({assets}) => { - - return ( -
-

{t('dashboard.most_flags')}

-
-

{t('streams.article')}

-

{t('dashboard.flags')}

-
-
- { - assets.length - ? assets.map((asset) => { - let flagSummary = null; - if (asset.action_summaries) { - flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary'); - } - - return ( -
- Moderate -

{flagSummary ? flagSummary.actionCount : 0}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_flags')}
- } -
-
- ); -}; - -FlagWidget.propTypes = { - assets: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - url: PropTypes.string, - action_summaries: PropTypes.array, - author: PropTypes.string, - created_at: PropTypes.string - })).isRequired -}; - -export default FlagWidget; diff --git a/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js b/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js deleted file mode 100644 index 7525f7223..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/LikeWidget.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {Link} from 'react-router'; -import styles from './Widget.css'; -import t from 'coral-framework/services/i18n'; - -const LikeWidget = ({assets}) => { - - return ( -
-

Articles with the most likes

-
-

{t('streams.article')}

-

{t('modqueue.likes')}

-
-
- { - assets.length - ? assets.map((asset) => { - const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary'); - return ( -
- Moderate -

{likeSummary ? likeSummary.actionCount : 0}

- -

{asset.title}

-
-

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

-
- ); - }) - :
{t('dashboard.no_likes')}
- } -
-
- ); -}; - -LikeWidget.propTypes = { - assets: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - url: PropTypes.string, - action_summaries: PropTypes.array, - author: PropTypes.string, - created_at: PropTypes.string - })).isRequired -}; - -export default LikeWidget; diff --git a/client/coral-admin/src/routes/Dashboard/components/MostLikedCommentsWidget.js b/client/coral-admin/src/routes/Dashboard/components/MostLikedCommentsWidget.js deleted file mode 100644 index 6bbb67c50..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/MostLikedCommentsWidget.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import ModerationQueue from 'coral-admin/src/containers/ModerationQueue/ModerationQueue'; -import styles from './Widget.css'; -import BanUserDialog from 'coral-admin/src/components/BanUserDialog'; -import t from 'coral-framework/services/i18n'; - -const MostLikedCommentsWidget = (props) => { - const { - comments, - moderation, - settings, - handleBanUser, - showBanUserDialog, - hideBanUserDialog, - acceptComment, - rejectComment - } = props; - - return ( -
-

{t('most_liked_comments')}

- - -
- ); -}; - -export default MostLikedCommentsWidget; diff --git a/client/coral-admin/src/routes/Dashboard/components/Widget.css b/client/coral-admin/src/routes/Dashboard/components/Widget.css deleted file mode 100644 index e94d2313f..000000000 --- a/client/coral-admin/src/routes/Dashboard/components/Widget.css +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @TODO: deprecated as this file contains styles from multiple components. Please refactor. - */ - -:root { - --row-height: 60px; -} - -.widget { - margin: 10px 5px 5px 5px; - box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2); - flex: 1; - background-color: white; - box-sizing: border-box; -} - -.widget * { - box-sizing: border-box; -} - -.heading { - margin: 0; - padding-left: 10px; - font-size: 1.3rem; - font-weight: 600; - color: #2c2c2c; -} - -.widgetTable { - height: calc(var(--row-height) * 10); -} - -.widgetTable + div:after { - content: ''; - clear: both; - display: block; -} - -.widgetHead p { - color: #2c2c2c; - font-weight: 500; - padding: 10px; - text-align: left; - text-transform: capitalize; - display: inline-block; - box-sizing: border-box; - margin-bottom: 0; -} - -.widgetHead p:last-child { - float: right; - margin-right: 100px; -} - -.rowLinkify { - border-bottom: 1px solid lightgrey; - color: #555; - height: var(--row-height); - padding: 10px; - transition: background-color 200ms; -} - -.rowLinkify:last-child { - border-bottom: none; -} - -.rowLinkify:hover { - background-color: #f8f8f8; - pointer: default; -} - -.linkToAsset { - display: inline-block; - text-decoration: none; -} - -.linkToModerate { - background-color: #BDBDBD; - padding: 10px 14px; - text-decoration: none; - color: black; - float: right; - margin-left: 15px; - transition: background-color 200ms; -} - -.linkToModerate:hover { - background-color: #9E9E9E; -} - -.lede { - font-size: 0.9em; - color: #aaa; -} - -.assetTitle { - color: #555; - text-decoration: none; - font-size: 1.2em; - font-weight: 500; - margin: 0; -} - -.widgetCount { - color: #555; - font-size: 1.3em; - font-weight: 400; - float: right; - margin-top: 7px; -} diff --git a/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js b/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js deleted file mode 100644 index e2e9ef28c..000000000 --- a/client/coral-admin/src/routes/Dashboard/containers/Dashboard.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import {connect} from 'react-redux'; -import Dashboard from '../components/Dashboard'; -import {compose, gql} from 'react-apollo'; -import withQuery from 'coral-framework/hocs/withQuery'; - -import {Spinner} from 'coral-ui'; - -class DashboardContainer extends React.Component { - - reloadData = () => { - this.props.data.refetch(); - } - - render () { - if (this.props.data.loading) { - return ; - } - return ; - } -} - -export const witDashboardQuery = withQuery(gql` - query CoralAdmin_Dashboard($from: Date!, $to: Date!) { - assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) { - ...CoralAdmin_Metrics - } - assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) { - ...CoralAdmin_Metrics - } - } - fragment CoralAdmin_Metrics on Asset { - id - title - url - author - created_at - commentCount - action_summaries { - actionCount - actionableItemCount - } - } -`, { - options: ({windowStart, windowEnd}) => { - return { - variables: { - from: windowStart, - to: windowEnd, - } - }; - } - }); - -const mapStateToProps = (state) => { - return { - windowStart: state.dashboard.windowStart, - windowEnd: state.dashboard.windowEnd, - }; -}; - -export default compose( - connect(mapStateToProps), - witDashboardQuery, -)(DashboardContainer); diff --git a/client/coral-admin/src/routes/Dashboard/index.js b/client/coral-admin/src/routes/Dashboard/index.js deleted file mode 100644 index 5bbb476e2..000000000 --- a/client/coral-admin/src/routes/Dashboard/index.js +++ /dev/null @@ -1 +0,0 @@ -export {default} from './containers/Dashboard.js'; diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 1b75d23cc..ca6f0224a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -138,7 +138,7 @@ class ModerationContainer extends Component { }, ]; - this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param)); + this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param)); } unsubscribe() { diff --git a/client/coral-embed-stream/src/containers/ExtendableTabPanel.js b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js index 362f6bf89..3fca664e7 100644 --- a/client/coral-embed-stream/src/containers/ExtendableTabPanel.js +++ b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js @@ -1,7 +1,6 @@ import React from 'react'; import ExtendableTabPanel from '../components/ExtendableTabPanel'; import {connect} from 'react-redux'; -import omit from 'lodash/omit'; import {TabPane} from 'coral-ui'; import ExtendableTab from '../components/ExtendableTab'; import {getShallowChanges} from 'coral-framework/utils'; @@ -128,7 +127,7 @@ ExtendableTabPanelContainer.propTypes = { }; const mapStateToProps = (state) => ({ - reduxState: omit(state, 'apollo'), + reduxState: state, }); export default connect(mapStateToProps, null)(ExtendableTabPanelContainer); diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.css b/client/coral-embed-stream/src/tabs/stream/components/Comment.css index f5bead3b8..15d42bef0 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Comment.css +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.css @@ -119,6 +119,8 @@ .commentInfoBar { margin-left: auto; + flex: 1; + text-align: right; } @keyframes enter { @@ -131,9 +133,6 @@ animation: enter 1000ms; } -.commentContainer { -} - .commentAvatar { max-width: 60px; } @@ -152,9 +151,32 @@ } .header { - display: flex; - align-items: center; margin: 10px 0; + display: flex; +} + +.headerContainer { + flex: 1; + flex-direction: column; + align-items: flex-start; + + @media (min-width: 480px) { + display: flex; + align-items: center; + flex-direction: row; + } +} + +.tagsContainer { + display: flex; +} + +.tagsContainer > * { + margin: 3px; + + &:first-child { + margin-left: 0px; + } } .content { diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js index 3da59afdc..ba14e3c7f 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js @@ -452,39 +452,43 @@ export default class Comment extends React.Component {
- - - {isStaff(comment.tags) ? Staff : null} - - - - +
- { - (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null - } - + +
+ {isStaff(comment.tags) ? Staff : null} + + +
+ + + + { + (comment.editing && comment.editing.edited) + ?  ({t('comment.edited')}) + : null + } + +
new Date(); - const showCommentBox = loggedIn && ((!banned & !temporarilySuspended && !highlightedComment) || keepCommentBox); - + const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); const slotProps = {data}; const slotQueryData = {root, asset}; @@ -250,18 +254,17 @@ class Stream extends React.Component { content={asset.settings.infoBoxContent} enable={asset.settings.infoBoxEnable} /> - - - - + {questionBoxEnable && ( + + + + )} {!banned && temporarilySuspended && diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 63f1edf67..a04ee6f05 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -190,11 +190,9 @@ body { background-color: #4C1066; color: white; display: inline-block; - margin: 0px 5px; - padding: 5px 5px; border-radius: 2px; font-size: 12px; - font-weight: bold; + padding: 5px 6px; } /* Comment Action Styles */ diff --git a/client/coral-framework/components/CommentTimestamp.css b/client/coral-framework/components/CommentTimestamp.css index a4620902c..0c54019ff 100644 --- a/client/coral-framework/components/CommentTimestamp.css +++ b/client/coral-framework/components/CommentTimestamp.css @@ -2,5 +2,6 @@ display: inline-block; color: #696969; font-size: 12px; + white-space: nowrap; } diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js index 51a849448..8470fadba 100644 --- a/client/coral-framework/components/IfSlotIsEmpty.js +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -1,7 +1,6 @@ import React, {Children} from 'react'; import {connect} from 'react-redux'; import PropTypes from 'prop-types'; -import omit from 'lodash/omit'; import {getShallowChanges} from 'coral-framework/utils'; class IfSlotIsEmpty extends React.Component { @@ -39,7 +38,7 @@ IfSlotIsEmpty.propTypes = { }; const mapStateToProps = (state) => ({ - reduxState: omit(state, 'apollo'), + reduxState: state, }); export default connect(mapStateToProps, null)(IfSlotIsEmpty); diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js index 6186e2166..a498ad3aa 100644 --- a/client/coral-framework/components/IfSlotIsNotEmpty.js +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -1,7 +1,6 @@ import React, {Children} from 'react'; import {connect} from 'react-redux'; import PropTypes from 'prop-types'; -import omit from 'lodash/omit'; import {getShallowChanges} from 'coral-framework/utils'; class IfSlotIsNotEmpty extends React.Component { @@ -39,7 +38,7 @@ IfSlotIsNotEmpty.propTypes = { }; const mapStateToProps = (state) => ({ - reduxState: omit(state, 'apollo'), + reduxState: state, }); export default connect(mapStateToProps, null)(IfSlotIsNotEmpty); diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index e1acd82f4..0404df80f 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -2,7 +2,6 @@ import React from 'react'; import cn from 'classnames'; import styles from './Slot.css'; import {connect} from 'react-redux'; -import omit from 'lodash/omit'; import kebabCase from 'lodash/kebabCase'; import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; @@ -121,7 +120,7 @@ Slot.propTypes = { }; const mapStateToProps = (state) => ({ - reduxState: omit(state, 'apollo'), + reduxState: state, }); export default connect(mapStateToProps, null)(Slot); diff --git a/client/coral-framework/components/TalkProvider.js b/client/coral-framework/components/TalkProvider.js index 418bb3760..1b9c5ca9c 100644 --- a/client/coral-framework/components/TalkProvider.js +++ b/client/coral-framework/components/TalkProvider.js @@ -9,17 +9,18 @@ class TalkProvider extends React.Component { pym: this.props.pym, plugins: this.props.plugins, rest: this.props.rest, - graphqlRegistry: this.props.graphqlRegistry, + graphql: this.props.graphql, notification: this.props.notification, storage: this.props.storage, history: this.props.history, + store: this.props.store, }; } render() { - const {children, client, store} = this.props; + const {children, client} = this.props; return ( - + {children} ); @@ -31,10 +32,11 @@ TalkProvider.childContextTypes = { eventEmitter: PropTypes.object, plugins: PropTypes.object, rest: PropTypes.func, - graphqlRegistry: PropTypes.object, + graphql: PropTypes.object, notification: PropTypes.object, storage: PropTypes.object, history: PropTypes.object, + store: PropTypes.object, }; export default TalkProvider; diff --git a/client/coral-framework/graphql/reduceDocument.js b/client/coral-framework/graphql/reduceDocument.js new file mode 100644 index 000000000..12e0954d7 --- /dev/null +++ b/client/coral-framework/graphql/reduceDocument.js @@ -0,0 +1,293 @@ +import { + getMainDefinition, + getFragmentDefinitions, + createFragmentMap, + shouldInclude, + getOperationDefinition, +} from 'apollo-utilities'; + +function getDirectivesID(directives) { + let id = ''; + directives.forEach((directive) => { + id += `@${directive.name.value}(`; + let first = true; + directive.arguments.forEach((arg) => { + if (!first) { + id += ','; + } + first = false; + const value = arg.value.kind === 'Variable' + ? `$${arg.value.name.value}` + : arg.value.value; + id += `${arg.name.value}:${value}`; + }); + id += ')'; + }); + return id; +} + +// If two definitions have the same id, they can be merged. +function getDefinitionID(definition) { + + // Only merge when directives are exactly the same. + const trailing = definition.directives.length + ? `_${getDirectivesID(definition.directives)}` + : ''; + + switch (definition.kind) { + case 'FragmentSpread': + return `FragmentSpread_${definition.name.value}`; + case 'Field': + return `Field_${definition.alias ? definition.alias.value : definition.name.value}${trailing}`; + case 'InlineFragment': + return `InlineFragment_${definition.typeCondition.name.value}${trailing}`; + default: + throw new Error(`unknown definition kind ${definition.kind}`); + } +} + +/** + * Merge selections of 2 definitions. + */ +export function mergeDefinitions(a, b) { + const name = getDefinitionID(a); + + if (!!a.selectionSet !== !!b.selectionSet) { + throw Error(`incompatible field definition for ${name}`); + } + + if (!a.selectionSet) { + return b; + } + + const selectionSet = mergeSelectionSets(a.selectionSet, b.selectionSet); + + return { + ...b, + selectionSet, + }; +} + +/** + * Merge selectionSets + */ +export function mergeSelectionSets(a, b) { + const selectionsMap = [...a.selections, ...b.selections].reduce((o, sel) => { + const selName = getDefinitionID(sel); + if (!(selName in o)) { + o[selName] = sel; + return o; + } + o[selName] = mergeDefinitions(o[selName], sel); + return o; + }, {}); + + const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]); + + return { + ...b, + selections, + }; +} + +function getFragmentOrDie(name, execContext) { + const { + rawFragmentMap, + fragmentMap, + } = execContext; + + if (!(name in fragmentMap)) { + const fragment = rawFragmentMap[name]; + + if (!fragment) { + throw new Error(`fragment ${fragment.name.value} does not exist`); + } + + const typeCondition = fragment.typeCondition.name.value; + const transformed = transformDefinition(fragment, execContext, `type.${typeCondition}`, typeCondition); + fragmentMap[name] = transformed; + } + + return fragmentMap[name]; +} + +/** + * Return selections with resolved named fragments and directives. + */ +function getTransformedSelections(definition, path, gqlType, execContext) { + const { + variables, + } = execContext; + + const selectionsMap = definition.selectionSet.selections.reduce((o, sel) => { + if (variables && !shouldInclude(sel, variables)) { + + // Skip this entirely + return o; + } + if (sel.kind !== 'FragmentSpread') { + const transformed = transformDefinition(sel, execContext, path, gqlType); + const name = getDefinitionID(sel); + + // Merge existing value. + if (name in o) { + o[name] = mergeDefinitions(o[name], transformed); + return o; + } + + o[name] = transformed; + return o; + } + + const fragment = getFragmentOrDie(sel.name.value, execContext); + const typeCondition = fragment.typeCondition.name.value; + + // Turn NamedFragment into an InlineFragment. + if (gqlType !== typeCondition || fragment.directives.length) { + const node = { + ...fragment, + kind: 'InlineFragment', + }; + const name = getDefinitionID(node); + + // Merge existing value. + if (name in o) { + o[name] = mergeDefinitions(o[name], node); + return o; + } + + o[name] = node; + return o; + } + + // Merge NamedFragment directly into selections. + const fragmentSelections = fragment.selectionSet.selections; + fragmentSelections.forEach((s) => { + + if (variables && !shouldInclude(s, variables)) { + + // Skip this entirely + return; + } + + const selName = getDefinitionID(s); + if (!(selName in o)) { + o[selName] = s; + return; + } + + o[selName] = mergeDefinitions(o[selName], s); + }); + return o; + }, {}); + + const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]); + return selections; +} + +/** + * Resolve named fragments and directives in a definition. + */ +function transformDefinition(definition, execContext, path = '', type = null) { + if (!definition.selectionSet) { + return definition; + } + + const {typeGetter} = execContext; + + if (definition.kind === 'Field') { + const fieldName = definition.name.value; + path = `${path}.${fieldName}`; + + if (typeGetter) { + type = typeGetter(path); + } + } + + // InlineFragments + else if(!type && typeGetter) { + type = typeGetter(path); + } + + return { + ...definition, + selectionSet: { + ...definition.selectionSet, + selections: getTransformedSelections(definition, path, type, execContext), + }, + }; +} + +export default function reduceDocument(document, options = {}) { + const mainDefinition = getMainDefinition(document); + const fragments = getFragmentDefinitions(document); + const operationDefinition = getOperationDefinition(document); + const path = operationDefinition + ? operationDefinition.operation + : `type.${mainDefinition.typeCondition.name.value}`; + + const execContext = { + rawFragmentMap: createFragmentMap(fragments), + fragmentMap: options.fragmentMap || {}, + variables: options.variables, + typeGetter: options.typeGetter || (() => null), + }; + + return { + kind: 'Document', + definitions: [transformDefinition(mainDefinition, execContext, path)], + }; +} + +function getObjectType(fieldType) { + if (['NON_NULL', 'LIST'].indexOf(fieldType.kind) > -1) { + return getObjectType(fieldType.ofType); + } + return fieldType.name; +} + +function getFieldType(parentType, fieldName) { + const field = parentType.fields.find((f) => f.name === fieldName); + return getObjectType(field.type); +} + +export function createTypeGetter(introspectionData) { + const types = {}; + introspectionData.__schema.types.forEach((type) => types[type.name] = type); + + const result = { + 'query': introspectionData.__schema.queryType.name, + 'mutation': introspectionData.__schema.mutationType.name, + 'subscription': introspectionData.__schema.subscriptionType.name, + }; + + return (path) => { + if (result[path]) { + return result[path]; + } + let currentPath = ''; + const parts = path.split('.'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + // Handle special path e.g. 'type.ROOT_QUERY.fieldName' + if (part === 'type') { + const type = parts[i + 1]; + const nextPath = `type.${type}`; + result[nextPath] = type; + currentPath = nextPath; + i++; + continue; + } + + const nextPath = currentPath ? `${currentPath}.${part}` : part; + if (nextPath in result) { + currentPath = nextPath; + continue; + } + result[nextPath] = getFieldType(types[result[currentPath]], part); + currentPath = nextPath; + } + return result[path]; + }; +} diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index be6d07fed..3411e7552 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -64,18 +64,15 @@ function hasEqualLeaves(a, b, path = '') { export default (fragments) => hoistStatics((BaseComponent) => { class WithFragments extends React.Component { static contextTypes = { - graphqlRegistry: PropTypes.object, + graphql: PropTypes.object, }; get graphqlRegistry() { - return this.context.graphqlRegistry; + return this.context.graphql.registry; } resolveDocument(documentOrCallback) { - const document = typeof documentOrCallback === 'function' - ? documentOrCallback(this.props, this.context) - : documentOrCallback; - return this.graphqlRegistry.resolveFragments(document); + return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context); } fragments = mapValues(fragments, (val) => this.resolveDocument(val)); diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index cb6bc3789..639dbd461 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -42,18 +42,15 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { static contextTypes = { eventEmitter: PropTypes.object, store: PropTypes.object, - graphqlRegistry: PropTypes.object, + graphql: PropTypes.object, }; get graphqlRegistry() { - return this.context.graphqlRegistry; + return this.context.graphql.registry; } resolveDocument(documentOrCallback) { - const document = typeof documentOrCallback === 'function' - ? documentOrCallback(this.props, this.context) - : documentOrCallback; - return this.graphqlRegistry.resolveFragments(document); + return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context); } // Lazily resolve fragments from graphRegistry to support circular dependencies. diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index 9548827ac..8f0155a11 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -3,6 +3,8 @@ import {graphql} from 'react-apollo'; import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; import hoistStatics from 'recompose/hoistStatics'; +import {getOperationName} from 'apollo-client/queries/getFromAST'; +import throttle from 'lodash/throttle'; const withSkipOnErrors = (reducer) => (prev, action, ...rest) => { if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) { @@ -39,24 +41,30 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { return class WithQuery extends React.Component { static contextTypes = { eventEmitter: PropTypes.object, - graphqlRegistry: PropTypes.object, + graphql: PropTypes.object, + client: PropTypes.object, }; // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; + resolvedDocument = null; lastNetworkStatus = null; data = null; name = ''; + // Pending subscription data. + subscriptionQueue = []; + get graphqlRegistry() { - return this.context.graphqlRegistry; + return this.context.graphql.registry; + } + + get client() { + return this.context.client; } resolveDocument(documentOrCallback) { - const document = typeof documentOrCallback === 'function' - ? documentOrCallback(this.props, this.context) - : documentOrCallback; - return this.graphqlRegistry.resolveFragments(document); + return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context); } emitWhenNeeded(data) { @@ -72,6 +80,66 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { this.context.eventEmitter.emit(`query.${this.name}.${status}`, {variables, data: root}); } + // Handle any pending susbcription data in the subscription queue at max once every second. + // Updates are batched in written into apollo in one go. + processSubscriptionQueue = throttle(() => { + const variables = typeof this.wrappedOptions === 'function' + ? this.wrappedOptions(this.props).variables + : this.wrappedOptions.variables; + + const previousResult = this.client.readQuery({ + query: this.resolvedDocument, + variables, + }); + + let result = previousResult; + + this.subscriptionQueue.forEach(([updateQuery, data]) => { + result = updateQuery(result, {subscriptionData: {data}}); + }); + + if (result !== previousResult) { + this.client.writeQuery({ + query: this.resolvedDocument, + variables, + data: result, + }); + } + this.subscriptionQueue = []; + }, 1000); + + subscribeToMoreThrottled = ({document, variables, updateQuery}) => { + + // We need to add the typenames and resolve fragments. + const query = this.resolveDocument(document); + const handler = (error, data) => { + if (error) { + + // TODO: shuld this show a notification? + console.error(error); + return; + } + if (data) { + this.subscriptionQueue.push([updateQuery, data]); + + // Triggers the throttled subscription queue processor. + this.processSubscriptionQueue(); + } + }; + + // Start subscription. + const request = { + query, + variables, + operationName: getOperationName(query), + }; + + const id = this.client.networkInterface.subscribe(request, handler); + + // Return unsubscribe callback. + return () => this.client.networkInterface.unsubscribe(id); + }; + nextData(data) { this.emitWhenNeeded(data); @@ -102,6 +170,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { stopPolling: data.stopPolling, refetch: data.refetch, updateQuery: data.updateQuery, + subscribeToMoreThrottled: this.subscribeToMoreThrottled, subscribeToMore: (stmArgs) => { const resolvedDocument = this.resolveDocument(stmArgs.document); @@ -190,10 +259,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { getWrapped = () => { if (!this.memoized) { - const resolvedDocument = this.resolveDocument(document); - this.name = getDefinitionName(resolvedDocument); + this.resolvedDocument = this.resolveDocument(document); + this.name = getDefinitionName(this.resolvedDocument); this.memoized = graphql( - resolvedDocument, + this.resolvedDocument, {...this.wrappedConfig, options: this.wrappedOptions}, )(WrappedComponent); } diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js index 85144fe60..3ef6629c1 100644 --- a/client/coral-framework/services/bootstrap.js +++ b/client/coral-framework/services/bootstrap.js @@ -12,6 +12,7 @@ import {BASE_PATH} from 'coral-framework/constants/url'; import {createPluginsService} from './plugins'; import {createNotificationService} from './notification'; import {createGraphQLRegistry} from './graphqlRegistry'; +import {createGraphQLService} from './graphql'; import globalFragments from 'coral-framework/graphql/fragments'; import {createStorage, createPymStorage} from 'coral-framework/services/storage'; import {createHistory} from 'coral-framework/services/history'; @@ -121,7 +122,13 @@ export async function createContext({ introspectionData, }); const plugins = createPluginsService(pluginsConfig); - const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)); + const graphql = createGraphQLService( + createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)), + { + introspectionData, + optimize: process.env.NODE_ENV === 'production', + }, + ); if (!notification) { // Use default notification service (pym based) @@ -134,7 +141,7 @@ export async function createContext({ plugins, eventEmitter, rest, - graphqlRegistry, + graphql, notification, storage, history, @@ -143,13 +150,13 @@ export async function createContext({ }; // Load framework fragments. - Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key])); + Object.keys(globalFragments).forEach((key) => graphql.registry.addFragment(key, globalFragments[key])); // Register graphql extension - graphqlRegistry.add(graphqlExtension); + graphql.registry.add(graphqlExtension); // Register plugin graphql extensions. - plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext)); + plugins.getGraphQLExtensions().forEach((ext) => graphql.registry.add(ext)); // Load plugin translations. plugins.getTranslations().forEach((t) => loadTranslations(t)); @@ -159,21 +166,28 @@ export async function createContext({ pym.sendMessage('event', JSON.stringify({eventName, value})); }); + // Create our redux store. const finalReducers = { ...reducers, ...plugins.getReducers(), - apollo: client.reducer(), }; store = createStore(finalReducers, [ - client.middleware(), thunk.withExtraArgument(context), - apolloErrorReporter, createReduxEmitter(eventEmitter), ]); context.store = store; + // Create apollo redux store. + context.apolloStore = createStore({ + apollo: client.reducer(), + }, [ + client.middleware(), + apolloErrorReporter, + createReduxEmitter(eventEmitter), + ]); + // Run pre initialization. if (preInit) { await preInit(context); diff --git a/client/coral-framework/services/graphql.js b/client/coral-framework/services/graphql.js new file mode 100644 index 000000000..859d57d8a --- /dev/null +++ b/client/coral-framework/services/graphql.js @@ -0,0 +1,39 @@ +import reduceDocument, {createTypeGetter} from '../graphql/reduceDocument'; +import {addTypenameToDocument} from 'apollo-client/queries/queryTransform'; + +/** + * createGraphQLService + * @param {string} basename base path of the url + * @return {Object} histor service + */ +export function createGraphQLService(registry, { + introspectionData, + optimize = false, +}) { + const reduceOptions = { + typeGetter: optimize && introspectionData ? createTypeGetter(introspectionData) : null, + + // Use shared fragment map. + // Attention: Fragment names must be unique otherwise weird things will happen. + fragmentMap: {}, + }; + + return { + registry, + resolveDocument(documentOrCallback, props, context) { + let document = typeof documentOrCallback === 'function' + ? documentOrCallback(props, context) + : documentOrCallback; + document = registry.resolveFragments(document); + + if (optimize) { + document = reduceDocument(document, reduceOptions); + } + + // We also add typenames to the document which apollo would usually do, + // but we also use the network interface in subscriptions directly + // which require the resolved typenames. + return addTypenameToDocument(document); + }, + }; +} diff --git a/config.js b/config.js index 86cacaa83..d0eb3fbc8 100644 --- a/config.js +++ b/config.js @@ -182,7 +182,12 @@ const CONFIG = { DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE', // TRUST_THRESHOLDS defines the thresholds used for automoderation. - TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1' + TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1', + + // IGNORE_FLAGS_AGAINST_STAFF disables staff members from entering the + // reported queue from comments after this was enabled and from reports + // against the staff members user account. + IGNORE_FLAGS_AGAINST_STAFF: process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF === 'TRUE', }; //============================================================================== diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md index 74099258d..94a6b402d 100644 --- a/docs/_docs/02-02-advanced-configuration.md +++ b/docs/_docs/02-02-advanced-configuration.md @@ -456,3 +456,8 @@ Could be read as: added back to the queue. - At the moment of writing, behavior is not attached to the flagging reliability, but it is recorded. + +## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF + +When `TRUE`, staff members will have their accounts and comments moderated the +same as any other user in the system. (Default `FALSE`) \ No newline at end of file diff --git a/docs/_docs/03-03-product-guide-moderator-features.md b/docs/_docs/03-03-product-guide-moderator-features.md index 45516b1a5..064257623 100644 --- a/docs/_docs/03-03-product-guide-moderator-features.md +++ b/docs/_docs/03-03-product-guide-moderator-features.md @@ -8,13 +8,6 @@ permalink: /moderator-features/ The Admin is your moderators will moderate your comments, and your Admins will configure and manage the different parts of Talk. -### Dashboard - -The Dashboard provides real-time information to moderators so they know at a -glance where they should direct their attention. It shows what articles are -receiving the most reports and where the most active conversations are -happening. - ### Moderate This is the tab where Moderators will spend the majority of their time. They can diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index b2e315ec5..832b7b473 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -79,11 +79,6 @@ const findOrCreateAssetByURL = async (context, asset_url) => { return asset; }; -const getAssetsForMetrics = async ({loaders: {Comments}}) => { - return Comments.getByQuery({action_type: 'FLAG'}) - .then((connection) => connection.nodes); -}; - const findByUrl = async (context, asset_url) => { // Verify that the asset_url is parsable. @@ -111,7 +106,6 @@ module.exports = (context) => ({ findByUrl: (url) => findByUrl(context, url), getByQuery: (query) => getAssetsByQuery(context, query), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), - getForMetrics: () => getAssetsForMetrics(context), getAll: new util.SingletonResolver(() => AssetModel.find({})) } }); diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 8bc170c6d..0ef09940f 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -4,7 +4,6 @@ const debug = require('debug')('talk:graph:loaders'); const Actions = require('./actions'); const Assets = require('./assets'); const Comments = require('./comments'); -const Metrics = require('./metrics'); const Settings = require('./settings'); const Tags = require('./tags'); const Users = require('./users'); @@ -17,7 +16,6 @@ let loaders = [ Actions, Assets, Comments, - Metrics, Settings, Tags, Users, diff --git a/graph/loaders/metrics.js b/graph/loaders/metrics.js deleted file mode 100644 index 28efe2963..000000000 --- a/graph/loaders/metrics.js +++ /dev/null @@ -1,257 +0,0 @@ -const _ = require('lodash'); -const DataLoader = require('dataloader'); -const {objectCacheKeyFn} = require('./util'); - -const ActionModel = require('../../models/action'); -const CommentModel = require('../../models/comment'); - -/** - * Returns the assets which have had comments made within the last time period. - */ -const getAssetActivityMetrics = ({loaders: {Assets}}, {from, to, limit}) => { - let assetMetrics = []; - - return CommentModel.aggregate([ - {$match: { - parent_id: null, - created_at: { - $gt: from, - $lt: to - } - }}, - {$group: { - _id: '$asset_id', - commentCount: { - $sum: 1 - } - }}, - {$project: { - _id: false, - asset_id: '$_id', - commentCount: '$commentCount' - }}, - {$sort: { - commentCount: -1 - }}, - {$limit: limit} - ]) - .then((results) => { - assetMetrics = results; - - return Assets.getByID.loadMany(results.map((result) => result.asset_id)); - }) - .then((assets) => assets.map((asset, i) => { - - // We're leveraging the fact that the comments returned by the aggregation - // query are in the request order that we just made, it's what the - // Assets.getByID loader does. - asset.commentCount = assetMetrics[i].commentCount; - - return asset; - })); -}; - -/** - * Returns a list of assets with action metadata included on the models. - */ -const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to, sortBy, limit}) => { - - // Get the recent actions. - let actionSummaries = await Metrics.getRecentActions.load({from, to}); - - let commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => { - if (action_type !== sortBy) { - return acc; - } - - if (!(item_id in acc)) { - acc[item_id] = []; - } - - acc[item_id].push({action_type, count}); - - return acc; - }, {}); - - // Collect just the comment id's. - let commentIDs = Object.keys(commentMetrics); - - // Find those comments. - let comments = await Comments.get.loadMany(commentIDs); - - let commentResults = _.groupBy(comments, 'asset_id'); - - let assetMetrics = Object.keys(commentResults) - .map((asset_id) => { - let ids = commentResults[asset_id].map((comment) => comment.id); - let summaries = _.groupBy(_.flatten(ids.map((id) => commentMetrics[id])), 'action_type'); - - let action_summaries = Object.keys(summaries).map((action_type) => ({ - action_type, - actionCount: summaries[action_type].reduce((acc, {count}) => acc + count, 0), - actionableItemCount: summaries[action_type].length - })); - - return {action_summaries, id: asset_id}; - }) - - .filter((asset) => { - let contextActionSummary = asset.action_summaries.find((({action_type}) => action_type === sortBy)); - if (contextActionSummary === null || contextActionSummary.actionCount === 0) { - return false; - } - - return true; - }) - - // Sort these metrics by the predefined sort order. This will ensure that - // if the action summary does not exist on the object, that it is less - // prefered over the one that does have it. - .sort((a, b) => { - let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sortBy)); - let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sortBy)); - - // Both of them had an actionCount, hence we can determine that we could - // compare the actual values directly. - return bActionSummary.actionCount - aActionSummary.actionCount; - }); - - // Only keep the top `limit`. - assetMetrics = assetMetrics.slice(0, limit); - - // Determine the assets that we need to return. - let assets = await Assets.getByID.loadMany(assetMetrics.map((asset) => asset.id)); - - // Join up the assets that are returned by their id. - let groupedAssets = _.groupBy(assets, 'id'); - - // Return from the sorted asset metrics and return their assetes. - return assetMetrics.map(({id, action_summaries}) => { - if (id in groupedAssets) { - let asset = groupedAssets[id][0]; - - // Add the action summaries to the asset. - asset.action_summaries = action_summaries; - - return asset; - } - - return null; - }).filter((asset) => asset != null); -}; - -/** - * Returns a list of comments that are retrieved based on most activity within - * the indicated time range. - */ -const getCommentMetrics = async ({loaders: {Metrics, Comments}}, {from, to, sortBy, limit}) => { - - let commentActionSummaries = {}; - - let actionSummaries = await Metrics.getRecentActions.load({from, to}); - - actionSummaries.sort((a, b) => { - let aActionSummary = a.action_type === sortBy ? a : null; - let bActionSummary = b.action_type === sortBy ? b : null; - - // If either a or b don't have this action type, then one of them will - // automatically win. - if (aActionSummary == null || bActionSummary == null) { - if (bActionSummary != null) { - return 1; - } - - if (aActionSummary != null) { - return -1; - } - - return 0; - } - - // Both of them had an actionCount, hence we can determine that we could - // compare the actual values directly. - return bActionSummary.count - aActionSummary.count; - }); - - commentActionSummaries = _.groupBy(actionSummaries, 'item_id'); - - // Grab the comment id's for comment where they have at least one of the - // actions being sorted by. - let commentIDs = Object.keys(commentActionSummaries).filter((item_id) => { - let contextActionSummary = commentActionSummaries[item_id].find(({action_type}) => action_type === sortBy); - if (contextActionSummary == null) { - return false; - } - - return true; - }); - - // Only keep the top `limit`. - commentIDs = commentIDs.slice(0, limit); - - // If there are no comment's to get, then just continue with an empty - // array. - if (commentIDs.length === 0) { - return []; - } - - // Find those comments, this is the final stage, so let's get all the - // fields. - let comments = await Comments.get.loadMany(commentIDs); - - return comments.map((comment) => { - - // Add in the action summaries genrerated. - comment.action_summaries = commentActionSummaries[comment.id]; - - return comment; - }); -}; - -const getRecentActions = (context, {from, to}) => { - return ActionModel.aggregate([ - - // Find all actions that were created in the time range. - {$match: { - item_type: 'COMMENTS', - created_at: { - $gt: from, - $lt: to - } - }}, - - // Count all those items. - {$group: { - _id: { - item_id: '$item_id', - action_type: '$action_type' - }, - count: { - $sum: 1 - } - }}, - - // Project the count to a better field. - {$project: { - item_id: '$_id.item_id', - action_type: '$_id.action_type', - count: '$count' - }} - ]); -}; - -module.exports = (context) => ({ - Metrics: { - getRecentActions: new DataLoader(([{from, to}]) => getRecentActions(context, {from, to}).then((as) => [as]), { - batch: false, - cacheKeyFn: objectCacheKeyFn('from', 'to') - }), - Assets: { - get: ({from, to, sortBy, limit}) => getAssetMetrics(context, {from, to, sortBy, limit}), - getActivity: ({from, to, limit}) => getAssetActivityMetrics(context, {from, to, limit}), - }, - Comments: { - get: ({from, to, sortBy, limit}) => getCommentMetrics(context, {from, to, sortBy, limit}), - } - } -}); diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 277968e80..be7039e80 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -1,6 +1,8 @@ -const ActionsService = require('../../services/actions'); const errors = require('../../errors'); const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants'); +const { + IGNORE_FLAGS_AGAINST_STAFF, +} = require('../../config'); /** * getActionItem will return the item that is associated with the given action. @@ -10,21 +12,21 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants'); * @param {Object} action the action being performed * @return {Promise} resolves to the referenced item */ -const getActionItem = async ({loaders: {Comments, Users}}, {item_id, item_type}) => { - if (item_type === 'COMMENTS') { - const comment = await Comments.get.load(item_id); - if (!comment) { - throw errors.ErrNotFound; - } +const getActionItem = async (ctx, {item_id, item_type}) => { + const { + loaders: { + Comments, + Users, + }, + } = ctx; - return comment; - } else if (item_type === 'USERS') { - const user = await Users.getByID.load(item_id); - if (!user) { - throw errors.ErrNotFound; - } - - return user; + switch (item_type) { + case 'COMMENTS': + return Comments.get.load(item_id); + case 'USERS': + return Users.getByID.load(item_id); + default: + return null; } }; @@ -37,10 +39,34 @@ const getActionItem = async ({loaders: {Comments, Users}}, {item_id, item_type}) * @return {Promise} resolves to the action created */ const createAction = async (ctx, {item_id, item_type, action_type, group_id, metadata = {}}) => { - const {user = {}, pubsub} = ctx; + const { + user = {}, + pubsub, + connectors: { + services: { + Actions, + }, + }, + } = ctx; // Gets the item referenced by the action. const item = await getActionItem(ctx, {item_id, item_type}); + if (!item || item === null) { + throw errors.ErrNotFound; + } + + // If we are ignoring flags against staff, ensure that the target isn't a + // staff member. + if (IGNORE_FLAGS_AGAINST_STAFF) { + if (action_type === 'FLAG') { + + // If the item is a user, and this is a flag. Check to see if they are + // staff, if they are, don't permit the flag. + if (item_type === 'USERS' && item.isStaff()) { + return null; + } + } + } if (action_type === 'FLAG' && item_type === 'USERS') { @@ -52,7 +78,7 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met } // Create the action itself. - let action = await ActionsService.create({ + let action = await Actions.create({ item_id, item_type, user_id: user.id, @@ -78,10 +104,21 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met * @param {String} id the id of the action to delete * @return {Promise} resolves to the deleted action, or null if not found. */ -const deleteAction = ({user}, {id}) => ActionsService.delete({id, user_id: user.id}); +const deleteAction = (ctx, {id}) => { + const { + user, + connectors: { + services: { + Actions, + }, + }, + } = ctx; + + return Actions.delete({id, user_id: user.id}); +}; module.exports = (ctx) => { - const mutators = { + let mutators = { Action: { create: () => Promise.reject(errors.ErrNotAuthorized), delete: () => Promise.reject(errors.ErrNotAuthorized) diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 75d0f8564..e01ecfd36 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -15,7 +15,10 @@ const { EDIT_COMMENT } = require('../../perms/constants'); const debug = require('debug')('talk:graph:mutators:comment'); -const {DISABLE_AUTOFLAG_SUSPECT_WORDS} = require('../../config'); +const { + DISABLE_AUTOFLAG_SUSPECT_WORDS, + IGNORE_FLAGS_AGAINST_STAFF, +} = require('../../config'); const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => { const item_type = 'COMMENTS'; @@ -295,6 +298,15 @@ const moderationPhases = [ } }, + // If a given user is a staff member, always approve their comment. + (context) => { + if (IGNORE_FLAGS_AGAINST_STAFF && context.user && context.user.isStaff()) { + return { + status: 'ACCEPTED', + }; + } + }, + // This phase checks the comment if it has any links in it if the check is // enabled. (context, comment, {assetSettings: {premodLinksEnable}}) => { diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index e4701c901..aa8b90f20 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -1,7 +1,6 @@ const { SEARCH_ASSETS, SEARCH_OTHERS_COMMENTS, - SEARCH_COMMENT_METRICS, SEARCH_OTHER_USERS } = require('../../perms/constants'); @@ -58,27 +57,6 @@ const RootQuery = { return Users.getCountByQuery(query); }, - assetMetrics(_, query, {user, loaders: {Metrics: {Assets}}}) { - if (user == null || !user.can(SEARCH_ASSETS)) { - return null; - } - - const {sortBy} = query; - if (sortBy === 'ACTIVITY') { - return Assets.getActivity(query); - } - - return Assets.get(query); - }, - - commentMetrics(_, query, {user, loaders: {Metrics: {Comments}}}) { - if (user == null || !user.can(SEARCH_COMMENT_METRICS)) { - return null; - } - - return Comments.get(query); - }, - // This returns the current user, ensure that if we aren't logged in, we // return null. me(_, args, {user}) { diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index a9ac63ab6..7c66bb471 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -5,9 +5,8 @@ const { SEARCH_OTHER_USERS, SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES, - SEARCH_COMMENT_METRICS, LIST_OWN_TOKENS, - VIEW_USER_STATUS + VIEW_USER_STATUS, } = require('../../perms/constants'); const User = { @@ -79,7 +78,7 @@ const User = { // Extract the reliability from the user metadata if they have permission. reliable(user, _, {user: requestingUser}) { - if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) { + if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) { return KarmaService.model(user); } }, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 1adbd2e47..a8cd9e502 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -923,19 +923,6 @@ enum SORT_COMMENTS_BY { REPLIES } -# Metrics for the assets. -enum ASSET_METRICS_SORT { - - # Represents a FlagAction. - FLAG - - # Represents a don't agree action. - DONTAGREE - - # Represents activity. - ACTIVITY -} - type RootQuery { # Site wide settings and defaults. @@ -970,14 +957,6 @@ type RootQuery { # a single User by id user(id: ID!): User - - # Asset metrics related to user actions are saturated into the assets - # returned. Parameters `from` and `to` are related to the action created_at field. - assetMetrics(from: Date!, to: Date!, sortBy: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!] - - # Comment metrics related to user actions are saturated into the comments - # returned. Parameters `from` and `to` are related to the action created_at field. - commentMetrics(from: Date!, to: Date!, sortBy: ACTION_TYPE!, limit: Int = 10): [Comment!] } ################################################################################ diff --git a/locales/da.yml b/locales/da.yml index 48b5ab362..0a5152cd9 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -98,7 +98,6 @@ da: copy_and_paste: "Kopier og indsæt kode nedenfor i dit CMS system for at indlejre din kommentarboks i dine artiker." custom_css_url: "Brugerdefineret CSS URL" custom_css_url_desc: "URL til et CSS stylesheet der tilsidesætter den alimdelige strøms stilart. Kan være internt eller externt" - dashboard: "Instrumentbræt" days: "Dage" description: "Som administrator kan du tilpasse indstillingerne til kommentarstrømmen for denne historie:" domain_list_text: "Indtast de domæner du gerne vil tillade til Talk. f.eks. Dit lokale udviklings miljø (fx. localhost:3000 staging.domain.com domain.com)." @@ -153,16 +152,6 @@ da: username: "Brugernavn" write_your_username: "Rediger dit brugernavn" your_username: "Dit brugernavn vises på hver kommentar du sender." - dashboard: - auto_update: "Data opdateres automatisk hvert femte minut, eller når du genindlæser." - comment_count: "kommentarer" - flags: "Flags" - most_flags: "Historier med de fleste flags" - most_conversations: "Historier med de fleste samtaler" - next_update: "{0} minutter indtil næste opdatering." - no_activity: "Der har ikke været nogen kommentarer i løbet af de sidste fem minutter." - no_flags: "Der har ikke været nogle flag i de sidste 5 minutter! Hurra!" - no_likes: "Der har ikke været nogen der syntes godt om noget i de sidste 5 minutter. Helt stille" done: "Færdig" edit_comment: body_input_label: "Rediger denne kommentar" diff --git a/locales/en.yml b/locales/en.yml index 1215c15bd..10f537fb2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -105,7 +105,6 @@ en: copy_and_paste: "Copy and paste code below into your CMS to embed your comment box in your articles" custom_css_url: "Custom CSS URL" custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external." - dashboard: Dashboard days: Days description: "As an admin, you can customize the settings for the comment stream for this story:" domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)." @@ -160,16 +159,6 @@ en: username: Username write_your_username: "Edit your username" your_username: "Your username appears on every comment you post." - dashboard: - auto_update: "Data automatically updates every five minutes or when you Reload." - comment_count: comments - flags: Flags - most_flags: "Stories with the most flags" - most_conversations: "Stories with the most conversations" - next_update: "{0} minutes until next update." - no_activity: "There haven't been any comments anywhere in the last five minutes." - no_flags: "There have been no flags in the last 5 minutes! Hooray!" - no_likes: "There have been no likes in the last 5 minutes. All quiet." done: Done edit_comment: body_input_label: "Edit this comment" diff --git a/locales/es.yml b/locales/es.yml index fde19611d..e8369d949 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -103,7 +103,6 @@ es: copy_and_paste: "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus artículos" custom_css_url: "URL CSS a medida" custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa." - dashboard: Panel days: Días description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo" domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)." @@ -158,16 +157,6 @@ es: username: "Nombre" write_your_username: "Edita tu nombre" your_username: "Tu nombre aparece en cada comentario que publiques." - dashboard: - auto_update: "Los datos se actualizan automáticamente cada cinco minutos o cuando refresca el navegador." - comment_count: "comentarios" - flags: "Reportes" - most_flags: "Artículos con la mayor cantidad de reportes" - most_conversations: "Artículos con las mayores conversaciones" - next_update: "{0} minutos hasta la próxima actualización." - no_activity: "¡No han habido comentarios en ningún lado en los últimos 5 minutos." - no_flags: "¡No ha habido ningún reporte en los últimos 5 minutos! Bravo!" - no_likes: "¡No ha habido ningún 'me gusta' en los últimos 5 minutos. Todo tranquilo." done: "Hecho" edit_comment: body_input_label: "Editar este comentario" diff --git a/locales/fr.yml b/locales/fr.yml index 5c5a45aab..31ad3ca91 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -79,7 +79,6 @@ fr: copy_and_paste: "Copiez et collez le code ci-dessous dans votre CMS pour intégrer votre boîte de commentaires dans vos articles." custom_css_url: "URL CSS personnalisée" custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe." - dashboard: Tableau de bord days: Journées description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément." domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)." @@ -121,16 +120,6 @@ fr: weeks: Semaines wordlist: "Mots interdits" continue: Continuer - dashboard: - auto_update: "Les données sont automatiquement mises à jour toutes les cinq minutes ou lorsque vous rechargez." - comment_count: commentaires - flags: signalements - most_flags: "Articles avec le plus de signalements" - most_conversations: "Articles avec le plus de conversations" - next_update: "{0} minutes jusqu'à la prochaine mise à jour." - no_activity: "Il n'y a eu aucun commentaire nulle part dans les cinq dernières minutes." - no_flags: "Il n'y a pas eu aucun signalement au cours des 5 dernières minutes! Hooray !" - no_likes: "Il n'y a pas eu de \"J'aime\" au cours des 5 dernières minutes. Tout est calme." done: Terminé edit_comment: body_input_label: "Modifier ce commentaire" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index ae8e1310c..bdfe72d5a 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -98,7 +98,6 @@ pt_BR: copy_and_paste: "Copie e cole o código abaixo em seu CMS para incorporar sua caixa de comentários em seus artigos." custom_css_url: "URL para CSS customizado" custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo." - dashboard: Painel de controle days: Dias description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:" domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)." @@ -153,16 +152,6 @@ pt_BR: username: Nome de usuário write_your_username: "Edite seu nome de usuário" your_username: "Nosso nome de usuário aparece em todos os comentários que você publica." - dashboard: - auto_update: "Atualizado autimaticamente a cada minutos ou após recarregamento." - comment_count: comentários - flags: Marcações - most_flags: "Conversas com mais comentários marcados" - most_conversations: "Conversas com mais comentários" - next_update: "{0} minutos até a próxima atualização." - no_activity: "Não houve nenhum comentário nos últimos cinco minutos." - no_flags: "Não houve nenhum comentário marcado em nenhum lugar nos últimos 5 minutos. Hip Hip Uha!" - no_likes: "Não houve reações nos últimos 5 minutos." done: Feito edit_comment: body_input_label: "Edite este comentário" diff --git a/package.json b/package.json index dd6f004b5..3550be90a 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,11 @@ }, "homepage": "https://github.com/coralproject/talk#readme", "dependencies": { + "@coralproject/graphql-anywhere-optimized": "^0.1.0", "accepts": "^1.3.4", "apollo-client": "^1.9.1", "apollo-server-express": "^1.2.0", + "apollo-utilities": "^1.0.3", "app-module-path": "^2.2.0", "autoprefixer": "^6.5.2", "babel-cli": "6.26.0", @@ -99,7 +101,6 @@ "fs-extra": "^4.0.1", "gql-merge": "^0.0.4", "graphql": "^0.9.1", - "graphql-anywhere": "^3.1.0", "graphql-docs": "0.2.0", "graphql-errors": "^2.1.0", "graphql-redis-subscriptions": "1.3.0", diff --git a/perms/constants/query.js b/perms/constants/query.js index 65174ef8d..0b73ed1f9 100644 --- a/perms/constants/query.js +++ b/perms/constants/query.js @@ -4,9 +4,8 @@ module.exports = { SEARCH_ACTIONS: 'SEARCH_ACTIONS', SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS', SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS', - SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS', - LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', + VIEW_USER_STATUS: 'VIEW_USER_STATUS', VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS', - VIEW_USER_STATUS: 'VIEW_USER_STATUS' + LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', }; diff --git a/perms/reducers/query.js b/perms/reducers/query.js index b044cf3f1..67824342b 100644 --- a/perms/reducers/query.js +++ b/perms/reducers/query.js @@ -8,15 +8,13 @@ module.exports = (user, perm) => { case types.SEARCH_ACTIONS: case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: case types.SEARCH_OTHERS_COMMENTS: - case types.SEARCH_COMMENT_METRICS: + return check(user, ['ADMIN', 'MODERATOR']); case types.SEARCH_COMMENT_STATUS_HISTORY: case types.VIEW_USER_STATUS: case types.VIEW_PROTECTED_SETTINGS: return check(user, ['ADMIN', 'MODERATOR']); - case types.LIST_OWN_TOKENS: return check(user, ['ADMIN']); - default: break; } diff --git a/perms/reducers/subscription.js b/perms/reducers/subscription.js index 799b3e4c4..fb93edf8f 100644 --- a/perms/reducers/subscription.js +++ b/perms/reducers/subscription.js @@ -7,7 +7,6 @@ module.exports = (user, perm) => { case types.SUBSCRIBE_COMMENT_ACCEPTED: case types.SUBSCRIBE_COMMENT_REJECTED: case types.SUBSCRIBE_COMMENT_RESET: - return check(user, ['ADMIN', 'MODERATOR']); case types.SUBSCRIBE_ALL_COMMENT_EDITED: case types.SUBSCRIBE_ALL_COMMENT_ADDED: case types.SUBSCRIBE_ALL_USER_SUSPENDED: @@ -15,7 +14,6 @@ module.exports = (user, perm) => { case types.SUBSCRIBE_ALL_USERNAME_REJECTED: case types.SUBSCRIBE_ALL_USERNAME_APPROVED: return check(user, ['ADMIN', 'MODERATOR']); - default: break; } diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index 52ebe49fe..02aee96a4 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -179,10 +179,22 @@ function getReactionConfig(reaction) { RootMutation: { [`create${Reaction}Action`]: async (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { const comment = await Comments.get.load(item_id); + if (!comment) { + throw errors.ErrNotFound; + } - let action; try { - action = await Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION}); + const action = await Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION}); + + if (pubsub) { + + // The comment is needed to allow better filtering e.g. by asset_id. + pubsub.publish(`${reaction}ActionCreated`, {action, comment}); + } + + return { + [reaction]: action, + }; } catch (err) { if (err instanceof errors.ErrAlreadyExists) { return err.metadata.existing; @@ -190,16 +202,6 @@ function getReactionConfig(reaction) { throw err; } - - if (pubsub) { - - // The comment is needed to allow better filtering e.g. by asset_id. - pubsub.publish(`${reaction}ActionCreated`, {action, comment}); - } - - return { - [reaction]: action, - }; }, [`delete${Reaction}Action`]: async (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { const action = await Action.delete({id}); diff --git a/plugins/talk-plugin-subscriber/client/components/SubscriberBadge.css b/plugins/talk-plugin-subscriber/client/components/SubscriberBadge.css index 42edfd8ff..e6c8d9161 100644 --- a/plugins/talk-plugin-subscriber/client/components/SubscriberBadge.css +++ b/plugins/talk-plugin-subscriber/client/components/SubscriberBadge.css @@ -2,7 +2,6 @@ background-color: #616161; color: white; display: inline-block; - margin: 0px 5px; padding: 5px 5px; border-radius: 2px; font-size: 12px; diff --git a/postcss.config.js b/postcss.config.js index 79fae36cb..e76e674d2 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,7 @@ module.exports = { plugins: [ - require('postcss-smart-import')({ /* ...options */ }), - require('precss')({ /* ...options */ }), - require('autoprefixer')({ /* ...options */ }) - ] + require('postcss-smart-import'), + require('precss'), + require('autoprefixer'), + ], }; diff --git a/services/kue.js b/services/kue.js index 0fa610faa..01806632e 100644 --- a/services/kue.js +++ b/services/kue.js @@ -21,6 +21,9 @@ const getQueue = () => { } }); + // Watch for stuck jobs to manage. + queue.watchStuckJobs(1000); + return queue; }; @@ -47,6 +50,7 @@ class Task { .attempts(this.attempts) .delay(this.delay) .backoff({type: 'exponential'}) + .removeOnComplete(true) .save((err) => { if (err) { return reject(err); @@ -66,6 +70,15 @@ class Task { return getQueue().process(this.name, callback); } + /** + * Connect to redis now by getting the queue. + */ + static connect() { + + // Force setup the redis connection for kue. + getQueue(); + } + /** * Shutdown running jobs. */ @@ -145,3 +158,5 @@ if (process.env.NODE_ENV === 'test') { module.exports.Task = Task; } +// Add the job reference to the exported params. +module.exports.Job = kue.Job; diff --git a/test/server/graph/loaders/metrics.js b/test/server/graph/loaders/metrics.js deleted file mode 100644 index 27af2e912..000000000 --- a/test/server/graph/loaders/metrics.js +++ /dev/null @@ -1,133 +0,0 @@ -const {graphql} = require('graphql'); - -const schema = require('../../../../graph/schema'); -const Context = require('../../../../graph/context'); -const UserModel = require('../../../../models/user'); -const AssetModel = require('../../../../models/asset'); -const SettingsService = require('../../../../services/settings'); -const ActionModel = require('../../../../models/action'); -const CommentModel = require('../../../../models/comment'); - -const {expect} = require('chai'); - -describe('graph.loaders.Metrics', () => { - beforeEach(() => SettingsService.init()); - - describe('#Comments', () => { - const query = ` - query CommentMetrics($from: Date!, $to: Date!) { - flagged: commentMetrics(from: $from, to: $to, sortBy: FLAG) { - id - } - } - `; - - describe('different comment states', () => { - - beforeEach(() => CommentModel.create([ - {id: '1', body: 'a new comment!'}, - {id: '2', body: 'a new comment!'}, - {id: '3', body: 'a new comment!'} - ])); - - [ - {flagged: 0, actions: []}, - {flagged: 1, actions: [{action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'}]}, - {flagged: 1, actions: [ - {action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'}, - ]}, - {flagged: 1, actions: [ - {action_type: 'FLAG', item_id: '3', item_type: 'COMMENTS'} - ]} - ].forEach(({flagged, actions}) => { - - describe(`with actions=${actions.length}`, () => { - - beforeEach(() => ActionModel.create(actions)); - - it(`returns the correct amount of metrics flagged=${flagged}`, () => { - const context = new Context({user: new UserModel({role: 'ADMIN'})}); - - return graphql(schema, query, {}, context, { - from: (new Date()).setMinutes((new Date()).getMinutes() - 5), - to: (new Date()).setMinutes((new Date()).getMinutes() + 5) - }) - .then(({data, errors}) => { - expect(errors).to.be.undefined; - expect(data.flagged).to.have.length(flagged); - }); - }); - - }); - - }); - - }); - }); - - describe('#Assets', () => { - const query = ` - fragment metrics on Asset { - id - action_summaries { - actionCount - actionableItemCount - } - } - - query Metrics($from: Date!, $to: Date!) { - assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) { - ...metrics - } - } - `; - - describe('different comment states', () => { - - beforeEach(() => Promise.all([ - AssetModel.create([ - {id: 'a1', url: 'http://localhost:3030/article/1'}, - {id: 'a2', url: 'http://localhost:3030/article/2'} - ]), - CommentModel.create([ - {id: 'c1', asset_id: 'a1', body: 'a new comment!'}, - {id: 'c2', asset_id: 'a1', body: 'a new comment!'}, - {id: 'c3', asset_id: 'a1', body: 'a new comment!'} - ]) - ])); - - [ - {flagged: 0, actions: []}, - {flagged: 1, actions: [{action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'}]}, - {flagged: 1, actions: [ - {action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'}, - ]}, - {flagged: 1, actions: [ - {action_type: 'FLAG', item_id: 'c3', item_type: 'COMMENTS'} - ]} - ].forEach(({flagged, actions}) => { - - describe(`with actions=${actions.length}`, () => { - - beforeEach(() => ActionModel.create(actions)); - - it(`returns the correct amount of metrics flagged=${flagged}`, () => { - const context = new Context({user: new UserModel({role: 'ADMIN'})}); - - return graphql(schema, query, {}, context, { - from: (new Date()).setMinutes((new Date()).getMinutes() - 5), - to: (new Date()).setMinutes((new Date()).getMinutes() + 5) - }) - .then(({data, errors}) => { - expect(errors).to.be.undefined; - expect(data.assetsByFlag).to.have.length(flagged); - }); - }); - - }); - - }); - - }); - }); -}); diff --git a/webpack.config.js b/webpack.config.js index 58612a5aa..dfd2813e2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -118,6 +118,7 @@ const config = { }, resolve: { alias: { + 'graphql-anywhere': '@coralproject/graphql-anywhere-optimized', 'plugin-api': path.resolve(__dirname, 'plugin-api/'), plugins: path.resolve(__dirname, 'plugins/'), pluginsConfig: pluginsPath diff --git a/yarn.lock b/yarn.lock index 767c98f39..9f2d51b02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11,6 +11,10 @@ eslint-plugin-promise "^3.3.1" eslint-plugin-react "^7.3.0" +"@coralproject/graphql-anywhere-optimized@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@coralproject/graphql-anywhere-optimized/-/graphql-anywhere-optimized-0.1.0.tgz#3456f16f790d8593b0ca4355910578ab1c6edab5" + "@kadira/storybook-deployer@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@kadira/storybook-deployer/-/storybook-deployer-1.2.0.tgz#1708f5cb37fa08ab4173b1bd99df6f4717dfae12" @@ -285,6 +289,10 @@ apollo-tracing@^0.1.0: dependencies: graphql-extensions "^0.0.x" +apollo-utilities@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.3.tgz#bf435277609850dd442cf1d5c2e8bc6655eaa943" + app-module-path@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" @@ -3595,7 +3603,7 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6: version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" -graphql-anywhere@^3.0.1, graphql-anywhere@^3.1.0: +graphql-anywhere@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.1.0.tgz#3ea0d8e8646b5cee68035016a9a7557c15c21e96"