From b63fce4ebc9ae7dc001f05d6e557e206a181a199 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 15 Aug 2017 16:58:23 +0700 Subject: [PATCH 01/40] Optimize Slot rendering --- client/coral-framework/components/Slot.js | 49 ++++++++++++++++++----- client/coral-framework/helpers/plugins.js | 6 +++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index b1df6647a..f86d44eb1 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -4,19 +4,48 @@ import styles from './Slot.css'; import {connect} from 'react-redux'; import {getSlotElements} from 'coral-framework/helpers/plugins'; import omit from 'lodash/omit'; +import union from 'lodash/union'; +import isEqual from 'lodash/isEqual'; -function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) { - let children = getSlotElements(fill, reduxState, rest); - const pluginConfig = reduxState.config.pluginConfig || {}; - if (children.length === 0 && DefaultComponent) { - children = ; +class Slot extends React.Component { + shouldComponentUpdate(next) { + + // Prevent Slot from rerendering when only reduxState has changed and + // it does not result in a change of slot children. + const keys = union(Object.keys(this.props), Object.keys(next)); + const changes = keys.filter((key) => this.props[key] !== next[key]); + if (changes.length === 1 && changes[0] === 'reduxState') { + const prevChildrenUuid = this.getChildren(this.props).map((child) => child.type.talkUuid); + const nextChildrenUuid = this.getChildren(next).map((child) => child.type.talkUuid); + return !isEqual(prevChildrenUuid, nextChildrenUuid); + } + + // Prevent Slot from rerendering when no props has shallowly changed. + return changes.length !== 0; } - return ( -
- {children} -
- ); + getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, ...rest} = this.props) { + return rest; + } + + getChildren(props = this.props) { + return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props)); + } + + render() { + const {inline = false, className, reduxState, defaultComponent: DefaultComponent} = this.props; + let children = this.getChildren(); + const pluginConfig = reduxState.config.pluginConfig || {}; + if (children.length === 0 && DefaultComponent) { + children = ; + } + + return ( +
+ {children} +
+ ); + } } Slot.propTypes = { diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index b2ca78142..7bebaa1f6 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -9,6 +9,7 @@ import {loadTranslations} from 'coral-framework/services/i18n'; import {injectReducers} from 'coral-framework/services/store'; import camelize from './camelize'; import plugins from 'pluginsConfig'; +import uuid from 'uuid/v4'; export function getSlotComponents(slot, reduxState, props = {}) { const pluginConfig = reduxState.config.plugin_config || {}; @@ -100,7 +101,12 @@ function addMetaDataToSlotComponents() { const slots = plugin.module.slots; slots && Object.keys(slots).forEach((slot) => { slots[slot].forEach((component) => { + + // Attach plugin name to the component component.talkPluginName = plugin.name; + + // Attach uuid to the component + component.talkUuid = uuid(); }); }); }); From 4cfdb6574bfc651a8cf829ee387c59ec7597dc60 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 15 Aug 2017 21:23:39 +0700 Subject: [PATCH 02/40] withQuery props should be immutable --- client/coral-framework/hocs/withQuery.js | 129 ++++++++++++++--------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index adb67b7b3..2ddca7c7e 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -46,6 +46,7 @@ export default (document, config = {}) => (WrappedComponent) => { // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; lastNetworkStatus = null; + data = null; emitWhenNeeded(data) { const {variables, networkStatus} = data; @@ -60,59 +61,93 @@ export default (document, config = {}) => (WrappedComponent) => { this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, data: root}); } + nextData(data) { + this.emitWhenNeeded(data); + + // If data was previously set, we update it in a immutable way. + if (this.data) { + if (this.data.networkStatus !== data.networkStatus || + this.data.loading !== data.loading || + this.data.error !== data.error || + this.data.variables !== data.variables) { + this.data = { + ...this.data, + error: data.error, + networkStatus: data.networkStatus, + loading: data.loading, + variables: data.variables, + }; + } + } + else { + + // Set data for the first time. + this.data = { + error: data.error, + variables: data.variables, + networkStatus: data.networkStatus, + loading: data.loading, + startPolling: data.startPolling, + stopPolling: data.stopPolling, + refetch: data.refetch, + updateQuery: data.updateQuery, + subscribeToMore: (stmArgs) => { + + // Resolve document fragments before passing it to `apollo-client`. + return data.subscribeToMore({ + ...stmArgs, + document: resolveFragments(stmArgs.document), + onError: (err) => { + if (stmArgs.onErr) { + return stmArgs.onErr(err); + } + throw err; + }, + }); + }, + fetchMore: (lmArgs) => { + const fetchName = getDefinitionName(lmArgs.query); + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.begin`, + {variables: lmArgs.variables}); + + // Resolve document fragments before passing it to `apollo-client`. + return data.fetchMore({ + ...lmArgs, + query: resolveFragments(lmArgs.query), + }) + .then((res) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.success`, + {variables: lmArgs.variables, data: res.data}); + return Promise.resolve(res); + }) + .catch((err) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.error`, + {variables: lmArgs.variables, error: err}); + throw err; + }); + }, + }; + } + return this.data; + } + wrappedConfig = { ...config, options: config.options || {}, props: (args) => { - this.emitWhenNeeded(args.data); + const nextData = this.nextData(args.data); + const {root} = separateDataAndRoot(args.data); + if (config.props) { - const wrappedArgs = { - ...args, - data: { - ...args.data, - subscribeToMore: (stmArgs) => { + // Custom props, in this case we just pass the wrapped args to it. + return config.props({...args, data: {...args.data, ...nextData}}); + } - // Resolve document fragments before passing it to `apollo-client`. - return args.data.subscribeToMore({ - ...stmArgs, - document: resolveFragments(stmArgs.document), - onError: (err) => { - if (stmArgs.onErr) { - return stmArgs.onErr(err); - } - throw err; - }, - }); - }, - fetchMore: (lmArgs) => { - const fetchName = getDefinitionName(lmArgs.query); - this.context.eventEmitter.emit( - `query.${name}.fetchMore.${fetchName}.begin`, - {variables: lmArgs.variables}); - - // Resolve document fragments before passing it to `apollo-client`. - return args.data.fetchMore({ - ...lmArgs, - query: resolveFragments(lmArgs.query), - }) - .then((res) => { - this.context.eventEmitter.emit( - `query.${name}.fetchMore.${fetchName}.success`, - {variables: lmArgs.variables, data: res.data}); - return Promise.resolve(res); - }) - .catch((err) => { - this.context.eventEmitter.emit( - `query.${name}.fetchMore.${fetchName}.error`, - {variables: lmArgs.variables, error: err}); - throw err; - }); - }, - }, - }; - return config.props - ? config.props(wrappedArgs) - : separateDataAndRoot(wrappedArgs.data); + // Return our wrapped data with a separated root. + return {...args, data: nextData, root}; }, }; From 2b2e2f9b5f5fef5a67f6289e9ae2d4f7189933ce Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 15 Aug 2017 21:32:44 +0700 Subject: [PATCH 03/40] ClickOutside should only toggle action when open --- client/coral-embed-stream/src/components/Toggleable.js | 4 +++- client/talk-plugin-flags/components/FlagButton.js | 4 +++- .../client/components/PermalinkButton.js | 8 +++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/client/coral-embed-stream/src/components/Toggleable.js b/client/coral-embed-stream/src/components/Toggleable.js index 49f2a701b..a65e8212c 100644 --- a/client/coral-embed-stream/src/components/Toggleable.js +++ b/client/coral-embed-stream/src/components/Toggleable.js @@ -19,7 +19,9 @@ export default class Toggleable extends React.Component { } close = () => { - this.setState({isOpen: false}); + if (this.state.isOpen) { + this.setState({isOpen: false}); + } } render() { diff --git a/client/talk-plugin-flags/components/FlagButton.js b/client/talk-plugin-flags/components/FlagButton.js index 85ceed0ea..1d8438ba0 100644 --- a/client/talk-plugin-flags/components/FlagButton.js +++ b/client/talk-plugin-flags/components/FlagButton.js @@ -133,7 +133,9 @@ export default class FlagButton extends Component { } handleClickOutside = () => { - this.closeMenu(); + if (this.state.showMenu) { + this.closeMenu(); + } } render () { 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 = () => { From 33aea66ff774bf5ff48938eb4851018765e04050 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 15 Aug 2017 21:56:22 +0700 Subject: [PATCH 04/40] Refactor --- client/coral-framework/components/Slot.js | 7 +++---- client/coral-framework/utils/index.js | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index f86d44eb1..0b0f4d325 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -4,16 +4,15 @@ import styles from './Slot.css'; import {connect} from 'react-redux'; import {getSlotElements} from 'coral-framework/helpers/plugins'; import omit from 'lodash/omit'; -import union from 'lodash/union'; import isEqual from 'lodash/isEqual'; +import {getShallowChanges} from 'coral-framework/utils'; class Slot extends React.Component { shouldComponentUpdate(next) { // Prevent Slot from rerendering when only reduxState has changed and // it does not result in a change of slot children. - const keys = union(Object.keys(this.props), Object.keys(next)); - const changes = keys.filter((key) => this.props[key] !== next[key]); + const changes = getShallowChanges(this.props, next); if (changes.length === 1 && changes[0] === 'reduxState') { const prevChildrenUuid = this.getChildren(this.props).map((child) => child.type.talkUuid); const nextChildrenUuid = this.getChildren(next).map((child) => child.type.talkUuid); @@ -49,7 +48,7 @@ class Slot extends React.Component { } Slot.propTypes = { - fill: React.PropTypes.string + fill: React.PropTypes.string.isRequired }; const mapStateToProps = (state) => ({ diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 63dfd7dc4..2b4b6378f 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -1,5 +1,6 @@ import {gql} from 'react-apollo'; import t from 'coral-framework/services/i18n'; +import union from 'lodash/union'; import {capitalize} from 'coral-framework/helpers/strings'; export const getTotalActionCount = (type, comment) => { @@ -184,3 +185,8 @@ export function getSlotFragmentSpreads(slots, resource) { export function isCommentActive(commentStatus) { return ['NONE', 'ACCEPTED'].indexOf(commentStatus) >= 0; } + +export function getShallowChanges(a, b) { + return union(Object.keys(a), Object.keys(b)) + .filter((key) => a[key] !== b[key]); +} From 9ce6ab108a3b7c2ffe65fbbbdeaa73108ea8eafd Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 16 Aug 2017 22:07:25 +0700 Subject: [PATCH 05/40] Refactor StreamTabPanel and use hoistStatics for all hocs --- .../src/components/Comment.js | 2 +- .../src/components/Stream.js | 131 ++++++------------ .../src/components/StreamTabPanel.js | 23 +++ .../src/containers/Stream.js | 8 +- .../src/containers/StreamTabPanel.js | 84 +++++++++++ .../hocs/withCopyToClipboard.js | 5 +- client/coral-framework/hocs/withEmit.js | 7 +- client/coral-framework/hocs/withFragments.js | 24 +++- client/coral-framework/hocs/withMutation.js | 5 +- client/coral-framework/hocs/withQuery.js | 5 +- plugin-api/beta/client/hocs/withTags.js | 5 +- 11 files changed, 195 insertions(+), 104 deletions(-) create mode 100644 client/coral-embed-stream/src/components/StreamTabPanel.js create mode 100644 client/coral-embed-stream/src/containers/StreamTabPanel.js diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 6c231f4ae..b8063858d 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -72,7 +72,7 @@ const ActionButton = ({children}) => { ); }; -export default class Comment extends React.Component { +export default class Comment extends React.PureComponent { constructor(props) { super(props); diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 7b9e5f566..a9ea98a68 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -10,16 +10,16 @@ import {ModerationLink} from 'talk-plugin-moderation'; import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; import t, {timeago} from 'coral-framework/services/i18n'; -import {getSlotComponents} from 'coral-framework/helpers/plugins'; import CommentBox from 'talk-plugin-commentbox/CommentBox'; import QuestionBox from 'talk-plugin-questionbox/QuestionBox'; import {isCommentActive} from 'coral-framework/utils'; -import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui'; +import {Button, Tab, TabCount, TabPane} from 'coral-ui'; import cn from 'classnames'; import {getTopLevelParent, attachCommentToParent} from '../graphql/utils'; import AllCommentsPane from './AllCommentsPane'; import AutomaticAssetClosure from '../containers/AutomaticAssetClosure'; +import StreamTabPanel from '../containers/StreamTabPanel'; import styles from './Stream.css'; @@ -35,44 +35,9 @@ class Stream extends React.Component { componentWillReceiveProps(next) { // Keep comment box when user was live suspended, banned, ... - if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) { + if (!this.props.userIsDegraged && next.userIsDegraged) { this.setState({keepCommentBox: true}); } - - this.fallbackAllTab(next); - } - - componentDidMount() { - this.fallbackAllTab(); - } - - fallbackAllTab(props = this.props) { - if (props.activeStreamTab !== 'all') { - const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName); - if (slotPlugins.indexOf(props.activeStreamTab) === -1) { - props.setActiveStreamTab('all'); - } - } - } - - getSlotProps({data, root, root: {asset}} = this.props) { - return {data, root, asset}; - } - - getSlotComponents(slot, props = this.props) { - return getSlotComponents(slot, props.reduxState, this.getSlotProps(props)); - } - - setActiveReplyBox = (id) => { - if (!this.props.auth.user) { - this.props.showSignInDialog(); - } else { - this.props.setActiveReplyBox(id); - } - }; - - userIsDegraged({auth: {user}} = this.props) { - return !can(user, 'INTERACT_WITH_COMMUNITY'); } render() { @@ -99,7 +64,7 @@ class Stream extends React.Component { loadMoreComments, viewAllComments, auth: {loggedIn, user}, - editName + editName, } = this.props; const {keepCommentBox} = this.state; const open = !asset.isClosed; @@ -133,7 +98,7 @@ class Stream extends React.Component { }; const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); - const slotProps = this.getSlotProps(); + const slotProps = {data, root, asset}; if (!comment && !comments) { console.error('Talk: No comments came back from the graph given that query. Please, check the query params.'); @@ -247,55 +212,49 @@ class Stream extends React.Component { {...slotProps} /> - - {this.getSlotComponents('streamTabs').map((PluginComponent) => ( - - + + All Comments {totalCommentCount} - ))} - - All Comments {totalCommentCount} - - - - {this.getSlotComponents('streamTabPanes').map((PluginComponent) => ( - - + - ))} - - - - + } + sub + /> } diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.js b/client/coral-embed-stream/src/components/StreamTabPanel.js new file mode 100644 index 000000000..c5a292179 --- /dev/null +++ b/client/coral-embed-stream/src/components/StreamTabPanel.js @@ -0,0 +1,23 @@ +import React from 'react'; +import {TabBar, TabContent} from 'coral-ui'; + +class StreamTabPanel extends React.Component { + + render() { + const {activeTab, setActiveTab, appendTabs, appendTabPanes, pluginTabElements, pluginTabPaneElements, sub} = this.props; + return ( +
+ + {pluginTabElements} + {appendTabs} + + + {pluginTabPaneElements} + {appendTabPanes} + +
+ ); + } +} + +export default StreamTabPanel; diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 451b115a9..acc178261 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -16,6 +16,7 @@ import Comment from './Comment'; import {withFragments, withEmit} from 'coral-framework/hocs'; import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; import {Spinner} from 'coral-ui'; +import {can} from 'coral-framework/services/perms'; import { findCommentInEmbedQuery, insertCommentIntoEmbedQuery, @@ -23,7 +24,6 @@ import { insertFetchedCommentsIntoEmbedQuery, nest, } from '../graphql/utils'; -import omit from 'lodash/omit'; const {showSignInDialog, editName} = authActions; const {addNotification} = notificationActions; @@ -140,6 +140,10 @@ class StreamContainer extends React.Component { clearInterval(this.countPoll); } + userIsDegraged({auth: {user}} = this.props) { + return !can(user, 'INTERACT_WITH_COMMUNITY'); + } + render() { if (this.props.refetching) { return ; @@ -149,6 +153,7 @@ class StreamContainer extends React.Component { loadMore={this.loadMore} loadMoreComments={this.loadMoreComments} loadNewReplies={this.loadNewReplies} + userIsDegraged={this.userIsDegraged()} />; } } @@ -310,7 +315,6 @@ const mapStateToProps = (state) => ({ previousStreamTab: state.stream.previousTab, commentClassNames: state.stream.commentClassNames, pluginConfig: state.config.plugin_config, - reduxState: omit(state, 'apollo'), }); const mapDispatchToProps = (dispatch) => diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/StreamTabPanel.js new file mode 100644 index 000000000..574c4c881 --- /dev/null +++ b/client/coral-embed-stream/src/containers/StreamTabPanel.js @@ -0,0 +1,84 @@ +import React from 'react'; +import StreamTabPanel from '../components/StreamTabPanel'; +import {connect} from 'react-redux'; +import omit from 'lodash/omit'; +import {getSlotComponents} from 'coral-framework/helpers/plugins'; +import {Tab, TabPane} from 'coral-ui'; +import {getShallowChanges} from 'coral-framework/utils'; +import isEqual from 'lodash/isEqual'; + +class StreamTabPanelContainer extends React.Component { + + componentDidMount() { + this.fallbackAllTab(); + } + + componentWillReceiveProps(next) { + this.fallbackAllTab(next); + } + + shouldComponentUpdate(next) { + + // Prevent Slot from rerendering when only reduxState has changed and + // it does not result in a change of slot children. + const changes = getShallowChanges(this.props, next); + if (changes.length === 1 && changes[0] === 'reduxState') { + const prevUuid = this.getSlotComponents(this.props.tabSlot, this.props).map((cmp) => cmp.talkUuid); + const nextUuid = this.getSlotComponents(next.tabSlot, next).map((cmp) => cmp.talkUuid); + return !isEqual(prevUuid, nextUuid); + } + + // Prevent Slot from rerendering when no props has shallowly changed. + return changes.length !== 0; + } + + fallbackAllTab(props = this.props) { + if (props.activeTab !== props.fallbackTab) { + const slotPlugins = this.getSlotComponents(props.tabSlot, props).map((c) => c.talkPluginName); + if (slotPlugins.indexOf(props.activeTab) === -1) { + props.setActiveTab(props.fallbackTab); + } + } + } + + getSlotComponents(slot, props = this.props) { + return getSlotComponents(slot, props.reduxState, props.slotProps); + } + + getPluginTabElements(props = this.props) { + return this.getSlotComponents(props.tabSlot).map((PluginComponent) => ( + + + + )); + } + + getPluginTabPaneElements(props = this.props) { + return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => ( + + + + )); + } + + render() { + return ( + + ); + } +} + +const mapStateToProps = (state) => ({ + reduxState: omit(state, 'apollo'), +}); + +export default connect(mapStateToProps, null)(StreamTabPanelContainer); diff --git a/client/coral-framework/hocs/withCopyToClipboard.js b/client/coral-framework/hocs/withCopyToClipboard.js index 9e8046802..4de606c40 100644 --- a/client/coral-framework/hocs/withCopyToClipboard.js +++ b/client/coral-framework/hocs/withCopyToClipboard.js @@ -1,8 +1,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import Clipboard from 'clipboard'; +import hoistStatics from 'recompose/hoistStatics'; -export default (WrappedComponent) => { +export default hoistStatics((WrappedComponent) => { class WithCopyToClipboard extends React.Component { componentDidMount() { const clipboard = new Clipboard(ReactDOM.findDOMNode(this)); @@ -26,4 +27,4 @@ export default (WrappedComponent) => { } return WithCopyToClipboard; -}; +}); diff --git a/client/coral-framework/hocs/withEmit.js b/client/coral-framework/hocs/withEmit.js index 3a3216c8a..22d98bb27 100644 --- a/client/coral-framework/hocs/withEmit.js +++ b/client/coral-framework/hocs/withEmit.js @@ -1,11 +1,12 @@ import React from 'react'; -const PropTypes = require('prop-types'); +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; /** * WithEmit provides a property `emit: (eventName, value)` * to the wrapped component. */ -export default (WrappedComponent) => { +export default hoistStatics((WrappedComponent) => { class WithEmit extends React.Component { static contextTypes = { eventEmitter: PropTypes.object, @@ -24,4 +25,4 @@ export default (WrappedComponent) => { } return WithEmit; -}; +}); diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index 62e96444c..c422f8705 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,5 +1,21 @@ // TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. -export default (fragments) => (BaseComponent) => { - BaseComponent.fragments = fragments; - return BaseComponent; -}; + +import React from 'react'; +import {resolveFragments} from 'coral-framework/services/graphqlRegistry'; +import mapValues from 'lodash/mapValues'; +import hoistStatics from 'recompose/hoistStatics'; + +export default (fragments) => hoistStatics((BaseComponent) => { + class WithFragments extends React.Component { + fragments = mapValues(fragments, (val) => resolveFragments(val)); + + render() { + return ; + } + } + + WithFragments.fragments = fragments; + return WithFragments; +}); diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index 7e2c3d09d..bb2321d11 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -8,6 +8,7 @@ import {getMutationOptions, resolveFragments} from 'coral-framework/services/gra import {getDefinitionName, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; +import hoistStatics from 'recompose/hoistStatics'; class ResponseErrors extends Error { constructor(errors) { @@ -30,7 +31,7 @@ class ResponseError { * Exports a HOC with the same signature as `graphql`, that will * apply mutation options registered in the graphRegistry. */ -export default (document, config = {}) => (WrappedComponent) => { +export default (document, config = {}) => hoistStatics((WrappedComponent) => { config = { ...config, options: config.options || {}, @@ -147,4 +148,4 @@ export default (document, config = {}) => (WrappedComponent) => { return ; } }; -}; +}); diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index d98400419..f19c3209e 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -3,6 +3,7 @@ import {graphql} from 'react-apollo'; import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; +import hoistStatics from 'recompose/hoistStatics'; const withSkipOnErrors = (reducer) => (prev, action, ...rest) => { if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) { @@ -35,7 +36,7 @@ function networkStatusToString(networkStatus) { * Exports a HOC with the same signature as `graphql`, that will * apply query options registered in the graphRegistry. */ -export default (document, config = {}) => (WrappedComponent) => { +export default (document, config = {}) => hoistStatics((WrappedComponent) => { const name = getDefinitionName(document); return class WithQuery extends React.Component { @@ -190,4 +191,4 @@ export default (document, config = {}) => (WrappedComponent) => { return ; } }; -}; +}); diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index b0a74f4d7..428c3f43f 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -8,8 +8,9 @@ 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'; -export default (tag) => (WrappedComponent) => { +export default (tag) => hoistStatics((WrappedComponent) => { if (typeof tag !== 'string') { console.error('Tag must be a valid string'); return null; @@ -109,4 +110,4 @@ export default (tag) => (WrappedComponent) => { WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`; return enhance(WithTags); -}; +}); From 4aa6b530228352dbcd3f6387fcd88582d7866675 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 16 Aug 2017 22:32:44 +0700 Subject: [PATCH 06/40] PropTypes for StreamTabPanel --- .../src/components/Stream.js | 4 +-- .../src/components/StreamTabPanel.js | 24 ++++++++++++---- .../src/containers/StreamTabPanel.js | 28 +++++++++++++++++-- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index a9ea98a68..26b054e0d 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -220,12 +220,12 @@ class Stream extends React.Component { tabPaneSlot={'streamTabPanes'} slotProps={slotProps} appendTabs={ - + All Comments {totalCommentCount} } appendTabPanes={ - + - {pluginTabElements} - {appendTabs} + {tabs} - {pluginTabPaneElements} - {appendTabPanes} + {tabPanes} ); } } +StreamTabPanel.propTypes = { + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, + tabs: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element) + ]), + tabPanes: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element) + ]), + className: PropTypes.string, + sub: PropTypes.bool, +}; + export default StreamTabPanel; diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/StreamTabPanel.js index 574c4c881..4a313d2b4 100644 --- a/client/coral-embed-stream/src/containers/StreamTabPanel.js +++ b/client/coral-embed-stream/src/containers/StreamTabPanel.js @@ -6,6 +6,7 @@ import {getSlotComponents} from 'coral-framework/helpers/plugins'; import {Tab, TabPane} from 'coral-ui'; import {getShallowChanges} from 'coral-framework/utils'; import isEqual from 'lodash/isEqual'; +import PropTypes from 'prop-types'; class StreamTabPanelContainer extends React.Component { @@ -69,14 +70,35 @@ class StreamTabPanelContainer extends React.Component { render() { return ( ); } } +StreamTabPanelContainer.propTypes = { + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, + appendTabs: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element) + ]), + appendTabPanes: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element) + ]), + fallbackTab: PropTypes.string.isRequired, + tabSlot: PropTypes.string.isRequired, + tabPaneSlot: PropTypes.string.isRequired, + className: PropTypes.string, + sub: PropTypes.bool, +}; + const mapStateToProps = (state) => ({ reduxState: omit(state, 'apollo'), }); From ae81d5cb3c703d13832c508d79b42e2c7fac9d08 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 16 Aug 2017 22:43:48 +0700 Subject: [PATCH 07/40] Optimize IfSlotIsX rendering --- .../components/IfSlotIsEmpty.js | 35 +++++++++++++++---- .../components/IfSlotIsNotEmpty.js | 35 +++++++++++++++---- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/client/coral-framework/components/IfSlotIsEmpty.js b/client/coral-framework/components/IfSlotIsEmpty.js index 424a59bc7..e9211fc57 100644 --- a/client/coral-framework/components/IfSlotIsEmpty.js +++ b/client/coral-framework/components/IfSlotIsEmpty.js @@ -3,13 +3,36 @@ import {connect} from 'react-redux'; import {isSlotEmpty} from 'coral-framework/helpers/plugins'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; +import {getShallowChanges} from 'coral-framework/utils'; -function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) { - return ( - - {isSlotEmpty(slot, reduxState, rest) ? children : null} - - ); +class IfSlotIsEmpty extends React.Component { + + shouldComponentUpdate(next) { + + // Prevent Slot from rerendering when only reduxState has changed and + // it does not result in a change. + const changes = getShallowChanges(this.props, next); + if (changes.length === 1 && changes[0] === 'reduxState') { + return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next); + } + + // Prevent Slot from rerendering when no props has shallowly changed. + return changes.length !== 0; + } + + isSlotEmpty(props = this.props) { + const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props; + return isSlotEmpty(slot, reduxState, rest); + } + + render() { + const {className, component: Component = 'div', children} = this.props; + return ( + + {this.isSlotEmpty() ? children : null} + + ); + } } IfSlotIsEmpty.propTypes = { diff --git a/client/coral-framework/components/IfSlotIsNotEmpty.js b/client/coral-framework/components/IfSlotIsNotEmpty.js index 3a41fffbd..e7a0e83ce 100644 --- a/client/coral-framework/components/IfSlotIsNotEmpty.js +++ b/client/coral-framework/components/IfSlotIsNotEmpty.js @@ -3,13 +3,36 @@ import {connect} from 'react-redux'; import {isSlotEmpty} from 'coral-framework/helpers/plugins'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; +import {getShallowChanges} from 'coral-framework/utils'; -function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) { - return ( - - {!isSlotEmpty(slot, reduxState, rest) ? children : null} - - ); +class IfSlotIsNotEmpty extends React.Component { + + shouldComponentUpdate(next) { + + // Prevent Slot from rerendering when only reduxState has changed and + // it does not result in a change. + const changes = getShallowChanges(this.props, next); + if (changes.length === 1 && changes[0] === 'reduxState') { + return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next); + } + + // Prevent Slot from rerendering when no props has shallowly changed. + return changes.length !== 0; + } + + isSlotEmpty(props = this.props) { + const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props; + return isSlotEmpty(slot, reduxState, rest); + } + + render() { + const {className, component: Component = 'div', children} = this.props; + return ( + + {this.isSlotEmpty() ? null : children} + + ); + } } IfSlotIsNotEmpty.propTypes = { From f8031be86589e3cbdebfadb862eae77a8a1748b3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 16 Aug 2017 23:41:14 +0700 Subject: [PATCH 08/40] callback props from withMutation now keep their identity --- .../src/components/Comment.js | 2 +- client/coral-framework/hocs/withMutation.js | 35 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index b8063858d..6c231f4ae 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -72,7 +72,7 @@ const ActionButton = ({children}) => { ); }; -export default class Comment extends React.PureComponent { +export default class Comment extends React.Component { constructor(props) { super(props); diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index bb2321d11..7c3dcfa1a 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -9,6 +9,7 @@ import {getDefinitionName, getResponseErrors} from '../utils'; import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; import hoistStatics from 'recompose/hoistStatics'; +import union from 'lodash/union'; class ResponseErrors extends Error { constructor(errors) { @@ -47,7 +48,13 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { // Lazily resolve fragments from graphRegistry to support circular dependencies. memoized = null; - wrappedProps = (data) => { + // Props as we would pass to the BaseComponent without optimizations. + dynamicProps = {}; + + // Props that are optimized by keeping the identity of function callbacks. + staticProps = {}; + + propsWrapper = (data) => { const name = getDefinitionName(document); const callbacks = getMutationOptions(name); const mutate = (base) => { @@ -133,12 +140,34 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { throw error; }); }; - return config.props({...data, mutate}); + + // Save current props to `dynamicProps` + this.dynamicProps = config.props({...data, mutate}); + + // Sync props to `staticProps`. + // `staticProps` ultimately contains the same props as `dynamicProps` but all callbacks + // keep their identity. + union(Object.keys(this.dynamicProps), Object.keys(this.staticProps)).forEach((key) => { + if (!(key in this.dynamicProps)) { + delete this.staticProps[key]; + return; + } + if (typeof this.dynamicProps[key] !== 'function') { + this.staticProps[key] = this.dynamicProps[key]; + return; + } + + if (!(key in this.staticProps)) { + this.staticProps[key] = (...args) => this.dynamicProps[key](...args); + return; + } + }); + return this.staticProps; }; getWrapped = () => { if (!this.memoized) { - this.memoized = graphql(resolveFragments(document), {...config, props: this.wrappedProps})(WrappedComponent); + this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent); } return this.memoized; }; From df141925fb755544f19b4999c0be3c73eac0eca2 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 17:15:06 +0700 Subject: [PATCH 09/40] add hoistStatics to withReaction --- plugin-api/beta/client/hocs/withReaction.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 22c30d639..9ac49cf43 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -11,9 +11,10 @@ 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'; -export default (reaction) => (WrappedComponent) => { +export default (reaction) => hoistStatics((WrappedComponent) => { if (typeof reaction !== 'string') { console.error('Reaction must be a valid string'); return null; @@ -391,4 +392,4 @@ export default (reaction) => (WrappedComponent) => { WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; return enhance(WithReactions); -}; +}); From 1ff6c5bdcc7aa95a637b31189f5a56bbb039cc66 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 17:16:15 +0700 Subject: [PATCH 10/40] Use comment container --- .../src/components/AllCommentsPane.js | 2 +- .../src/components/Comment.js | 23 ++------- .../src/components/Stream.js | 2 +- .../src/containers/Comment.js | 47 +++++++++++++++++-- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/client/coral-embed-stream/src/components/AllCommentsPane.js b/client/coral-embed-stream/src/components/AllCommentsPane.js index 6c5d6bb23..c141f0618 100644 --- a/client/coral-embed-stream/src/components/AllCommentsPane.js +++ b/client/coral-embed-stream/src/components/AllCommentsPane.js @@ -5,7 +5,7 @@ import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import NewCount from './NewCount'; import {TransitionGroup} from 'react-transition-group'; import {forEachError} from 'coral-framework/utils'; -import Comment from '../components/Comment'; +import Comment from '../containers/Comment'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 6c231f4ae..39c3b5edf 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -24,6 +24,7 @@ import InactiveCommentLabel from './InactiveCommentLabel'; import {EditableCommentContent} from './EditableCommentContent'; import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive} from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; +import CommentContainer from '../containers/Comment'; const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF'); const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length; @@ -86,7 +87,6 @@ export default class Comment extends React.Component { // Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment) isEditing: false, replyBoxVisible: false, - animateEnter: false, loadingState: '', ...resetCursors({}, props), }; @@ -112,22 +112,6 @@ export default class Comment extends React.Component { } } - componentWillEnter(callback) { - callback(); - const userId = this.props.currentUser ? this.props.currentUser.id : null; - if (this.props.comment.id.indexOf('pending') >= 0) { - return; - } - if (userId && this.props.comment.user.id === userId) { - - // This comment was just added by currentUser. - if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) { - return; - } - } - this.setState({animateEnter: true}); - } - static propTypes = { // id of currently opened ReplyBox. tracked in Stream.js @@ -315,6 +299,7 @@ export default class Comment extends React.Component { showSignInDialog, liveUpdates, commentIsIgnored, + animateEnter, commentClassNames = [] } = this.props; @@ -367,7 +352,7 @@ export default class Comment extends React.Component { styles[`rootLevel${depth}`], { ...conditionalClassNames, - [styles.enter]: this.state.animateEnter, + [styles.enter]: animateEnter, }, ); @@ -542,7 +527,7 @@ export default class Comment extends React.Component { {view.map((reply) => { return commentIsIgnored(reply) ? - : { + class WithAnimateEnter extends React.Component { + state = { + animateEnter: false, + }; + + componentWillEnter(callback) { + callback(); + const userId = this.props.currentUser ? this.props.currentUser.id : null; + if (this.props.comment.id.indexOf('pending') >= 0) { + return; + } + if (userId && this.props.comment.user.id === userId) { + + // This comment was just added by currentUser. + if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) { + return; + } + } + this.setState({animateEnter: true}); + } + + render() { + return ; + } + } + return WithAnimateEnter; +}); + +const withCommentFragments = withFragments({ root: gql` fragment CoralEmbedStream_Comment_root on RootQuery { __typename @@ -57,4 +91,11 @@ export default withFragments({ ${getSlotFragmentSpreads(slots, 'comment')} } ` -})(Comment); +}); + +const enhance = compose( + withAnimateEnter, + withCommentFragments, +); + +export default enhance(Comment); From 7ea6d133c2e7a7693a2201a8d7b257ecb39eefad Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 21:31:09 +0700 Subject: [PATCH 11/40] Filter queryData in withFragments and optimize rendering --- .../src/components/Comment.js | 18 +++- .../src/components/Stream.js | 29 ++++--- .../src/containers/Comment.js | 68 +++++++++------ .../src/containers/Stream.js | 33 ++----- .../src/containers/StreamTabPanel.js | 10 ++- client/coral-framework/components/Slot.js | 17 ++-- client/coral-framework/helpers/plugins.js | 39 +++++++-- client/coral-framework/hocs/withFragments.js | 85 ++++++++++++++++++- plugin-api/beta/client/hocs/withReaction.js | 11 ++- plugin-api/beta/client/hocs/withTags.js | 6 ++ .../client/containers/TabPane.js | 4 +- 11 files changed, 227 insertions(+), 93 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 39c3b5edf..cc1ee7bed 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -224,6 +224,10 @@ export default class Comment extends React.Component { return; } + commentPostedHandler = () => { + this.props.setActiveReplyBox(''); + } + // getVisibileReplies returns a list containing comments // which were authored by current user or comes before the `idCursor`. getVisibileReplies() { @@ -372,10 +376,13 @@ export default class Comment extends React.Component { // props that are passed down the slots. const slotProps = { data, + depth, + }; + + const queryData = { root, asset, comment, - depth, }; return ( @@ -389,6 +396,7 @@ export default class Comment extends React.Component { className={`${styles.commentAvatar} talk-stream-comment-avatar`} fill="commentAvatar" {...slotProps} + queryData={queryData} inline /> @@ -411,6 +419,7 @@ export default class Comment extends React.Component { className={styles.commentInfoBar} fill="commentInfoBar" {...slotProps} + queryData={queryData} /> { isActive && (currentUser && (comment.user.id === currentUser.id)) && @@ -457,6 +466,7 @@ export default class Comment extends React.Component { fill="commentContent" defaultComponent={CommentContent} {...slotProps} + queryData={queryData} /> } @@ -468,6 +478,7 @@ export default class Comment extends React.Component { {!disableReply && @@ -484,6 +495,7 @@ export default class Comment extends React.Component { fill="commentActions" wrapperComponent={ActionButton} {...slotProps} + queryData={queryData} inline /> @@ -509,9 +521,7 @@ export default class Comment extends React.Component { {activeReplyBox === comment.id ? { - setActiveReplyBox(''); - }} + commentPostedHandler={this.commentPostedHandler} charCountEnable={charCountEnable} maxCharCount={maxCharCount} setActiveReplyBox={setActiveReplyBox} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index aa1221943..cf74813e9 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -40,6 +40,15 @@ class Stream extends React.Component { } } + commentIsIgnored = (comment) => { + const me = this.props.root.me; + return ( + me && + me.ignoredUsers && + me.ignoredUsers.find((u) => u.id === comment.user.id) + ); + }; + render() { const { data, @@ -48,7 +57,7 @@ class Stream extends React.Component { setActiveReplyBox, appendItemArray, commentClassNames, - root: {asset, asset: {comment, comments, totalCommentCount}, me}, + root: {asset, asset: {comment, comments, totalCommentCount}}, postComment, addNotification, editComment, @@ -89,16 +98,9 @@ class Stream extends React.Component { user.suspension.until && new Date(user.suspension.until) > new Date(); - const commentIsIgnored = (comment) => { - return ( - me && - me.ignoredUsers && - me.ignoredUsers.find((u) => u.id === comment.user.id) - ); - }; - const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); - const slotProps = {data, root, asset}; + const slotProps = {data}; + const slotQueryData = {root, asset}; if (!comment && !comments) { console.error('Talk: No comments came back from the graph given that query. Please, check the query params.'); @@ -160,6 +162,7 @@ class Stream extends React.Component { @@ -194,7 +197,7 @@ class Stream extends React.Component { deleteAction={deleteAction} showSignInDialog={showSignInDialog} key={highlightedComment.id} - commentIsIgnored={commentIsIgnored} + commentIsIgnored={this.commentIsIgnored} comment={highlightedComment} charCountEnable={asset.settings.charCountEnable} maxCharCount={asset.settings.charCount} @@ -209,6 +212,7 @@ class Stream extends React.Component { > @@ -219,6 +223,7 @@ class Stream extends React.Component { tabSlot={'streamTabs'} tabPaneSlot={'streamTabPanes'} slotProps={slotProps} + queryData={slotQueryData} appendTabs={ All Comments {totalCommentCount} @@ -245,7 +250,7 @@ class Stream extends React.Component { loadNewReplies={loadNewReplies} deleteAction={deleteAction} showSignInDialog={showSignInDialog} - commentIsIgnored={commentIsIgnored} + commentIsIgnored={this.commentIsIgnored} charCountEnable={asset.settings.charCountEnable} maxCharCount={asset.settings.charCount} editComment={editComment} diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 40c2fa000..5f3ee0c9d 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -3,7 +3,9 @@ import React from 'react'; import Comment from '../components/Comment'; import {withFragments} from 'coral-framework/hocs'; import {getSlotFragmentSpreads} from 'coral-framework/utils'; +import {THREADING_LEVEL} from '../constants/stream'; import hoistStatics from 'recompose/hoistStatics'; +import {nest} from '../graphql/utils'; const slots = [ 'streamQuestionArea', @@ -48,6 +50,36 @@ const withAnimateEnter = hoistStatics((BaseComponent) => { return WithAnimateEnter; }); +const singleCommentFragment = gql` + fragment CoralEmbedStream_Comment_SingleComment on Comment { + id + body + created_at + status + replyCount + tags { + tag { + name + } + } + user { + id + username + } + action_summaries { + __typename + count + current_user { + id + } + } + editing { + edited + editableUntil + } + } +`; + const withCommentFragments = withFragments({ root: gql` fragment CoralEmbedStream_Comment_root on RootQuery { @@ -63,33 +95,21 @@ const withCommentFragments = withFragments({ `, comment: gql` fragment CoralEmbedStream_Comment_comment on Comment { - id - body - created_at - status - replyCount - tags { - tag { - name + ...CoralEmbedStream_Comment_SingleComment + ${nest(` + replies(limit: 3, excludeIgnored: $excludeIgnored) { + nodes { + ...CoralEmbedStream_Comment_SingleComment + ...nest + } + hasNextPage + startCursor + endCursor } - } - user { - id - username - } - action_summaries { - __typename - count - current_user { - id - } - } - editing { - edited - editableUntil - } + `, THREADING_LEVEL)} ${getSlotFragmentSpreads(slots, 'comment')} } + ${singleCommentFragment} ` }); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index acc178261..f433bfb77 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -161,19 +161,11 @@ class StreamContainer extends React.Component { const commentFragment = gql` fragment CoralEmbedStream_Stream_comment on Comment { id + status + user { + id + } ...${getDefinitionName(Comment.fragments.comment)} - ${nest(` - replies(excludeIgnored: $excludeIgnored) { - nodes { - id - ...${getDefinitionName(Comment.fragments.comment)} - ...nest - } - hasNextPage - startCursor - endCursor - } - `, THREADING_LEVEL)} } ${Comment.fragments.comment} `; @@ -210,27 +202,14 @@ const LOAD_MORE_QUERY = gql` query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) { comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) { nodes { - id - ...${getDefinitionName(Comment.fragments.comment)} - ${nest(` - replies(limit: 3, excludeIgnored: $excludeIgnored) { - nodes { - id - ...${getDefinitionName(Comment.fragments.comment)} - ...nest - } - hasNextPage - startCursor - endCursor - } - `, THREADING_LEVEL)} + ...CoralEmbedStream_Stream_comment } hasNextPage startCursor endCursor } } - ${Comment.fragments.comment} + ${commentFragment} `; const slots = [ diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/StreamTabPanel.js index 4a313d2b4..7b91d58ee 100644 --- a/client/coral-embed-stream/src/containers/StreamTabPanel.js +++ b/client/coral-embed-stream/src/containers/StreamTabPanel.js @@ -2,7 +2,7 @@ import React from 'react'; import StreamTabPanel from '../components/StreamTabPanel'; import {connect} from 'react-redux'; import omit from 'lodash/omit'; -import {getSlotComponents} from 'coral-framework/helpers/plugins'; +import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins'; import {Tab, TabPane} from 'coral-ui'; import {getShallowChanges} from 'coral-framework/utils'; import isEqual from 'lodash/isEqual'; @@ -43,14 +43,14 @@ class StreamTabPanelContainer extends React.Component { } getSlotComponents(slot, props = this.props) { - return getSlotComponents(slot, props.reduxState, props.slotProps); + return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData); } getPluginTabElements(props = this.props) { return this.getSlotComponents(props.tabSlot).map((PluginComponent) => ( @@ -61,7 +61,7 @@ class StreamTabPanelContainer extends React.Component { return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => ( )); @@ -95,6 +95,8 @@ StreamTabPanelContainer.propTypes = { fallbackTab: PropTypes.string.isRequired, tabSlot: PropTypes.string.isRequired, tabPaneSlot: PropTypes.string.isRequired, + slotProps: PropTypes.object.isRequired, + queryData: PropTypes.object, className: PropTypes.string, sub: PropTypes.bool, }; diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 0b0f4d325..838382978 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -2,11 +2,13 @@ import React from 'react'; import cn from 'classnames'; import styles from './Slot.css'; import {connect} from 'react-redux'; -import {getSlotElements} from 'coral-framework/helpers/plugins'; +import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins'; import omit from 'lodash/omit'; import isEqual from 'lodash/isEqual'; import {getShallowChanges} from 'coral-framework/utils'; +const emptyConfig = {}; + class Slot extends React.Component { shouldComponentUpdate(next) { @@ -23,20 +25,20 @@ class Slot extends React.Component { return changes.length !== 0; } - getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, ...rest} = this.props) { + getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) { return rest; } getChildren(props = this.props) { - return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props)); + return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData); } render() { - const {inline = false, className, reduxState, defaultComponent: DefaultComponent} = this.props; + const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props; let children = this.getChildren(); - const pluginConfig = reduxState.config.pluginConfig || {}; + const pluginConfig = reduxState.config.pluginConfig || emptyConfig; if (children.length === 0 && DefaultComponent) { - children = ; + children = ; } return ( @@ -48,7 +50,8 @@ class Slot extends React.Component { } Slot.propTypes = { - fill: React.PropTypes.string.isRequired + fill: React.PropTypes.string.isRequired, + queryData: React.PropTypes.object, }; const mapStateToProps = (state) => ({ diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 7bebaa1f6..7f6e5f1c4 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -11,8 +11,11 @@ import camelize from './camelize'; import plugins from 'pluginsConfig'; import uuid from 'uuid/v4'; -export function getSlotComponents(slot, reduxState, props = {}) { - const pluginConfig = reduxState.config.plugin_config || {}; +// This is returned for pluginConfig when it is empty. +const emptyConfig = {}; + +export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) { + const pluginConfig = reduxState.config.plugin_config || emptyConfig; return flatten(plugins // Filter out components that have slots and have been disabled in `plugin_config` @@ -25,7 +28,7 @@ export function getSlotComponents(slot, reduxState, props = {}) { if(!component.isExcluded) { return true; } - let resolvedProps = {...props, config: pluginConfig}; + let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData); if (component.mapStateToProps) { resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)}; } @@ -33,17 +36,35 @@ export function getSlotComponents(slot, reduxState, props = {}) { }); } -export function isSlotEmpty(slot, reduxState, props) { - return getSlotComponents(slot, reduxState, props).length === 0; +export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) { + return getSlotComponents(slot, reduxState, props, queryData).length === 0; +} + +/** + * getSlotComponentProps calculate the props we would pass to the slot component. + * query datas are only passed to the component if it is defined in `component.fragments`. + */ +export function getSlotComponentProps(component, reduxState, props, queryData) { + const pluginConfig = reduxState.config.plugin_config || emptyConfig; + return { + ...props, + config: pluginConfig, + ...( + component.fragments + ? pick(queryData, Object.keys(component.fragments)) + : queryData // TODO: should be {} + ) + }; } /** * Returns React Elements for given slot. */ -export function getSlotElements(slot, reduxState, props = {}) { - const pluginConfig = reduxState.config.plugin_config || {}; - return getSlotComponents(slot, reduxState, props) - .map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig})); +export function getSlotElements(slot, reduxState, props = {}, queryData = {}) { + return getSlotComponents(slot, reduxState, props, queryData) + .map((component, i) => { + return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)}); + }); } export function getSlotFragments(slot, part) { diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index c422f8705..aef7147d8 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -1,17 +1,98 @@ -// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. - import React from 'react'; +import graphql from 'graphql-anywhere'; import {resolveFragments} from 'coral-framework/services/graphqlRegistry'; import mapValues from 'lodash/mapValues'; import hoistStatics from 'recompose/hoistStatics'; +import {getShallowChanges} from 'coral-framework/utils'; + +// TODO: Should not depend on `props.data` +// Currently necessary because of this https://github.com/apollographql/graphql-anywhere/issues/38 +function filter(doc, data, variables) { + const resolver = ( + fieldName, + root, + args, + context, + info, + ) => { + return root[info.resultKey]; + }; + + return graphql(resolver, doc, data, null, variables); +} + +// filterProps returns only the property as defined in the fragments. +// TODO: Should not depend on `props.data` +function filterProps(props, fragments) { + const filtered = {}; + Object.keys(fragments).forEach((key) => { + if (!(key in props)) { + return; + } + filtered[key] = filter(fragments[key], props[key], props.data.variables); + }); + return filtered; +} + +// hasEqualLeaves compares two different apollo query result for equality. +function hasEqualLeaves(a, b, path = '') { + for (const key in a) { + if (typeof a[key] === 'object') { + if (Array.isArray(a[key])) { + if (a[key].length !== b[key].length) { + return false; + } + } + if (!hasEqualLeaves(a[key], b[key], `${path}.${key}`)) { + return false; + } + continue; + } + if (a[key] !== b[key]) { + return false; + } + } + return true; +} export default (fragments) => hoistStatics((BaseComponent) => { class WithFragments extends React.Component { fragments = mapValues(fragments, (val) => resolveFragments(val)); + fragmentKeys = Object.keys(fragments).sort(); + + // Cache variables between lifecycles to speed up render. + filteredProps = filterProps(this.props, this.fragments) + queryDataHasChanged = false; + lastFilteredProps = null; + shallowChanges = null; + + componentWillReceiveProps(next) { + this.shallowChanges = getShallowChanges(this.props, next); + this.queryDataHasChanged = this.fragmentKeys.some((key) => this.shallowChanges.indexOf(key) >= 0); + + if (this.queryDataHasChanged) { + + // If query data has changed, we compute the next filtered props. + this.lastFilteredProps = this.filteredProps; + this.filteredProps = filterProps(next, this.fragments); + } + } + + shouldComponentUpdate(next) { + + // If only query data was changed. + if (this.queryDataHasChanged && this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0)) { + return !hasEqualLeaves(this.lastFilteredProps, this.filteredProps); + } + + return this.shallowChanges.length !== 0; + } render() { + const queryProps = this.filteredProps; return ; } } diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 9ac49cf43..0d118a12f 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -176,7 +176,7 @@ export default (reaction) => hoistStatics((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, @@ -186,7 +186,7 @@ export default (reaction) => hoistStatics((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, @@ -372,9 +372,16 @@ export default (reaction) => hoistStatics((WrappedComponent) => { const enhance = compose( withFragments({ + asset: gql` + fragment ${Reaction}Button_asset on Asset { + id + } + `, comment: gql` fragment ${Reaction}Button_comment on Comment { + id action_summaries { + __typename ... on ${Reaction}ActionSummary { count current_user { diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index 428c3f43f..b1d565557 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -93,8 +93,14 @@ export default (tag) => hoistStatics((WrappedComponent) => { const enhance = compose( withFragments({ + asset: gql` + fragment ${Tag}Button_asset on Asset { + id + } + `, comment: gql` fragment ${Tag}Button_comment on Comment { + id tags { tag { name diff --git a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js index 2195b0107..63ce30d01 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js +++ b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js @@ -16,8 +16,8 @@ class TabPaneContainer extends React.Component { query: LOAD_MORE_QUERY, variables: { limit: 5, - cursor: this.props.root.asset.featuredComments.endCursor, - asset_id: this.props.root.asset.id, + cursor: this.props.asset.featuredComments.endCursor, + asset_id: this.props.asset.id, sort: 'REVERSE_CHRONOLOGICAL', excludeIgnored: this.props.data.variables.excludeIgnored, }, From d228d49c7d0388bfbfa7800d4bd7826b44b2cdf3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 22:44:13 +0700 Subject: [PATCH 12/40] Optimize rendering when `activeReplyBox` changes --- .../src/components/Comment.js | 31 ++++++++++++++++++- .../coral-embed-stream/src/graphql/utils.js | 6 +++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index cc1ee7bed..d34de96ac 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -16,13 +16,14 @@ import mapValues from 'lodash/mapValues'; import LoadMore from './LoadMore'; import {getEditableUntilDate} from './util'; +import {findCommentWithId} from '../graphql/utils'; import {TopRightMenu} from './TopRightMenu'; import CommentContent from './CommentContent'; import Slot from 'coral-framework/components/Slot'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import InactiveCommentLabel from './InactiveCommentLabel'; import {EditableCommentContent} from './EditableCommentContent'; -import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive} from 'coral-framework/utils'; +import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive, getShallowChanges} from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; import CommentContainer from '../containers/Comment'; @@ -73,6 +74,17 @@ const ActionButton = ({children}) => { ); }; +// Determine whether the comment with id is in the part of the comments tree. +function containsCommentId(props, id) { + if (props.comment.id === id) { + return true; + } + if (props.comment.replies) { + return findCommentWithId(props.comment.replies.nodes, id); + } + return false; +} + export default class Comment extends React.Component { constructor(props) { @@ -112,6 +124,23 @@ export default class Comment extends React.Component { } } + shouldComponentUpdate(next) { + + // Specifically handle `activeReplyBox` if it is the only change. + const changes = getShallowChanges(this.props, next); + if (changes.length === 1 && changes[0] === 'activeReplyBox') { + if ( + !containsCommentId(next, this.props.activeReplyBox) && + !containsCommentId(next, next.activeReplyBox) + ) { + return false; + } + } + + // Prevent Slot from rerendering when no props has shallowly changed. + return changes.length !== 0; + } + static propTypes = { // id of currently opened ReplyBox. tracked in Stream.js diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index ac6298616..11d3d411b 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -117,7 +117,7 @@ export function getTopLevelParent(comment) { return comment; } -function findComment(nodes, callback) { +export function findComment(nodes, callback) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (callback(node)) { @@ -133,6 +133,10 @@ function findComment(nodes, callback) { return false; } +export function findCommentWithId(nodes, id) { + return findComment(nodes, (node) => node.id === id); +} + export function findCommentInEmbedQuery(root, callbackOrId) { let callback = callbackOrId; if (typeof callbackOrId === 'string') { From 25c11ad690b0da8bd6fbe9e754dbf911c788780d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 23:22:30 +0700 Subject: [PATCH 13/40] Keep old query data when not changed in withFragments --- client/coral-framework/hocs/withFragments.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index aef7147d8..76a6f7035 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -63,18 +63,19 @@ export default (fragments) => hoistStatics((BaseComponent) => { // Cache variables between lifecycles to speed up render. filteredProps = filterProps(this.props, this.fragments) queryDataHasChanged = false; - lastFilteredProps = null; shallowChanges = null; componentWillReceiveProps(next) { this.shallowChanges = getShallowChanges(this.props, next); - this.queryDataHasChanged = this.fragmentKeys.some((key) => this.shallowChanges.indexOf(key) >= 0); - if (this.queryDataHasChanged) { + if (this.fragmentKeys.some((key) => this.shallowChanges.indexOf(key) >= 0)) { + const nextFilteredProps = filterProps(next, this.fragments); + this.queryDataHasChanged = !hasEqualLeaves(this.filteredProps, nextFilteredProps); + if (this.queryDataHasChanged) { - // If query data has changed, we compute the next filtered props. - this.lastFilteredProps = this.filteredProps; - this.filteredProps = filterProps(next, this.fragments); + // Only changed props when query data has changed. + this.filteredProps = filterProps(next, this.fragments); + } } } @@ -82,7 +83,7 @@ export default (fragments) => hoistStatics((BaseComponent) => { // If only query data was changed. if (this.queryDataHasChanged && this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0)) { - return !hasEqualLeaves(this.lastFilteredProps, this.filteredProps); + return this.queryDataHasChanged; } return this.shallowChanges.length !== 0; From af4b01c2aee7a91a0b250c5902b78d99214fc486 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 17 Aug 2017 23:23:18 +0700 Subject: [PATCH 14/40] Correctly handly activeReplyBox optimization --- client/coral-embed-stream/src/components/Comment.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index d34de96ac..dcc52d85a 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -130,7 +130,7 @@ export default class Comment extends React.Component { const changes = getShallowChanges(this.props, next); if (changes.length === 1 && changes[0] === 'activeReplyBox') { if ( - !containsCommentId(next, this.props.activeReplyBox) && + !containsCommentId(this.props, this.props.activeReplyBox) && !containsCommentId(next, next.activeReplyBox) ) { return false; From 0808f97fb59c5c30537b98494c07b60eb61dcaeb Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 16:40:42 +0700 Subject: [PATCH 15/40] Consider state when optimizing comment --- client/coral-embed-stream/src/components/Comment.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index dcc52d85a..5a2572574 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -124,14 +124,14 @@ export default class Comment extends React.Component { } } - shouldComponentUpdate(next) { + shouldComponentUpdate(nextProps, nextState) { // Specifically handle `activeReplyBox` if it is the only change. - const changes = getShallowChanges(this.props, next); + const changes = [...getShallowChanges(this.props, nextProps), ...getShallowChanges(this.state, nextState)]; if (changes.length === 1 && changes[0] === 'activeReplyBox') { if ( !containsCommentId(this.props, this.props.activeReplyBox) && - !containsCommentId(next, next.activeReplyBox) + !containsCommentId(nextProps, nextProps.activeReplyBox) ) { return false; } From fabc8b8c46ce8ac66f27e02890a8913edaf71678 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 17:49:57 +0700 Subject: [PATCH 16/40] Check when one of the leaves are empty --- client/coral-framework/hocs/withFragments.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index 76a6f7035..33c39abd0 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -4,6 +4,7 @@ import {resolveFragments} from 'coral-framework/services/graphqlRegistry'; import mapValues from 'lodash/mapValues'; import hoistStatics from 'recompose/hoistStatics'; import {getShallowChanges} from 'coral-framework/utils'; +import union from 'lodash/union'; // TODO: Should not depend on `props.data` // Currently necessary because of this https://github.com/apollographql/graphql-anywhere/issues/38 @@ -36,8 +37,11 @@ function filterProps(props, fragments) { // hasEqualLeaves compares two different apollo query result for equality. function hasEqualLeaves(a, b, path = '') { - for (const key in a) { - if (typeof a[key] === 'object') { + for (const key of union(Object.keys(a), Object.keys(b))) { + if (!(key in a) || !(key in b)) { + return false; + } + if (typeof a[key] === 'object' && a[key] && b[key]) { if (Array.isArray(a[key])) { if (a[key].length !== b[key].length) { return false; From 6a5f6e3ce1745ca7ca8b793ba940b3fbb7fd8979 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 17:57:32 +0700 Subject: [PATCH 17/40] Adapt loading detection --- client/coral-admin/src/containers/UserDetail.js | 2 +- client/coral-embed-stream/src/containers/Stream.js | 6 +++++- client/coral-settings/containers/ProfileContainer.js | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index 2ae6e4c9c..88c43ce5f 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -108,7 +108,7 @@ class UserDetailContainer extends React.Component { return null; } - const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0; + const loading = this.props.data.loading; return ; } return = 0; + const loading = this.props.data.loading; if (!auth.loggedIn) { return ; From 8eaac3eaa20aadab7ccc5fd1535777453f7723fe Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 19:50:56 +0700 Subject: [PATCH 18/40] Tiny refactor.. --- .../containers/ProfileContainer.js | 5 +- client/talk-plugin-history/Comment.js | 83 ++++++++++--------- client/talk-plugin-history/CommentHistory.js | 6 +- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 3423cb6e2..6ffad3c87 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -51,7 +51,7 @@ class ProfileContainer extends Component { }; render() { - const {auth, auth: {user}, asset, showSignInDialog, stopIgnoringUser} = this.props; + const {auth, auth: {user}, showSignInDialog, stopIgnoringUser, root, data} = this.props; const {me} = this.props.root; const loading = this.props.data.loading; @@ -87,7 +87,7 @@ class ProfileContainer extends Component {

{t('framework.my_comments')}

{me.comments.nodes.length - ? + ? :

{t('user_no_comment')}

} ); @@ -138,7 +138,6 @@ const withProfileQuery = withQuery( `); const mapStateToProps = (state) => ({ - asset: state.asset, auth: state.auth }); diff --git a/client/talk-plugin-history/Comment.js b/client/talk-plugin-history/Comment.js index f90362e2d..d1640cb5e 100644 --- a/client/talk-plugin-history/Comment.js +++ b/client/talk-plugin-history/Comment.js @@ -7,54 +7,57 @@ import CommentContent from '../coral-embed-stream/src/components/CommentContent' import t from 'coral-framework/services/i18n'; -const Comment = (props) => { - return ( - - - ); -}; + ); + } +} 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 && From dc49d23dc8d12776d1a51f98b6568d297be1a60f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 19:55:51 +0700 Subject: [PATCH 19/40] Define fragments for PermalinkButton --- .../client/containers/PermalinkButton.js | 16 ++++++++++++++++ plugins/talk-plugin-permalink/client/index.js | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 plugins/talk-plugin-permalink/client/containers/PermalinkButton.js 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: { From 1dcdccecbc1f3699e055db6290b3d90db2caf0db Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Fri, 18 Aug 2017 10:19:10 -0300 Subject: [PATCH 20/40] UserDetail UI redone and Flagging Reliability added --- .../coral-admin/src/components/UserDetail.css | 100 ++++++++++++++---- .../coral-admin/src/components/UserDetail.js | 65 +++++++----- client/coral-ui/components/Icon.css | 2 + 3 files changed, 118 insertions(+), 49 deletions(-) diff --git a/client/coral-admin/src/components/UserDetail.css b/client/coral-admin/src/components/UserDetail.css index 20e090f47..5cbe0fe32 100644 --- a/client/coral-admin/src/components/UserDetail.css +++ b/client/coral-admin/src/components/UserDetail.css @@ -1,6 +1,78 @@ .copyButton { - float: right; - top: -10px; + background-color: white; + border: solid 1px; + padding: 2px 6px; + height: auto; + line-height: initial; + min-width: auto; + letter-spacing: normal; + font-size: 0.9em; + margin-left: 10px; +} + +.userDetailList { + list-style: none; + padding: 0; + margin: 0; +} + +.userDetailItem { + margin: 0 5px; + font-weight: 500; +} + +.stats { + display: flex; + list-style: none; + padding: 0; + margin: 0; + text-align: center; + margin: 15px 0 5px; + color: #595959; +} + +.stat { + margin-right: 20px; +} + +.stat:last-child { + margin-right: 0px; +} + +.statItem, .statReportResult { + padding: 3px 5px; + background-color: #D8D8D8; + border-radius: 3px; + font-weight: 500; + display: block; + font-size: 0.9em; + line-height: normal; + letter-spacing: 0.4px; + min-width: 60px; +} + +.statResult { + font-size: 1.5em; + padding: 5px 0; + display: inline-block; +} + +.statReportResult { + color: white; + margin: 5px 0; + font-weight: 400; +} + +.statReportResult.reliable { + background-color: #749C48; +} + +.statReportResult.neutral { + background-color: #616161; +} + +.statReportResult.unreliable { + background-color: #F44336; } .memberSince { @@ -8,27 +80,9 @@ } .small { - color: #aaa; -} - -.stats { - display: flex; - - .stat { - margin: 0 4px 10px 0px; - } - - .stat:last-child { - margin-right: 0; - } - - p { - margin: 0; - } - - .stat p:first-child { - font-weight: bold; - } + color: #888888; + font-size: 0.9em; + letter-spacing: 0.4px; } .profileEmail { diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 34773ddef..64018cde8 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -1,12 +1,14 @@ -import React, {PropTypes} from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import Comment from './UserDetailComment'; import styles from './UserDetail.css'; -import {Button, Drawer, Spinner} from 'coral-ui'; +import {Icon, Button, Drawer, Spinner} from 'coral-ui'; import {Slot} from 'coral-framework/components'; import ButtonCopyToClipboard from './ButtonCopyToClipboard'; import {actionsMap} from '../utils/moderationQueueActionsMap'; import ClickOutside from 'coral-framework/components/ClickOutside'; import LoadMore from '../components/LoadMore'; +import cn from 'classnames'; export default class UserDetail extends React.Component { @@ -74,13 +76,6 @@ export default class UserDetail extends React.Component { loadMore, } = this.props; - const localProfile = user.profiles.find((p) => p.provider === 'local'); - - let profile; - if (localProfile) { - profile = localProfile.id; - } - let rejectedPercent = (rejectedComments / totalComments) * 100; if (rejectedPercent === Infinity || isNaN(rejectedPercent)) { @@ -94,8 +89,40 @@ export default class UserDetail extends React.Component {

{user.username}

- {profile && this.profile = ref} value={profile} />} - +
    +
  • + + Member Since: + {new Date(user.created_at).toLocaleString()} +
  • + + {user.profiles.map(({id}) => +
  • + + Email: + {id} +
  • + )} +
+ +
    +
  • + Total Comments + {totalComments} +
  • +
  • + Reject Rate + {`${(rejectedPercent).toFixed(1)}%`} +
  • +
  • + Reports + Reliable +
  • +
+ +

+ Data represents the last six months of activity +

-

Member since {new Date(user.created_at).toLocaleString()}

+
-

- Account summary -
Data represents the last six months of activity -

-
-
-

Total Comments

-

{totalComments}

-
-
-

Reject Rate

-

{`${(rejectedPercent).toFixed(1)}%`}

-
-
{ selectedCommentIds.length === 0 ? ( diff --git a/client/coral-ui/components/Icon.css b/client/coral-ui/components/Icon.css index 16fe6d235..1a118257a 100644 --- a/client/coral-ui/components/Icon.css +++ b/client/coral-ui/components/Icon.css @@ -1,3 +1,5 @@ .root { + vertical-align: middle; + font-size: inherit; } From 9f62754fd77f8fcdcdc99d4cc97b9add8011850f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 20:33:37 +0700 Subject: [PATCH 21/40] Show warnings when slot components uses query data without fragments --- client/coral-framework/helpers/plugins.js | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 7f6e5f1c4..8fc3310e6 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -5,8 +5,10 @@ import merge from 'lodash/merge'; import flattenDeep from 'lodash/flattenDeep'; import isEmpty from 'lodash/isEmpty'; import flatten from 'lodash/flatten'; +import mapValues from 'lodash/mapValues'; import {loadTranslations} from 'coral-framework/services/i18n'; import {injectReducers} from 'coral-framework/services/store'; +import {getDisplayName} from 'coral-framework/helpers/hoc'; import camelize from './camelize'; import plugins from 'pluginsConfig'; import uuid from 'uuid/v4'; @@ -40,6 +42,34 @@ export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) { return getSlotComponents(slot, reduxState, props, queryData).length === 0; } +// Memoize the warnings so we only show them once. +const memoizedWarnings = []; + +function withWarnings(component, queryData) { + if (process.env.NODE_ENV !== 'production') { + + // Show warnings when accessing queryData only when not in production. + return mapValues(queryData, (value, key) => { + return new Proxy(queryData[key], { + get(target, name) { + + // Only care about the components defined in the plugins. + if (component.talkPluginName) { + const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`; + if (memoizedWarnings.indexOf(warning) === -1) { + console.warn(warning); + memoizedWarnings.push(warning); + } + } + return queryData[key][name]; + } + }); + }); + } + + return queryData; +} + /** * getSlotComponentProps calculate the props we would pass to the slot component. * query datas are only passed to the component if it is defined in `component.fragments`. @@ -52,7 +82,7 @@ export function getSlotComponentProps(component, reduxState, props, queryData) { ...( component.fragments ? pick(queryData, Object.keys(component.fragments)) - : queryData // TODO: should be {} + : withWarnings(component, queryData) ) }; } From 00f365ab38d7d8dfd1c689add5e3d7bf552c0623 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 20:40:51 +0700 Subject: [PATCH 22/40] Add missing fragments --- .../client/containers/Tag.js | 15 +++++++++++++++ .../talk-plugin-featured-comments/client/index.js | 2 +- .../client/containers/OffTopicTag.js | 15 +++++++++++++++ plugins/talk-plugin-offtopic/client/index.js | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 plugins/talk-plugin-featured-comments/client/containers/Tag.js create mode 100644 plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js 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 ad10f87ca..79c3d874f 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'; From de90bdbd19c4bc4840d79f29946b59c7ef8ba25e Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 21:19:13 +0700 Subject: [PATCH 23/40] Gracefully handle when data is not available --- client/coral-framework/hocs/withFragments.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index 33c39abd0..e6aca318a 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -30,7 +30,9 @@ function filterProps(props, fragments) { if (!(key in props)) { return; } - filtered[key] = filter(fragments[key], props[key], props.data.variables); + filtered[key] = props.data + ? filter(fragments[key], props[key], props.data.variables) + : props[key]; }); return filtered; } From e5156f7fc1895ef826ae58d7651a218bb58d254a Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 21:36:14 +0700 Subject: [PATCH 24/40] Integrate embed slot in to graphql framework --- client/coral-embed-stream/src/components/Embed.js | 10 +++++++--- client/coral-embed-stream/src/containers/Embed.js | 7 ++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index f918b7a16..a74028627 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -28,7 +28,7 @@ export default class Embed extends React.Component { }; render() { - const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props; + const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props; const {user} = this.props.auth; const hasHighlightedComment = !!commentId; @@ -64,14 +64,18 @@ export default class Embed extends React.Component { } - + - + diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index e21a683d1..731cab264 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -11,7 +11,7 @@ import {Spinner} from 'coral-ui'; import * as authActions from 'coral-framework/actions/auth'; import * as assetActions from 'coral-framework/actions/asset'; import pym from 'coral-framework/services/pym'; -import {getDefinitionName} from 'coral-framework/utils'; +import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; import Stream from './Stream'; @@ -146,12 +146,17 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql` } `; +const slots = [ + 'embed', +]; + const EMBED_QUERY = gql` query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { me { id status } + ${getSlotFragmentSpreads(slots, 'root')} ...${getDefinitionName(Stream.fragments.root)} } ${Stream.fragments.root} From 64580696fb8d620e7c084cf6b9604296ce68bfd9 Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Fri, 18 Aug 2017 11:42:23 -0300 Subject: [PATCH 25/40] Reliability Added --- client/coral-admin/src/components/UserDetail.js | 6 +++++- client/coral-admin/src/containers/UserDetail.js | 3 +++ client/coral-framework/utils/user.js | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 client/coral-framework/utils/user.js diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 64018cde8..6af1b6678 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -9,6 +9,8 @@ import {actionsMap} from '../utils/moderationQueueActionsMap'; import ClickOutside from 'coral-framework/components/ClickOutside'; import LoadMore from '../components/LoadMore'; import cn from 'classnames'; +import capitalize from 'lodash/capitalize'; +import {getReliability} from 'coral-framework/utils/user'; export default class UserDetail extends React.Component { @@ -116,7 +118,9 @@ export default class UserDetail extends React.Component {
  • Reports - Reliable + + {capitalize(getReliability(user.reliable.flagger))} +
  • diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js index 2ae6e4c9c..41fccb7c5 100644 --- a/client/coral-admin/src/containers/UserDetail.js +++ b/client/coral-admin/src/containers/UserDetail.js @@ -142,6 +142,9 @@ export const withUserDetailQuery = withQuery(gql` id provider } + reliable { + flagger + } ${getSlotFragmentSpreads(slots, 'user')} } totalComments: commentCount(query: {author_id: $author_id}) diff --git a/client/coral-framework/utils/user.js b/client/coral-framework/utils/user.js new file mode 100644 index 000000000..044583026 --- /dev/null +++ b/client/coral-framework/utils/user.js @@ -0,0 +1,14 @@ + /** + * getReliability + * retrieves reliability value as string + */ + +export const getReliability = (reliabilityValue) => { + if (reliabilityValue === null) { + return 'neutral'; + } else if (reliabilityValue) { + return 'reliable'; + } else { + return 'unreliable'; + } +}; From 833e78f99a6a40cef12183c3a823071532a5cc83 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 21:41:46 +0700 Subject: [PATCH 26/40] Integrate ProfileContainer slot into graphql framework --- client/coral-settings/containers/ProfileContainer.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 6ffad3c87..f1b3787dd 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -14,6 +14,7 @@ import CommentHistory from 'talk-plugin-history/CommentHistory'; import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth'; import {insertCommentsSorted} from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; +import {getSlotFragmentSpreads} from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; @@ -94,6 +95,11 @@ class ProfileContainer extends Component { } } +// TODO: This Slot should be included in `talk-plugin-history` instead. +const slots = [ + 'commentContent', +]; + const CommentFragment = gql` fragment TalkSettings_CommentConnectionFragment on CommentConnection { nodes { @@ -103,8 +109,10 @@ const CommentFragment = gql` id title url + ${getSlotFragmentSpreads(slots, 'asset')} } created_at + ${getSlotFragmentSpreads(slots, 'comment')} } endCursor hasNextPage @@ -133,6 +141,7 @@ const withProfileQuery = withQuery( ...TalkSettings_CommentConnectionFragment } } + ${getSlotFragmentSpreads(slots, 'root')} } ${CommentFragment} `); From 0cbf6e24f77c64b56208f2313cabe7c93445a6b3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 21:59:43 +0700 Subject: [PATCH 27/40] Handle null values --- client/coral-framework/helpers/plugins.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 8fc3310e6..57caf2c46 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -50,6 +50,11 @@ function withWarnings(component, queryData) { // Show warnings when accessing queryData only when not in production. return mapValues(queryData, (value, key) => { + + // Keep null values.. + if (!queryData[key]) { + return queryData[key]; + } return new Proxy(queryData[key], { get(target, name) { From b8452b535bfdc3fb486d759d3d273abd1e2ac8e6 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 22:19:03 +0700 Subject: [PATCH 28/40] Allow passing custom fragments to withReaction and withTag --- plugin-api/beta/client/hocs/withReaction.js | 20 +++++++++++++++---- plugin-api/beta/client/hocs/withTags.js | 20 +++++++++++++++---- .../client/containers/Comment.js | 1 - .../client/containers/ModTag.js | 13 +++++++++++- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 0d118a12f..158431898 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -13,13 +13,17 @@ 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) => hoistStatics((WrappedComponent) => { +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; @@ -248,7 +252,7 @@ export default (reaction) => hoistStatics((WrappedComponent) => { } render() { - const {comment} = this.props; + const {root, asset, comment} = this.props; const reactionSummary = getMyActionSummary( `${Reaction}ActionSummary`, @@ -263,10 +267,12 @@ export default (reaction) => hoistStatics((WrappedComponent) => { const alreadyReacted = !!reactionSummary; return hoistStatics((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 { @@ -389,7 +398,10 @@ export default (reaction) => hoistStatics((WrappedComponent) => { } } } - }` + ${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''} + } + ${fragments.comment ? fragments.comment : ''} + ` }), connect(mapStateToProps, mapDispatchToProps), withDeleteReaction, diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index b1d565557..d13fbc8c2 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -9,13 +9,17 @@ 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) => hoistStatics((WrappedComponent) => { +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(); @@ -69,13 +73,15 @@ export default (tag) => hoistStatics((WrappedComponent) => { } render() { - const {comment, user, config} = this.props; + const {root, asset, comment, user, config} = this.props; const alreadyTagged = isTagged(comment.tags, TAG); return hoistStatics((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 { @@ -106,7 +115,10 @@ export default (tag) => hoistStatics((WrappedComponent) => { name } } - }` + ${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''} + } + ${fragments.comment ? fragments.comment : ''} + ` }), connect(mapStateToProps, mapDispatchToProps), withAddTag, 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); From 0a602b79c3a1efeeecc0f15a76b04d03e2806082 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 22:21:51 +0700 Subject: [PATCH 29/40] Enable optimization for more slots --- .../coral-admin/src/components/UserDetail.js | 4 +- .../routes/Moderation/components/Comment.js | 42 ++++++++++--------- .../Moderation/components/Moderation.js | 3 +- client/talk-plugin-history/Comment.js | 4 +- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 34773ddef..afdccb634 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -56,6 +56,7 @@ export default class UserDetail extends React.Component { renderLoaded() { const { + root, root: { user, totalComments, @@ -101,8 +102,7 @@ export default class UserDetail extends React.Component {

    Member since {new Date(user.created_at).toLocaleString()}


    diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index c6ec0fd4c..60077e80a 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -29,7 +29,12 @@ class Comment extends React.Component { bannedWords, selected, className, - ...props + data, + root, + currentUserId, + currentAsset, + acceptComment, + rejectComment, } = this.props; const flagActionSummaries = getActionSummary('FlagActionSummary', comment); @@ -38,20 +43,22 @@ class Comment extends React.Component { let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; - const showSuspenUserDialog = () => props.showSuspendUserDialog({ + const showSuspenUserDialog = () => this.props.showSuspendUserDialog({ userId: comment.user.id, username: comment.user.username, commentId: comment.id, commentStatus: comment.status, }); - const showBanUserDialog = () => props.showBanUserDialog({ + const showBanUserDialog = () => this.props.showBanUserDialog({ userId: comment.user.id, username: comment.user.username, commentId: comment.id, commentStatus: comment.status, }); + const queryData = {root, comment, asset: comment.asset}; + return (
  •  ({t('comment.edited')}) : null } - {props.currentUserId !== comment.user.id && + {currentUserId !== comment.user.id &&
  • @@ -103,7 +108,7 @@ class Comment extends React.Component {
    Story: {comment.asset.title} - {!props.currentAsset && + {!currentAsset && {t('modqueue.moderate')}}
    @@ -124,10 +129,9 @@ class Comment extends React.Component {

    @@ -150,30 +154,28 @@ class Comment extends React.Component { acceptComment={() => (comment.status === 'ACCEPTED' ? null - : props.acceptComment({commentId: comment.id}))} + : acceptComment({commentId: comment.id}))} rejectComment={() => (comment.status === 'REJECTED' ? null - : props.rejectComment({commentId: comment.id}))} + : rejectComment({commentId: comment.id}))} /> ); })}
    {flagActions && flagActions.length ?

    Date: Fri, 18 Aug 2017 22:35:13 +0700 Subject: [PATCH 30/40] Only use Proxy when available --- client/coral-framework/helpers/plugins.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 57caf2c46..3173115c2 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -45,8 +45,10 @@ export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) { // Memoize the warnings so we only show them once. const memoizedWarnings = []; +// withWarnings decorates the props of queryData with a proxy that +// prints a warning when accessing deeper props. function withWarnings(component, queryData) { - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== 'production' && window.Proxy) { // Show warnings when accessing queryData only when not in production. return mapValues(queryData, (value, key) => { From e77a8ce3c7bdda5ac2bdadf33ef345acf4ac6fae Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 22:36:13 +0700 Subject: [PATCH 31/40] More comments --- client/coral-framework/components/Slot.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index 838382978..c0cca143c 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -51,6 +51,8 @@ class Slot extends React.Component { Slot.propTypes = { fill: React.PropTypes.string.isRequired, + + // props coming from graphql must be passed through this property. queryData: React.PropTypes.object, }; From 44ec469fa5c146fed1e819627ce23ef06f0c06ff Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 22:47:30 +0700 Subject: [PATCH 32/40] Disable fragment warnings --- plugin-api/beta/client/hocs/withReaction.js | 10 ++++++++++ plugin-api/beta/client/hocs/withTags.js | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 158431898..3300ac2d3 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -15,6 +15,16 @@ import hoistStatics from 'recompose/hoistStatics'; import * as PropTypes from 'prop-types'; import {getDefinitionName} from '../utils'; +/* + * 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'); diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js index d13fbc8c2..491c35038 100644 --- a/plugin-api/beta/client/hocs/withTags.js +++ b/plugin-api/beta/client/hocs/withTags.js @@ -11,6 +11,16 @@ import {forEachError, isTagged} from 'coral-framework/utils'; import hoistStatics from 'recompose/hoistStatics'; import {getDefinitionName} from '../utils'; +/* + * 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'); From 5028f4adf41b95efcede07a9dcc1c8939ec703c8 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 23:19:10 +0700 Subject: [PATCH 33/40] Better shouldComponentUpdate detection in withFragments --- client/coral-framework/hocs/withFragments.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js index e6aca318a..9bc06290e 100644 --- a/client/coral-framework/hocs/withFragments.js +++ b/client/coral-framework/hocs/withFragments.js @@ -86,9 +86,9 @@ export default (fragments) => hoistStatics((BaseComponent) => { } shouldComponentUpdate(next) { + const onlyQueryDataChanges = this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0); - // If only query data was changed. - if (this.queryDataHasChanged && this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0)) { + if (onlyQueryDataChanges) { return this.queryDataHasChanged; } From 58a5d5eea835297b2cece69a9e82973e0e854521 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 23:20:05 +0700 Subject: [PATCH 34/40] Refactor Moderation Comment a little bit --- .../routes/Moderation/components/Comment.js | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 60077e80a..8d543e97b 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -20,6 +20,31 @@ import t, {timeago} from 'coral-framework/services/i18n'; class Comment extends React.Component { + showSuspenUserDialog = () => { + const {comment, showSuspendUserDialog} = this.props; + return showSuspendUserDialog({ + userId: comment.user.id, + username: comment.user.username, + commentId: comment.id, + commentStatus: comment.status, + }); + }; + + showBanUserDialog = () => { + const {comment, showBanUserDialog} = this.props; + return showBanUserDialog({ + userId: comment.user.id, + username: comment.user.username, + commentId: comment.id, + commentStatus: comment.status, + }); + }; + + viewUserDetail = () => { + const {viewUserDetail, comment} = this.props; + return viewUserDetail(comment.user.id); + }; + render() { const { actions = [], @@ -43,20 +68,6 @@ class Comment extends React.Component { let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; - const showSuspenUserDialog = () => this.props.showSuspendUserDialog({ - userId: comment.user.id, - username: comment.user.username, - commentId: comment.id, - commentStatus: comment.status, - }); - - const showBanUserDialog = () => this.props.showBanUserDialog({ - userId: comment.user.id, - username: comment.user.username, - commentId: comment.id, - commentStatus: comment.status, - }); - const queryData = {root, comment, asset: comment.asset}; return ( @@ -69,7 +80,7 @@ class Comment extends React.Component {

    { ( - viewUserDetail(comment.user.id)}> + {comment.user.username} ) @@ -86,11 +97,11 @@ class Comment extends React.Component { + onClick={this.showSuspenUserDialog}> Suspend User + onClick={this.showBanUserDialog}> Ban User From cf1c93f395411d71eb56c9d1ecb1e3e031a81864 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 18 Aug 2017 23:37:53 +0700 Subject: [PATCH 35/40] Add comments --- client/coral-embed-stream/src/containers/Comment.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 5f3ee0c9d..005a24ae1 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -18,6 +18,12 @@ const slots = [ 'commentAvatar' ]; +/** + * withAnimateEnter is a HOC that passes a property `animateEnter` to the + * underlying BaseComponent. It must be a direct child of a `TransitionGroup` + * from https://github.com/reactjs/react-transition-group and as such must + * be the uppermost HOC applied to the BaseComponent. + */ const withAnimateEnter = hoistStatics((BaseComponent) => { class WithAnimateEnter extends React.Component { state = { From f49591225f314b3e0b003b6e7e6680a5b624362e Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 19 Aug 2017 00:10:31 +0700 Subject: [PATCH 36/40] Use UserDetailComment container --- client/coral-admin/src/components/UserDetail.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/components/UserDetail.js b/client/coral-admin/src/components/UserDetail.js index 9ee16935f..11cd444e1 100644 --- a/client/coral-admin/src/components/UserDetail.js +++ b/client/coral-admin/src/components/UserDetail.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Comment from './UserDetailComment'; +import Comment from '../containers/UserDetailComment'; import styles from './UserDetail.css'; import {Icon, Button, Drawer, Spinner} from 'coral-ui'; import {Slot} from 'coral-framework/components'; @@ -99,7 +99,7 @@ export default class UserDetail extends React.Component { {new Date(user.created_at).toLocaleString()} - {user.profiles.map(({id}) => + {user.profiles.map(({id}) =>
  • Email: From 1b09825602594913e88c7012a34b1a0535297f36 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 18 Aug 2017 11:50:51 -0600 Subject: [PATCH 37/40] added more debugging to redis, improved retry --- config.js | 18 +++++++++++ docs/_docs/02-01-configuration.md | 15 +++++++++ graph/context.js | 2 +- graph/subscriptions.js | 4 +-- middleware/pubsub.js | 3 +- services/pubsub.js | 29 +++++++++-------- services/redis.js | 54 +++++++++++++++++++++++++------ 7 files changed, 96 insertions(+), 29 deletions(-) diff --git a/config.js b/config.js index a92802ee7..22720a36a 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 //------------------------------------------------------------------------------ diff --git a/docs/_docs/02-01-configuration.md b/docs/_docs/02-01-configuration.md index 8f76ad999..58c828f9a 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 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/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; } From 3b26e855bc891fe7f4412e4eaa9edfefc1b70ce9 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 21 Aug 2017 17:02:54 +0700 Subject: [PATCH 38/40] Don't manipulate state directly... --- .../containers/ConfigureStreamContainer.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js index 8489c809f..1db8f497a 100644 --- a/client/coral-configure/containers/ConfigureStreamContainer.js +++ b/client/coral-configure/containers/ConfigureStreamContainer.js @@ -15,7 +15,7 @@ class ConfigureStreamContainer extends Component { this.state = { changed: false, - dirtySettings: props.asset.settings, + dirtySettings: {...props.asset.settings}, closedAt: !props.asset.isClosed ? 'open' : 'closed' }; @@ -48,26 +48,28 @@ class ConfigureStreamContainer extends Component { changed: false }); }, 300); - - // this.props.loadAsset(this.props.data.asset); } } handleChange (e) { + const changes = {}; - // TODO: Don’t directly manipulate state and make state change immutable. if (e.target && e.target.id === 'qboxenable') { - this.state.dirtySettings.questionBoxEnable = e.target.checked; + changes.questionBoxEnable = e.target.checked; } if (e.target && e.target.id === 'qboxcontent') { - this.state.dirtySettings.questionBoxContent = e.target.value; + changes.questionBoxContent = e.target.value; } if (e.target && e.target.id === 'plinksenable') { - this.state.dirtySettings.premodLinksEnable = e.target.value; + changes.premodLinksEnable = e.target.value; } this.setState({ - changed: true + changed: true, + dirtySettings: { + ...this.state.dirtySettings, + ...changes, + }, }); } From 077bc5ad3352665843eb97af8501c912ec9ec2ac Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 21 Aug 2017 17:09:15 +0700 Subject: [PATCH 39/40] Fix typo --- .../coral-admin/src/routes/Moderation/components/Comment.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 8d543e97b..1335a8c52 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -20,7 +20,7 @@ import t, {timeago} from 'coral-framework/services/i18n'; class Comment extends React.Component { - showSuspenUserDialog = () => { + showSuspendUserDialog = () => { const {comment, showSuspendUserDialog} = this.props; return showSuspendUserDialog({ userId: comment.user.id, @@ -97,7 +97,7 @@ class Comment extends React.Component { + onClick={this.showSuspendUserDialog}> Suspend User Date: Mon, 21 Aug 2017 08:12:36 -0600 Subject: [PATCH 40/40] changed trust defaults --- config.js | 2 +- docs/_docs/02-01-configuration.md | 2 +- services/karma.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config.js b/config.js index 22720a36a..11c8b5077 100644 --- a/config.js +++ b/config.js @@ -153,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 58c828f9a..df790b24b 100644 --- a/docs/_docs/02-01-configuration.md +++ b/docs/_docs/02-01-configuration.md @@ -177,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/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(';')