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"