- );
-};
+ );
+ }
+}
Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string
}).isRequired,
- asset: PropTypes.shape({
- url: PropTypes.string,
- title: PropTypes.string
- }).isRequired
};
export default Comment;
diff --git a/client/talk-plugin-history/CommentHistory.js b/client/talk-plugin-history/CommentHistory.js
index d584d6a0a..43ae93f1d 100644
--- a/client/talk-plugin-history/CommentHistory.js
+++ b/client/talk-plugin-history/CommentHistory.js
@@ -22,16 +22,18 @@ class CommentHistory extends React.Component {
}
render() {
- const {link, comments} = this.props;
+ const {link, comments, data, root} = this.props;
return (
{comments.nodes.map((comment, i) => {
return ;
+ />;
})}
{comments.hasNextPage &&
diff --git a/config.js b/config.js
index a92802ee7..11c8b5077 100644
--- a/config.js
+++ b/config.js
@@ -8,6 +8,7 @@
require('env-rewrite').rewrite();
const uniq = require('lodash/uniq');
+const ms = require('ms');
//==============================================================================
// CONFIG INITIALIZATION
@@ -84,6 +85,23 @@ const CONFIG = {
MONGO_URL: process.env.TALK_MONGO_URL,
REDIS_URL: process.env.TALK_REDIS_URL,
+ // REDIS_RECONNECTION_MAX_ATTEMPTS is the amount of attempts that a redis
+ // connection will attempt to reconnect before aborting with an error.
+ REDIS_RECONNECTION_MAX_ATTEMPTS: parseInt(process.env.TALK_REDIS_RECONNECTION_MAX_ATTEMPTS || '100'),
+
+ // REDIS_RECONNECTION_MAX_RETRY_TIME is the time in string format for the
+ // maximum amount of time that a client can be considered "connecting" before
+ // attempts at reconnection are aborted with an error.
+ REDIS_RECONNECTION_MAX_RETRY_TIME: ms(process.env.TALK_REDIS_RECONNECTION_MAX_RETRY_TIME || '1 min'),
+
+ // REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied
+ // against the current attempt count inbetween attempts to connect to redis.
+ REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'),
+
+ // REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay
+ // before attempting to reconnect to redis.
+ REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'),
+
//------------------------------------------------------------------------------
// Server Config
//------------------------------------------------------------------------------
@@ -135,7 +153,7 @@ 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:-1,-1;flag:-1,-1'
+ TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1'
};
//==============================================================================
diff --git a/docs/_docs/02-01-configuration.md b/docs/_docs/02-01-configuration.md
index 8f76ad999..df790b24b 100644
--- a/docs/_docs/02-01-configuration.md
+++ b/docs/_docs/02-01-configuration.md
@@ -52,6 +52,21 @@ These are only used during the webpack build.
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
+#### Advanced
+
+- `TALK_REDIS_RECONNECTION_MAX_ATTEMPTS` (_optional_) - the amount of attempts
+ that a redis connection will attempt to reconnect before aborting with an
+ error. (Default `100`)
+- `TALK_REDIS_RECONNECTION_MAX_RETRY_TIME` (_optional_) - the time in string
+ format for the maximum amount of time that a client can be considered
+ "connecting" before attempts at reconnection are aborted with an error.
+ (Default `1 min`)
+- `TALK_REDIS_RECONNECTION_BACKOFF_FACTOR` (_optional_) - the time factor that
+ will be multiplied against the current attempt count inbetween attempts to
+ connect to redis. (Default `500 ms`)
+- `TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME` (_optional_) - the minimum time
+ used to delay before attempting to reconnect to redis. (Default `1 sec`)
+
### Server
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
@@ -162,7 +177,7 @@ Trust can auto-moderate comments based on user history. By specifying this
option, the behavior can be changed to offer different results.
- `TRUST_THRESHOLDS` (_optional_) - configure the reliability thresholds for
- flagging and commenting. (Default `comment:-1,-1;flag:-1,-1`)
+ flagging and commenting. (Default `comment:2,-1;flag:2,-1`)
The form of the environment variable:
diff --git a/graph/context.js b/graph/context.js
index cffec0896..8d7c740b6 100644
--- a/graph/context.js
+++ b/graph/context.js
@@ -53,7 +53,7 @@ class Context {
this.plugins = decorateContextPlugins(this, contextPlugins);
// Bind the publish/subscribe to the context.
- this.pubsub = pubsub.createClient();
+ this.pubsub = pubsub.getClient();
}
}
diff --git a/graph/subscriptions.js b/graph/subscriptions.js
index adac849e0..f89b41d3a 100644
--- a/graph/subscriptions.js
+++ b/graph/subscriptions.js
@@ -127,15 +127,13 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
}),
});
-const pubsubClient = pubsub.createClientFactory();
-
/**
* This creates a new subscription manager.
*/
const createSubscriptionManager = (server) => new SubscriptionServer({
subscriptionManager: new SubscriptionManager({
schema,
- pubsub: pubsubClient(),
+ pubsub: pubsub.getClient(),
setupFunctions,
}),
onConnect: ({token}, connection) => {
diff --git a/middleware/pubsub.js b/middleware/pubsub.js
index 15f7c51a2..957a523a7 100644
--- a/middleware/pubsub.js
+++ b/middleware/pubsub.js
@@ -1,12 +1,11 @@
const pubsub = require('../services/pubsub');
-const pubsubClient = pubsub.createClientFactory();
// To handle dependancy injection safer, we inject the pubsub handle onto the
// request object.
module.exports = (req, res, next) => {
// Attach the pubsub handle to the requests.
- req.pubsub = pubsubClient();
+ req.pubsub = pubsub.getClient();
// Forward on the request.
next();
diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js
index 22c30d639..3300ac2d3 100644
--- a/plugin-api/beta/client/hocs/withReaction.js
+++ b/plugin-api/beta/client/hocs/withReaction.js
@@ -11,14 +11,29 @@ import {showSignInDialog} from 'coral-framework/actions/auth';
import {addNotification} from 'coral-framework/actions/notification';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
+import hoistStatics from 'recompose/hoistStatics';
import * as PropTypes from 'prop-types';
+import {getDefinitionName} from '../utils';
-export default (reaction) => (WrappedComponent) => {
+/*
+ * Disable false-positive warning below, as it doesn't work well with how we currently
+ * assemble the queries.
+ *
+ * Warning: fragment with name {fragment name} already exists.
+ * graphql-tag enforces all fragment names across your application to be unique; read more about
+ * this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
+ */
+gql.disableFragmentWarnings();
+
+export default (reaction, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
+ // fragments allow the extension of the fragments defined in this HOC.
+ const {fragments = {}} = options;
+
// Global instance counter for each `reaction` type.
let instances = 0;
@@ -175,7 +190,7 @@ export default (reaction) => (WrappedComponent) => {
createdSubscription = context.client.subscribe({
query: REACTION_CREATED_SUBSCRIPTION,
variables: {
- assetId: this.props.root.asset.id,
+ assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionCreated,
@@ -185,7 +200,7 @@ export default (reaction) => (WrappedComponent) => {
deletedSubscription = context.client.subscribe({
query: REACTION_DELETED_SUBSCRIPTION,
variables: {
- assetId: this.props.root.asset.id,
+ assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionDeleted,
@@ -247,7 +262,7 @@ export default (reaction) => (WrappedComponent) => {
}
render() {
- const {comment} = this.props;
+ const {root, asset, comment} = this.props;
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
@@ -262,10 +277,12 @@ export default (reaction) => (WrappedComponent) => {
const alreadyReacted = !!reactionSummary;
return
(WrappedComponent) => {
const enhance = compose(
withFragments({
+ ...fragments,
+ asset: gql`
+ fragment ${Reaction}Button_asset on Asset {
+ id
+ ${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
+ }
+ ${fragments.asset ? fragments.asset : ''}
+ `,
comment: gql`
fragment ${Reaction}Button_comment on Comment {
+ id
action_summaries {
+ __typename
... on ${Reaction}ActionSummary {
count
current_user {
@@ -381,7 +408,10 @@ export default (reaction) => (WrappedComponent) => {
}
}
}
- }`
+ ${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
+ }
+ ${fragments.comment ? fragments.comment : ''}
+ `
}),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
@@ -391,4 +421,4 @@ export default (reaction) => (WrappedComponent) => {
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
-};
+});
diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js
index b0a74f4d7..491c35038 100644
--- a/plugin-api/beta/client/hocs/withTags.js
+++ b/plugin-api/beta/client/hocs/withTags.js
@@ -8,13 +8,28 @@ import {withAddTag, withRemoveTag} from 'coral-framework/graphql/mutations';
import withFragments from 'coral-framework/hocs/withFragments';
import {addNotification} from 'coral-framework/actions/notification';
import {forEachError, isTagged} from 'coral-framework/utils';
+import hoistStatics from 'recompose/hoistStatics';
+import {getDefinitionName} from '../utils';
-export default (tag) => (WrappedComponent) => {
+/*
+ * Disable false-positive warning below, as it doesn't work well with how we currently
+ * assemble the queries.
+ *
+ * Warning: fragment with name {fragment name} already exists.
+ * graphql-tag enforces all fragment names across your application to be unique; read more about
+ * this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
+ */
+gql.disableFragmentWarnings();
+
+export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof tag !== 'string') {
console.error('Tag must be a valid string');
return null;
}
+ // fragments allow the extension of the fragments defined in this HOC.
+ const {fragments = {}} = options;
+
const Tag = capitalize(tag);
const TAG = tag.toUpperCase();
@@ -68,13 +83,15 @@ export default (tag) => (WrappedComponent) => {
}
render() {
- const {comment, user, config} = this.props;
+ const {root, asset, comment, user, config} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return (WrappedComponent) => {
const enhance = compose(
withFragments({
+ ...fragments,
+ asset: gql`
+ fragment ${Tag}Button_asset on Asset {
+ id
+ ${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
+ }
+ ${fragments.asset ? fragments.asset : ''}
+ `,
comment: gql`
fragment ${Tag}Button_comment on Comment {
+ id
tags {
tag {
name
}
}
- }`
+ ${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
+ }
+ ${fragments.comment ? fragments.comment : ''}
+ `
}),
connect(mapStateToProps, mapDispatchToProps),
withAddTag,
@@ -109,4 +138,4 @@ export default (tag) => (WrappedComponent) => {
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
return enhance(WithTags);
-};
+});
diff --git a/plugins/talk-plugin-featured-comments/client/containers/Comment.js b/plugins/talk-plugin-featured-comments/client/containers/Comment.js
index 7080627e6..bba764a1d 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/Comment.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/Comment.js
@@ -31,7 +31,6 @@ export default withFragments({
name
}
}
-
user {
id
username
diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
index 3b564d127..7081c7ec6 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
@@ -1,5 +1,16 @@
import ModTag from '../components/ModTag';
import {withTags} from 'plugin-api/beta/client/hocs';
+import {gql} from 'react-apollo';
-export default withTags('featured')(ModTag);
+const fragments = {
+ comment: gql`
+ fragment TalkFeaturedComments_ModTag_comment on Comment {
+ user {
+ username
+ }
+ }
+ `
+};
+
+export default withTags('featured', {fragments})(ModTag);
diff --git a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
index 4c36c5c62..a5fd3b7e8 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
@@ -18,7 +18,7 @@ class TabPaneContainer extends React.Component {
limit: 5,
cursor: this.props.root.asset.featuredComments.endCursor,
asset_id: this.props.root.asset.id,
- sort: 'DESC',
+ sort: 'REVERSE_CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
diff --git a/plugins/talk-plugin-featured-comments/client/containers/Tag.js b/plugins/talk-plugin-featured-comments/client/containers/Tag.js
new file mode 100644
index 000000000..60d9e1e2b
--- /dev/null
+++ b/plugins/talk-plugin-featured-comments/client/containers/Tag.js
@@ -0,0 +1,15 @@
+import {gql} from 'react-apollo';
+import Tag from '../components/Tag';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ comment: gql`
+ fragment TalkFeaturedComments_Tag_comment on Comment {
+ tags {
+ tag {
+ name
+ }
+ }
+ }
+ `
+})(Tag);
diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js
index c796521e9..30d59c546 100644
--- a/plugins/talk-plugin-featured-comments/client/index.js
+++ b/plugins/talk-plugin-featured-comments/client/index.js
@@ -1,5 +1,5 @@
import Tab from './containers/Tab';
-import Tag from './components/Tag';
+import Tag from './containers/Tag';
import Button from './components/Button';
import TabPane from './containers/TabPane';
import translations from './translations.yml';
diff --git a/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js b/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js
new file mode 100644
index 000000000..bf57dc5cb
--- /dev/null
+++ b/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js
@@ -0,0 +1,15 @@
+import {gql} from 'react-apollo';
+import OffTopicTag from '../components/OffTopicTag';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ comment: gql`
+ fragment TalkOfftopic_OffTopicTag_comment on Comment {
+ tags {
+ tag {
+ name
+ }
+ }
+ }
+ `
+})(OffTopicTag);
diff --git a/plugins/talk-plugin-offtopic/client/index.js b/plugins/talk-plugin-offtopic/client/index.js
index b284f7280..24bfc095f 100644
--- a/plugins/talk-plugin-offtopic/client/index.js
+++ b/plugins/talk-plugin-offtopic/client/index.js
@@ -1,5 +1,5 @@
import translations from './translations.json';
-import OffTopicTag from './components/OffTopicTag';
+import OffTopicTag from './containers/OffTopicTag';
import OffTopicFilter from './containers/OffTopicFilter';
import OffTopicCheckbox from './containers/OffTopicCheckbox';
import reducer from './reducer';
diff --git a/plugins/talk-plugin-permalink/client/components/PermalinkButton.js b/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
index 27cbf9188..e2536f86f 100644
--- a/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
+++ b/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
@@ -28,9 +28,11 @@ export default class PermalinkButton extends React.Component {
}
handleClickOutside = () => {
- this.setState({
- popoverOpen: false
- });
+ if (this.state.popoverOpen) {
+ this.setState({
+ popoverOpen: false
+ });
+ }
}
copyPermalink = () => {
diff --git a/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js b/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js
new file mode 100644
index 000000000..5e1d11110
--- /dev/null
+++ b/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js
@@ -0,0 +1,16 @@
+import {gql} from 'react-apollo';
+import PermalinkButton from '../components/PermalinkButton';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ asset: gql`
+ fragment TalkPermalink_Button_asset on Asset {
+ url
+ }
+ `,
+ comment: gql`
+ fragment TalkPermalink_Button_comment on Comment {
+ id
+ }
+ `
+})(PermalinkButton);
diff --git a/plugins/talk-plugin-permalink/client/index.js b/plugins/talk-plugin-permalink/client/index.js
index d413da870..c28c4ae71 100644
--- a/plugins/talk-plugin-permalink/client/index.js
+++ b/plugins/talk-plugin-permalink/client/index.js
@@ -1,4 +1,4 @@
-import PermalinkButton from './components/PermalinkButton';
+import PermalinkButton from './containers/PermalinkButton';
export default {
slots: {
diff --git a/services/karma.js b/services/karma.js
index a2c087eaa..687b67af0 100644
--- a/services/karma.js
+++ b/services/karma.js
@@ -19,7 +19,7 @@ const {
*
* The default used is:
*
- * comment:-1,-1;flag:-1,-1
+ * comment:2,-2;flag:2,-2
*/
const parseThresholds = (thresholds) => thresholds
.split(';')
diff --git a/services/pubsub.js b/services/pubsub.js
index 780aeba16..9fc2ae902 100644
--- a/services/pubsub.js
+++ b/services/pubsub.js
@@ -1,23 +1,24 @@
const {RedisPubSub} = require('graphql-redis-subscriptions');
+const {connectionOptions, attachMonitors} = require('./redis');
-const {connectionOptions} = require('./redis');
+/**
+ * getClient returns the pubsub singleton for this instance.
+ */
+let pubsub = null;
+const getClient = () => {
+ if (pubsub !== null) {
+ return pubsub;
+ }
-const createClient = () => new RedisPubSub({connection: connectionOptions});
+ pubsub = new RedisPubSub({connection: connectionOptions});
-const createClientFactory = () => {
- let ins = null;
- return () => {
- if (ins) {
- return ins;
- }
+ // Attach the node monitors to the subscriber + publishers.
+ attachMonitors(pubsub.redisPublisher);
+ attachMonitors(pubsub.redisSubscriber);
- ins = createClient();
-
- return ins;
- };
+ return pubsub;
};
module.exports = {
- createClient,
- createClientFactory
+ getClient,
};
diff --git a/services/redis.js b/services/redis.js
index 1983249f1..b87e5e9fd 100644
--- a/services/redis.js
+++ b/services/redis.js
@@ -1,45 +1,80 @@
const redis = require('redis');
const debug = require('debug')('talk:services:redis');
+const enabled = require('debug').enabled('talk:services:redis');
const {
- REDIS_URL
+ REDIS_URL,
+ REDIS_RECONNECTION_MAX_ATTEMPTS,
+ REDIS_RECONNECTION_MAX_RETRY_TIME,
+ REDIS_RECONNECTION_BACKOFF_FACTOR,
+ REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME,
} = require('../config');
+const attachMonitors = (client) => {
+ debug('client created');
+
+ // Debug events.
+ if (enabled) {
+ client.on('ready', () => debug('client ready'));
+ client.on('connect', () => debug('client connected'));
+ client.on('reconnecting', () => debug('client connection lost, attempting to reconnect'));
+ client.on('end', () => debug('client ended'));
+ }
+
+ // Error events.
+ client.on('error', (err) => {
+ if (err) {
+ console.error('Error connecting to redis:', err);
+ }
+ });
+};
+
const connectionOptions = {
url: REDIS_URL,
retry_strategy: function(options) {
- if (options.error && options.error.code === 'ECONNREFUSED') {
+ if (options.error && options.error.code !== 'ECONNREFUSED') {
+
+ debug('retry strategy: none, an error occured');
// End reconnecting on a specific error and flush all commands with a individual error
- return new Error('The server refused the connection');
+ return options.error;
}
- if (options.total_retry_time > 1000 * 60 * 60) {
+ if (options.total_retry_time > REDIS_RECONNECTION_MAX_RETRY_TIME) {
+
+ debug('retry strategy: none, exhausted retry time');
// End reconnecting after a specific timeout and flush all commands with a individual error
return new Error('Retry time exhausted');
}
- if (options.times_connected > 10) {
+ if (options.attempt > REDIS_RECONNECTION_MAX_ATTEMPTS) {
+
+ debug('retry strategy: none, exhausted retry attempts');
// End reconnecting with built in error
return undefined;
}
// reconnect after
- return Math.max(options.attempt * 100, 3000);
+ const delay = Math.max(options.attempt * REDIS_RECONNECTION_BACKOFF_FACTOR, REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME);
+
+ debug(`retry strategy: try to reconnect ${delay} ms from now`);
+
+ return delay;
}
};
const createClient = () => {
let client = redis.createClient(connectionOptions);
+ // Attach the monitors that will print debug messages to the console.
+ attachMonitors(client);
+
client.ping((err) => {
if (err) {
console.error('Can\'t ping the redis server!');
throw err;
}
-
- debug('connection established');
});
return client;
@@ -47,12 +82,13 @@ const createClient = () => {
module.exports = {
connectionOptions,
+ attachMonitors,
createClient,
createClientFactory: () => {
let client = null;
return () => {
- if (client) {
+ if (client !== null) {
return client;
}