mirror of
https://github.com/wassname/talk.git
synced 2026-07-11 03:37:01 +08:00
Merge branch 'master' into user-sort
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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> <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: Don’t directly manipulate state and make state change immutable.
|
||||
if (e.target && e.target.id === 'qboxenable') {
|
||||
this.state.dirtySettings.questionBoxEnable = e.target.checked;
|
||||
changes.questionBoxEnable = e.target.checked;
|
||||
}
|
||||
if (e.target && e.target.id === 'qboxcontent') {
|
||||
this.state.dirtySettings.questionBoxContent = e.target.value;
|
||||
changes.questionBoxContent = e.target.value;
|
||||
}
|
||||
if (e.target && e.target.id === 'plinksenable') {
|
||||
this.state.dirtySettings.premodLinksEnable = e.target.value;
|
||||
changes.premodLinksEnable = e.target.value;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
changed: true
|
||||
changed: true,
|
||||
dirtySettings: {
|
||||
...this.state.dirtySettings,
|
||||
...changes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user