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, },