Merge branch 'master' into user-sort

This commit is contained in:
Wyatt Johnson
2017-08-21 08:48:26 -06:00
53 changed files with 1295 additions and 471 deletions
@@ -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 {
+46 -29
View File
@@ -1,12 +1,16 @@
import React, {PropTypes} from 'react';
import Comment from './UserDetailComment';
import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/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';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
export default class UserDetail extends React.Component {
@@ -56,6 +60,7 @@ export default class UserDetail extends React.Component {
renderLoaded() {
const {
root,
root: {
user,
totalComments,
@@ -74,13 +79,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,32 +92,51 @@ export default class UserDetail extends React.Component {
<h3>{user.username}</h3>
<div>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
<ul className={styles.userDetailList}>
<li>
<Icon name="assignment_ind"/>
<span className={styles.userDetailItem}>Member Since:</span>
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({id}) =>
<li key={id}>
<Icon name="email"/>
<span className={styles.userDetailItem}>Email:</span>
{id} <ButtonCopyToClipboard className={styles.copyButton} icon="content_copy" copyText={id} />
</li>
)}
</ul>
<ul className={styles.stats}>
<li className={styles.stat}>
<span className={styles.statItem}> Total Comments </span>
<spam className={styles.statResult}> {totalComments} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reject Rate </spam>
<spam className={styles.statResult}> {`${(rejectedPercent).toFixed(1)}%`} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reports </spam>
<spam className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
{capitalize(getReliability(user.reliable.flagger))}
</spam>
</li>
</ul>
<p className={styles.small}>
Data represents the last six months of activity
</p>
</div>
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
queryData={root, user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
selectedCommentIds.length === 0
? (
@@ -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 <UserDetail
bulkReject={this.bulkReject}
@@ -142,6 +142,9 @@ export const withUserDetailQuery = withQuery(gql`
id
provider
}
reliable {
flagger
}
${getSlotFragmentSpreads(slots, 'user')}
}
totalComments: commentCount(query: {author_id: $author_id})
@@ -20,6 +20,31 @@ import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
showSuspendUserDialog = () => {
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 = [],
@@ -29,7 +54,12 @@ class Comment extends React.Component {
bannedWords,
selected,
className,
...props
data,
root,
currentUserId,
currentAsset,
acceptComment,
rejectComment,
} = this.props;
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
@@ -38,19 +68,7 @@ class Comment extends React.Component {
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const showBanUserDialog = () => props.showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const queryData = {root, comment, asset: comment.asset};
return (
<li
@@ -62,7 +80,7 @@ class Comment extends React.Component {
<div className={styles.author}>
{
(
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
<span className={styles.username} onClick={this.viewUserDetail}>
{comment.user.username}
</span>
)
@@ -75,15 +93,15 @@ class Comment extends React.Component {
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showSuspenUserDialog}>
onClick={this.showSuspendUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showBanUserDialog}>
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
@@ -91,11 +109,9 @@ class Comment extends React.Component {
<div className={styles.adminCommentInfoBar}>
<CommentType type={commentType} className={styles.commentType}/>
<Slot
data={props.data}
root={props.root}
comment={comment}
asset={comment.asset}
fill="adminCommentInfoBar"
data={data}
queryData={queryData}
/>
</div>
</div>
@@ -103,7 +119,7 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CommentAnimatedEdit body={comment.body}>
@@ -124,10 +140,9 @@ class Comment extends React.Component {
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
data={data}
queryData={queryData}
/>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
@@ -150,30 +165,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}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
data={data}
queryData={queryData}
/>
</div>
</div>
</CommentAnimatedEdit>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
data={data}
queryData={queryData}
/>
{flagActions && flagActions.length
? <FlagBox
@@ -168,8 +168,7 @@ export default class Moderation extends Component {
<Slot
data={data}
root={root}
assset={asset}
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
@@ -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: Dont 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,
},
});
}
@@ -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);
@@ -16,14 +16,16 @@ 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';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
@@ -72,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) {
@@ -86,7 +99,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,20 +124,21 @@ 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) {
shouldComponentUpdate(nextProps, nextState) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
// Specifically handle `activeReplyBox` if it is the only change.
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(nextProps, nextProps.activeReplyBox)
) {
return false;
}
}
this.setState({animateEnter: true});
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
static propTypes = {
@@ -244,6 +257,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() {
@@ -319,6 +336,7 @@ export default class Comment extends React.Component {
showSignInDialog,
liveUpdates,
commentIsIgnored,
animateEnter,
emit,
commentClassNames = []
} = this.props;
@@ -372,7 +390,7 @@ export default class Comment extends React.Component {
styles[`rootLevel${depth}`],
{
...conditionalClassNames,
[styles.enter]: this.state.animateEnter,
[styles.enter]: animateEnter,
},
);
@@ -392,10 +410,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 (
@@ -409,6 +430,7 @@ export default class Comment extends React.Component {
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
inline
/>
@@ -431,6 +453,7 @@ export default class Comment extends React.Component {
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
/>
{ isActive && (currentUser && (comment.user.id === currentUser.id)) &&
@@ -477,6 +500,7 @@ export default class Comment extends React.Component {
fill="commentContent"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
/>
</div>
}
@@ -488,6 +512,7 @@ export default class Comment extends React.Component {
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
{!disableReply &&
@@ -504,6 +529,7 @@ export default class Comment extends React.Component {
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
inline
/>
<ActionButton>
@@ -529,9 +555,7 @@ export default class Comment extends React.Component {
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
commentPostedHandler={this.commentPostedHandler}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
@@ -547,7 +571,7 @@ export default class Comment extends React.Component {
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
: <CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
@@ -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 {
</Tab>
}
</TabBar>
<Slot fill="embed" />
<Slot
data={data}
queryData={{root}}
fill="embed"
/>
<TabContent
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'}>
<Stream data={this.props.data} root={this.props.root} />
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'}>
<ProfileContainer />
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {StreamError} from './StreamError';
import Comment from '../components/Comment';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
@@ -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,46 +35,20 @@ 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);
}
commentIsIgnored = (comment) => {
const me = this.props.root.me;
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
const {
data,
@@ -83,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,
@@ -125,16 +99,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 = this.getSlotProps();
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.');
@@ -196,6 +163,7 @@ class Stream extends React.Component {
<Slot
fill="stream"
queryData={slotQueryData}
{...slotProps}
/>
@@ -230,7 +198,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}
@@ -246,58 +214,54 @@ class Stream extends React.Component {
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
active={activeStreamTab === PluginComponent.talkPluginName}
/>
<StreamTabPanel
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
slotProps={slotProps}
queryData={slotQueryData}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
))}
<Tab tabId={'all'}>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
</TabBar>
<TabContent activeTab={activeStreamTab} sub>
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={this.commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
))}
<TabPane tabId={'all'}>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
</TabContent>
}
sub
/>
</div>
}
</div>
@@ -0,0 +1,37 @@
import React from 'react';
import {TabBar, TabContent} from 'coral-ui';
import PropTypes from 'prop-types';
class StreamTabPanel extends React.Component {
render() {
const {activeTab, setActiveTab, tabs, tabPanes, sub} = this.props;
return (
<div>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
<TabContent activeTab={activeTab} sub={sub}>
{tabPanes}
</TabContent>
</div>
);
}
}
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;
@@ -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() {
@@ -1,7 +1,11 @@
import {gql} from 'react-apollo';
import {gql, compose} from 'react-apollo';
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',
@@ -14,7 +18,75 @@ const slots = [
'commentAvatar'
];
export default withFragments({
/**
* 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 = {
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 <BaseComponent
{...this.props}
animateEnter={this.state.animateEnter}
/>;
}
}
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 {
__typename
@@ -29,32 +101,27 @@ export default 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}
`
})(Comment);
});
const enhance = compose(
withAnimateEnter,
withCommentFragments,
);
export default enhance(Comment);
@@ -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}
@@ -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,8 +140,16 @@ class StreamContainer extends React.Component {
clearInterval(this.countPoll);
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
if (this.props.refetching) {
if (this.props.refetching
|| !this.props.root.asset
|| !this.props.root.asset.comment
&& !this.props.root.asset.comments
) {
return <Spinner />;
}
return <Stream
@@ -149,6 +157,7 @@ class StreamContainer extends React.Component {
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
/>;
}
}
@@ -156,19 +165,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}
`;
@@ -205,27 +206,14 @@ const LOAD_MORE_QUERY = gql`
query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Cursor, $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 = [
@@ -310,7 +298,6 @@ const mapStateToProps = (state) => ({
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
reduxState: omit(state, 'apollo'),
});
const mapDispatchToProps = (dispatch) =>
@@ -0,0 +1,108 @@
import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
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';
import PropTypes from 'prop-types';
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, props.queryData);
}
getPluginTabElements(props = this.props) {
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
));
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
/>
</TabPane>
));
}
render() {
return (
<StreamTabPanel
className={this.props.className}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
tabs={this.getPluginTabElements().concat(this.props.appendTabs)}
tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)}
sub={this.props.sub}
/>
);
}
}
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,
slotProps: PropTypes.object.isRequired,
queryData: PropTypes.object,
className: PropTypes.string,
sub: PropTypes.bool,
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
export default connect(mapStateToProps, null)(StreamTabPanelContainer);
@@ -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') {
@@ -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 (
<Component className={className}>
{isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
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 (
<Component className={className}>
{this.isSlotEmpty() ? children : null}
</Component>
);
}
}
IfSlotIsEmpty.propTypes = {
@@ -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 (
<Component className={className}>
{!isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
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 (
<Component className={className}>
{this.isSlotEmpty() ? null : children}
</Component>
);
}
}
IfSlotIsNotEmpty.propTypes = {
+45 -12
View File
@@ -2,25 +2,58 @@ 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';
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 = <DefaultComponent {...rest} />;
const emptyConfig = {};
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 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);
return !isEqual(prevChildrenUuid, nextChildrenUuid);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
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), props.queryData);
}
render() {
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData)} />;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
}
}
Slot.propTypes = {
fill: React.PropTypes.string
fill: React.PropTypes.string.isRequired,
// props coming from graphql must be passed through this property.
queryData: React.PropTypes.object,
};
const mapStateToProps = (state) => ({
+73 -9
View File
@@ -5,13 +5,19 @@ 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';
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`
@@ -24,7 +30,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)};
}
@@ -32,17 +38,70 @@ 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;
}
// 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' && window.Proxy) {
// 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) {
// 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`.
*/
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))
: withWarnings(component, queryData)
)
};
}
/**
* 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) {
@@ -100,7 +159,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();
});
});
});
@@ -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;
};
});
+4 -3
View File
@@ -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;
};
});
+109 -5
View File
@@ -1,5 +1,109 @@
// 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 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';
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
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] = props.data
? filter(fragments[key], props[key], props.data.variables)
: props[key];
});
return filtered;
}
// hasEqualLeaves compares two different apollo query result for equality.
function hasEqualLeaves(a, b, path = '') {
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;
}
}
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;
shallowChanges = null;
componentWillReceiveProps(next) {
this.shallowChanges = getShallowChanges(this.props, next);
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) {
// Only changed props when query data has changed.
this.filteredProps = filterProps(next, this.fragments);
}
}
}
shouldComponentUpdate(next) {
const onlyQueryDataChanges = this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0);
if (onlyQueryDataChanges) {
return this.queryDataHasChanged;
}
return this.shallowChanges.length !== 0;
}
render() {
const queryProps = this.filteredProps;
return <BaseComponent
{...this.props}
{...queryProps}
/>;
}
}
WithFragments.fragments = fragments;
return WithFragments;
});
+35 -5
View File
@@ -8,6 +8,8 @@ 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';
import union from 'lodash/union';
class ResponseErrors extends Error {
constructor(errors) {
@@ -30,7 +32,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 || {},
@@ -46,7 +48,13 @@ export default (document, config = {}) => (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) => {
@@ -132,12 +140,34 @@ export default (document, config = {}) => (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;
};
@@ -147,4 +177,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+85 -49
View File
@@ -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 {
@@ -46,6 +47,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 +62,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};
},
};
@@ -155,4 +191,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+6
View File
@@ -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]);
}
+14
View File
@@ -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';
}
};
@@ -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';
@@ -51,9 +52,9 @@ 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 = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
const loading = this.props.data.loading;
if (!auth.loggedIn) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
@@ -87,13 +88,18 @@ class ProfileContainer extends Component {
<h3>{t('framework.my_comments')}</h3>
{me.comments.nodes.length
? <CommentHistory comments={me.comments} asset={asset} link={link} loadMore={this.loadMore}/>
? <CommentHistory data={data} root={root} comments={me.comments} link={link} loadMore={this.loadMore}/>
: <p>{t('user_no_comment')}</p>}
</div>
);
}
}
// 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,12 +141,12 @@ const withProfileQuery = withQuery(
...TalkSettings_CommentConnectionFragment
}
}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentFragment}
`);
const mapStateToProps = (state) => ({
asset: state.asset,
auth: state.auth
});
+2
View File
@@ -1,3 +1,5 @@
.root {
vertical-align: middle;
font-size: inherit;
}
@@ -133,7 +133,9 @@ export default class FlagButton extends Component {
}
handleClickOutside = () => {
this.closeMenu();
if (this.state.showMenu) {
this.closeMenu();
}
}
render () {
+41 -40
View File
@@ -7,54 +7,55 @@ import CommentContent from '../coral-embed-stream/src/components/CommentContent'
import t from 'coral-framework/services/i18n';
const Comment = (props) => {
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
comment={props.comment}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={props.link(`${props.asset.url}`)}>
Story: {props.asset.title ? props.asset.title : props.asset.url}
</a>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
class Comment extends React.Component {
render() {
const {comment, link, data, root} = this.props;
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
data={data}
queryData={{root, comment, asset: comment.asset}}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={link(`${comment.asset.url}`)}>
Story: {comment.asset.title ? comment.asset.title : comment.asset.url}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={props.comment.created_at}
/>
</li>
</ul>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={link(`${comment.asset.url}?commentId=${comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={comment.created_at}
/>
</li>
</ul>
</div>
</div>
</div>
);
};
);
}
}
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;
+4 -2
View File
@@ -22,16 +22,18 @@ class CommentHistory extends React.Component {
}
render() {
const {link, comments} = this.props;
const {link, comments, data, root} = this.props;
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{comments.nodes.map((comment, i) => {
return <Comment
key={i}
data={data}
root={root}
comment={comment}
link={link}
asset={comment.asset} />;
/>;
})}
</div>
{comments.hasNextPage &&
+19 -1
View File
@@ -8,6 +8,7 @@
require('env-rewrite').rewrite();
const uniq = require('lodash/uniq');
const ms = require('ms');
//==============================================================================
// CONFIG INITIALIZATION
@@ -84,6 +85,23 @@ const CONFIG = {
MONGO_URL: process.env.TALK_MONGO_URL,
REDIS_URL: process.env.TALK_REDIS_URL,
// REDIS_RECONNECTION_MAX_ATTEMPTS is the amount of attempts that a redis
// connection will attempt to reconnect before aborting with an error.
REDIS_RECONNECTION_MAX_ATTEMPTS: parseInt(process.env.TALK_REDIS_RECONNECTION_MAX_ATTEMPTS || '100'),
// REDIS_RECONNECTION_MAX_RETRY_TIME is the time in string format for the
// maximum amount of time that a client can be considered "connecting" before
// attempts at reconnection are aborted with an error.
REDIS_RECONNECTION_MAX_RETRY_TIME: ms(process.env.TALK_REDIS_RECONNECTION_MAX_RETRY_TIME || '1 min'),
// REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied
// against the current attempt count inbetween attempts to connect to redis.
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'),
// REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay
// before attempting to reconnect to redis.
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'),
//------------------------------------------------------------------------------
// Server Config
//------------------------------------------------------------------------------
@@ -135,7 +153,7 @@ const CONFIG = {
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
// TRUST_THRESHOLDS defines the thresholds used for automoderation.
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:-1,-1;flag:-1,-1'
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1'
};
//==============================================================================
+16 -1
View File
@@ -52,6 +52,21 @@ These are only used during the webpack build.
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
#### Advanced
- `TALK_REDIS_RECONNECTION_MAX_ATTEMPTS` (_optional_) - the amount of attempts
that a redis connection will attempt to reconnect before aborting with an
error. (Default `100`)
- `TALK_REDIS_RECONNECTION_MAX_RETRY_TIME` (_optional_) - the time in string
format for the maximum amount of time that a client can be considered
"connecting" before attempts at reconnection are aborted with an error.
(Default `1 min`)
- `TALK_REDIS_RECONNECTION_BACKOFF_FACTOR` (_optional_) - the time factor that
will be multiplied against the current attempt count inbetween attempts to
connect to redis. (Default `500 ms`)
- `TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME` (_optional_) - the minimum time
used to delay before attempting to reconnect to redis. (Default `1 sec`)
### Server
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
@@ -162,7 +177,7 @@ Trust can auto-moderate comments based on user history. By specifying this
option, the behavior can be changed to offer different results.
- `TRUST_THRESHOLDS` (_optional_) - configure the reliability thresholds for
flagging and commenting. (Default `comment:-1,-1;flag:-1,-1`)
flagging and commenting. (Default `comment:2,-1;flag:2,-1`)
The form of the environment variable:
+1 -1
View File
@@ -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();
}
}
+1 -3
View File
@@ -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) => {
+1 -2
View File
@@ -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();
+37 -7
View File
@@ -11,14 +11,29 @@ import {showSignInDialog} from 'coral-framework/actions/auth';
import {addNotification} from 'coral-framework/actions/notification';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
import hoistStatics from 'recompose/hoistStatics';
import * as PropTypes from 'prop-types';
import {getDefinitionName} from '../utils';
export default (reaction) => (WrappedComponent) => {
/*
* Disable false-positive warning below, as it doesn't work well with how we currently
* assemble the queries.
*
* Warning: fragment with name {fragment name} already exists.
* graphql-tag enforces all fragment names across your application to be unique; read more about
* this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
*/
gql.disableFragmentWarnings();
export default (reaction, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
// fragments allow the extension of the fragments defined in this HOC.
const {fragments = {}} = options;
// Global instance counter for each `reaction` type.
let instances = 0;
@@ -175,7 +190,7 @@ export default (reaction) => (WrappedComponent) => {
createdSubscription = context.client.subscribe({
query: REACTION_CREATED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionCreated,
@@ -185,7 +200,7 @@ export default (reaction) => (WrappedComponent) => {
deletedSubscription = context.client.subscribe({
query: REACTION_DELETED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionDeleted,
@@ -247,7 +262,7 @@ export default (reaction) => (WrappedComponent) => {
}
render() {
const {comment} = this.props;
const {root, asset, comment} = this.props;
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
@@ -262,10 +277,12 @@ export default (reaction) => (WrappedComponent) => {
const alreadyReacted = !!reactionSummary;
return <WrappedComponent
root={root}
asset={asset}
comment={comment}
showSignInDialog={this.props.showSignInDialog}
addNotification={this.props.addNotification}
user={this.props.user}
comment={comment}
reactionSummary={reactionSummary}
count={count}
alreadyReacted={alreadyReacted}
@@ -371,9 +388,19 @@ export default (reaction) => (WrappedComponent) => {
const enhance = compose(
withFragments({
...fragments,
asset: gql`
fragment ${Reaction}Button_asset on Asset {
id
${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
}
${fragments.asset ? fragments.asset : ''}
`,
comment: gql`
fragment ${Reaction}Button_comment on Comment {
id
action_summaries {
__typename
... on ${Reaction}ActionSummary {
count
current_user {
@@ -381,7 +408,10 @@ export default (reaction) => (WrappedComponent) => {
}
}
}
}`
${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
}
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
@@ -391,4 +421,4 @@ export default (reaction) => (WrappedComponent) => {
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
});
+34 -5
View File
@@ -8,13 +8,28 @@ import {withAddTag, withRemoveTag} from 'coral-framework/graphql/mutations';
import withFragments from 'coral-framework/hocs/withFragments';
import {addNotification} from 'coral-framework/actions/notification';
import {forEachError, isTagged} from 'coral-framework/utils';
import hoistStatics from 'recompose/hoistStatics';
import {getDefinitionName} from '../utils';
export default (tag) => (WrappedComponent) => {
/*
* Disable false-positive warning below, as it doesn't work well with how we currently
* assemble the queries.
*
* Warning: fragment with name {fragment name} already exists.
* graphql-tag enforces all fragment names across your application to be unique; read more about
* this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
*/
gql.disableFragmentWarnings();
export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof tag !== 'string') {
console.error('Tag must be a valid string');
return null;
}
// fragments allow the extension of the fragments defined in this HOC.
const {fragments = {}} = options;
const Tag = capitalize(tag);
const TAG = tag.toUpperCase();
@@ -68,13 +83,15 @@ export default (tag) => (WrappedComponent) => {
}
render() {
const {comment, user, config} = this.props;
const {root, asset, comment, user, config} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return <WrappedComponent
user={user}
root={root}
asset={asset}
comment={comment}
user={user}
alreadyTagged={alreadyTagged}
postTag={this.postTag}
deleteTag={this.deleteTag}
@@ -92,14 +109,26 @@ export default (tag) => (WrappedComponent) => {
const enhance = compose(
withFragments({
...fragments,
asset: gql`
fragment ${Tag}Button_asset on Asset {
id
${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
}
${fragments.asset ? fragments.asset : ''}
`,
comment: gql`
fragment ${Tag}Button_comment on Comment {
id
tags {
tag {
name
}
}
}`
${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
}
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withAddTag,
@@ -109,4 +138,4 @@ export default (tag) => (WrappedComponent) => {
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
return enhance(WithTags);
};
});
@@ -31,7 +31,6 @@ export default withFragments({
name
}
}
user {
id
username
@@ -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);
@@ -18,7 +18,7 @@ class TabPaneContainer extends React.Component {
limit: 5,
cursor: this.props.root.asset.featuredComments.endCursor,
asset_id: this.props.root.asset.id,
sort: 'DESC',
sort: 'REVERSE_CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
@@ -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);
@@ -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';
@@ -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);
+1 -1
View File
@@ -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';
@@ -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 = () => {
@@ -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);
@@ -1,4 +1,4 @@
import PermalinkButton from './components/PermalinkButton';
import PermalinkButton from './containers/PermalinkButton';
export default {
slots: {
+1 -1
View File
@@ -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(';')
+15 -14
View File
@@ -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,
};
+45 -9
View File
@@ -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;
}