Merge branch 'master' into off-topic-features

Conflicts:
	client/coral-embed-stream/src/components/Comment.css
	client/coral-embed-stream/src/components/Comment.js
	client/coral-embed-stream/src/components/Stream.js
	client/coral-embed-stream/src/reducers/index.js
	client/coral-framework/helpers/plugins.js
This commit is contained in:
Chi Vinh Le
2017-06-07 22:37:06 +07:00
59 changed files with 1052 additions and 500 deletions
@@ -42,3 +42,12 @@ export const changeUserDetailStatuses = (tab) => {
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
export const toggleSelectCommentInUserDetail = (id, active) => {
return {
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
id
};
};
@@ -9,3 +9,6 @@ export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
+2 -1
View File
@@ -9,9 +9,10 @@ import App from './components/App';
import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
loadPluginsTranslations();
injectPluginsReducers();
render(
<ApolloProvider client={client} store={store}>
+1 -1
View File
@@ -13,5 +13,5 @@ export default {
community,
moderation,
install,
config
config,
};
+11 -2
View File
@@ -1,4 +1,4 @@
import {fromJS, Map} from 'immutable';
import {fromJS, Map, Set} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
@@ -10,6 +10,7 @@ const initialState = fromJS({
userDetailId: null,
userDetailActiveTab: 'all',
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
@@ -66,11 +67,19 @@ export default function moderation (state = initialState, action) {
case actions.VIEW_USER_DETAIL:
return state.set('userDetailId', action.userId);
case actions.HIDE_USER_DETAIL:
return state.set('userDetailId', null);
return state
.set('userDetailId', null)
.update('userDetailSelectedIds', (set) => set.clear());
case actions.CLEAR_USER_DETAIL_SELECTIONS:
return state.update('userDetailSelectedIds', (set) => set.clear());
case actions.CHANGE_USER_DETAIL_STATUSES:
return state
.set('userDetailActiveTab', action.tab)
.set('userDetailStatuses', action.statuses);
case actions.SELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
case actions.UNSELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
@@ -99,7 +99,7 @@
.inlineTextfield {
border-color: #ccc;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-width: 0px 0px 1px 0px;
text-align: center;
font-size: inherit;
}
@@ -108,7 +108,7 @@
outline: none;
}
.charCountTexfield {
.charCountTexfield, .editCommentTimeframeTextfield {
width: 4em;
padding: 0px;
}
@@ -25,12 +25,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
const on = styles.enabledSetting;
const off = styles.disabledSetting;
const onChangeEditCommentWindowLength = (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
updateSettings({editCommentWindowLength: milliseconds || value});
};
return (
<div className={styles.Configure}>
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
@@ -76,27 +70,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
onChangeWordlist={onChangeWordlist} />
{/* Edit Comment Timeframe */}
<Card className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
<p>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
style={{width: '3em'}}
className={styles.inlineTextfield}
type="number"
min="0"
onChange={onChangeEditCommentWindowLength}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</p>
</Card>
</div>
);
};
@@ -61,6 +61,13 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
}
};
const updateEditCommentWindowLength = (updateSettings) => (e) => {
const value = e.target.value;
const valueAsNumber = parseFloat(value);
const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000);
updateSettings({editCommentWindowLength: milliseconds || value});
};
const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
// just putting this here for shorthand below
@@ -132,6 +139,25 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
</div>
</div>
</Card>
{/* Edit Comment Timeframe */}
<Card className={styles.configSetting}>
<div className={styles.settingsHeader}>{t('configure.edit_comment_timeframe_heading')}</div>
<p>
{t('configure.edit_comment_timeframe_text_pre')}
&nbsp;
<input
className={`${styles.inlineTextfield} ${styles.editCommentTimeframeTextfield}`}
type="number"
min="0"
onChange={updateEditCommentWindowLength(updateSettings)}
placeholder="30"
defaultValue={(settings.editCommentWindowLength / 1000) /* saved as ms, rendered as seconds */}
pattern='[0-9]+([\.][0-9]*)?'
/>
&nbsp;
{t('configure.edit_comment_timeframe_text_post')}
</p>
</Card>
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
<div className={styles.action}>
<Checkbox
@@ -24,6 +24,8 @@ const Comment = ({
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
...props
}) => {
const links = linkify.getMatches(comment.body);
@@ -48,10 +50,17 @@ const Comment = ({
})
.concat(linkText);
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
return (
<li
tabIndex={props.index}
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
@@ -63,6 +72,16 @@ const Comment = ({
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
@@ -187,6 +206,7 @@ Comment.propTypes = {
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
toggleSelect: PropTypes.func,
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
@@ -41,8 +41,11 @@
}
.commentStatuses {
padding: 0;
padding: 10px 0 0 0;
margin: 0;
height: 52px;
list-style: none;
box-sizing: border-box;
li {
display: inline-block;
@@ -56,3 +59,24 @@
font-weight: bold;
border-bottom: 3px solid #F36451;
}
.bulkActionGroup {
height: 52px;
background-color: #efefef;
i {
margin-right: 0;
}
.bulkAction {
display: inline-block;
width: 48px;
height: 48px;
transform: scale(.7);
min-width: 0;
}
.bulkAction:last-child {
margin-left: -10px;
}
}
@@ -16,6 +16,10 @@ export default class UserDetail extends React.Component {
showSuspendUserDialog: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
toggleSelect: PropTypes.func.isRequired,
bulkAccept: PropTypes.func.isRequired,
bulkReject: PropTypes.func.isRequired,
}
copyPermalink = () => {
@@ -28,12 +32,24 @@ export default class UserDetail extends React.Component {
}
}
changeStatus = (tab) => {
if (tab === 'all') {
this.props.changeStatus('all');
} else if (tab === 'rejected') {
this.props.changeStatus('rejected');
}
rejectThenReload = (info) => {
this.props.rejectComment(info).then(() => {
this.props.data.refetch();
});
}
acceptThenReload = (info) => {
this.props.acceptComment(info).then(() => {
this.props.data.refetch();
});
}
showAll = () => {
this.props.changeStatus('all');
}
showRejected = () => {
this.props.changeStatus('rejected');
}
render () {
@@ -44,13 +60,17 @@ export default class UserDetail extends React.Component {
rejectedComments,
comments: {nodes}
},
moderation: {userDetailActiveTab: tab},
moderation: {
userDetailActiveTab: tab,
userDetailSelectedIds: selectedIds
},
bannedWords,
suspectWords,
toggleSelect,
bulkAccept,
bulkReject,
showBanUserDialog,
showSuspendUserDialog,
acceptComment,
rejectComment,
hideUserDetail
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
@@ -94,14 +114,38 @@ export default class UserDetail extends React.Component {
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.changeStatus.bind(this, 'all')}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.changeStatus.bind(this, 'rejected')}>Rejected</li>
</ul>
{
selectedIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{`${selectedIds.length} comments selected`}
</div>
)
}
<div>
{
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={i}
index={i}
@@ -113,8 +157,10 @@ export default class UserDetail extends React.Component {
actions={actionsMap[status]}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
acceptComment={acceptComment}
rejectComment={rejectComment}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
currentAsset={null}
currentUserId={this.props.id}
minimal={true} />;
@@ -185,10 +185,6 @@ span {
padding: 0 14px;
}
&:hover {
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
}
&:last-child {
border-bottom: none;
}
@@ -291,7 +287,6 @@ span {
@media (--big-viewport) {
.listItem {
border: 1px solid #e0e0e0;
margin-bottom: 30px;
&:last-child {
@@ -460,3 +455,15 @@ span {
position: relative;
}
}
.minimal {
margin: 0;
}
.minimalSelection {
background-color: #ecf4ff;
}
.bulkSelectInput {
cursor: pointer;
}
@@ -6,7 +6,12 @@ import UserDetail from '../components/UserDetail';
import withQuery from 'coral-framework/hocs/withQuery';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
import {getDefinitionName} from 'coral-framework/utils';
import {changeUserDetailStatuses} from 'coral-admin/src/actions/moderation';
import {
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail
} from 'coral-admin/src/actions/moderation';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import Comment from './Comment';
const commentConnectionFragment = gql`
@@ -31,12 +36,37 @@ class UserDetailContainer extends React.Component {
hideUserDetail: PropTypes.func.isRequired
}
// status can be 'ACCEPTED' or 'REJECTED'
bulkSetCommentStatus = (status) => {
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
return this.props.setCommentStatus({commentId, status});
});
Promise.all(changes).then(() => {
this.props.data.refetch(); // some comments may have moved out of this tab
this.props.clearUserDetailSelections(); // un-select everything
});
}
bulkReject = () => {
this.bulkSetCommentStatus('REJECTED');
}
bulkAccept = () => {
this.bulkSetCommentStatus('ACCEPTED');
}
render () {
if (!('user' in this.props.root)) {
return null;
}
return <UserDetail changeStatus={this.props.changeUserDetailStatuses} {...this.props}/>;
return <UserDetail
bulkReject={this.bulkReject}
bulkAccept={this.bulkAccept}
changeStatus={this.props.changeUserDetailStatuses}
toggleSelect={this.props.toggleSelectCommentInUserDetail}
{...this.props} />;
}
}
@@ -79,10 +109,15 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({changeUserDetailStatuses}, dispatch)
...bindActionCreators({
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail
}, dispatch)
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withUserDetailQuery,
withSetCommentStatus,
)(UserDetailContainer);
+8 -4
View File
@@ -14,14 +14,18 @@ if (window.devToolsExtension) {
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: client.reducer()
};
const store = createStore(
combineReducers({
...mainReducer,
apollo: client.reducer()
}),
combineReducers(coralReducers),
{},
compose(...middlewares)
);
store.coralReducers = coralReducers;
window.coralStore = store;
export default store;
@@ -2,7 +2,6 @@ import {pym} from 'coral-framework';
import * as actions from '../constants/stream';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount});
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
@@ -101,3 +101,13 @@
.commentInfoBar {
float: right;
}
@keyframes enter {
0% {background-color: rgba(0, 0, 0, 0);}
50% {background-color: rgba(255,255,0, 0.2);}
100% {background-color: rgba(0, 0, 0, 0);}
}
.enter {
animation: enter 1000ms;
}
@@ -1,5 +1,4 @@
import React, {PropTypes} from 'react';
import cn from 'classnames';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
@@ -8,6 +7,9 @@ import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
import {
BestButton,
IfUserCanModifyBest,
@@ -26,6 +28,41 @@ import styles from './Comment.css';
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length;
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second newest comment in
// the current reply list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
function resetCursors(state, props) {
const replies = props.comment.replies;
if (replies && replies.nodes.length) {
const idCursors = [replies.nodes[replies.nodes.length - 1].id];
if (replies.nodes.length >= 2) {
idCursors.push(replies.nodes[replies.nodes.length - 2].id);
}
return {idCursors};
}
return {idCursors: []};
}
// invalidateCursor is called whenever a comment is removed which is referenced
// by one of the 2 id cursors. It returns a new set of id cursors calculated
// using the help of the backup cursor.
function invalidateCursor(invalidated, state, props) {
const alt = invalidated === 1 ? 0 : 1;
const replies = props.comment.replies;
const idCursors = [];
if (state.idCursors[alt]) {
idCursors.push(state.idCursors[alt]);
const index = replies.nodes.findIndex((node) => node.id === idCursors[0]);
const prevInLine = replies.nodes[index - 1];
if (prevInLine) {
idCursors.push(prevInLine.id);
}
}
return {idCursors};
}
// hold actions links (e.g. Reply) along the comment footer
const ActionButton = ({children}) => {
@@ -49,9 +86,47 @@ 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,
...resetCursors({}, props),
};
}
componentWillReceiveProps(next) {
const {comment: {replies: prevReplies}} = this.props;
const {comment: {replies: nextReplies}} = next;
if (
prevReplies && nextReplies &&
nextReplies.nodes.length < prevReplies.nodes.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextReplies.nodes, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextReplies.nodes, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
}
}
}
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
}
}
this.setState({animateEnter: true});
}
static propTypes = {
reactKey: PropTypes.string.isRequired,
@@ -67,6 +142,7 @@ export default class Comment extends React.Component {
addNotification: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
liveUpdates: PropTypes.bool.isRequired,
asset: PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
@@ -127,6 +203,50 @@ export default class Comment extends React.Component {
}
}
hasIgnoredReplies() {
return this.props.comment.replies &&
this.props.comment.replies.nodes.some((reply) => this.props.commentIsIgnored(reply));
}
loadNewReplies = () => {
const {replies, replyCount, id} = this.props.comment;
if (replyCount > replies.nodes.length) {
this.props.loadMore(id).then(() => {
this.setState(resetCursors(this.state, this.props));
});
return;
}
this.setState(resetCursors);
};
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
const {comment: {replies}, currentUser, liveUpdates} = this.props;
const idCursor = this.state.idCursors[0];
const userId = currentUser ? currentUser.id : null;
if (!replies) {
return [];
}
if (liveUpdates) {
return replies.nodes;
}
const view = [];
let pastCursor = false;
replies.nodes.forEach((comment) => {
if (idCursor && !pastCursor || comment.user.id === userId) {
view.push(comment);
}
if (comment.id === idCursor) {
pastCursor = true;
}
});
return view;
}
componentDidMount() {
this._isMounted = true;
if (this.editWindowExpiryTimeout) {
@@ -156,26 +276,30 @@ export default class Comment extends React.Component {
comment,
postFlag,
parentId,
loadMore,
ignoreUser,
highlighted,
postComment,
currentUser,
postDontAgree,
setActiveReplyBox,
activeReplyBox,
deleteAction,
disableReply,
maxCharCount,
postDontAgree,
addCommentTag,
activeReplyBox,
addNotification,
charCountEnable,
showSignInDialog,
removeCommentTag,
liveUpdates,
commentIsIgnored,
setActiveReplyBox,
commentClassNames = []
} = this.props;
const view = this.getVisibileReplies();
const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length);
const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -241,7 +365,7 @@ export default class Comment extends React.Component {
let res = [];
// Adding classNames based on tags
Object.keys(className).map((cn) => {
Object.keys(className).forEach((cn) => {
const condition = className[cn];
condition.tags.forEach((tag) => {
if (hasTag(comment.tags, tag)) {
@@ -257,6 +381,7 @@ export default class Comment extends React.Component {
return (
<div
className={cn(commentClass, 'talk-stream-comment-wrapper', {[styles.enter]: this.state.animateEnter})}
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}
className={cn([commentClass, classNamesToAdd])}
@@ -265,15 +390,15 @@ export default class Comment extends React.Component {
<div
className={highlighted === comment.id ? 'highlighted-comment' : ''}
>
<AuthorName author={comment.user} />
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<span className={styles.bylineSecondary}>
<PubDate created_at={comment.created_at} />
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>(Edited)</span></span>
@@ -402,46 +527,49 @@ export default class Comment extends React.Component {
assetId={asset.id}
/>
: null}
{comment.replies &&
comment.replies.nodes.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
{comment.replies &&
<div className="coral-load-more-replies">
<LoadMore
topLevel={false}
replyCount={comment.replyCount}
moreComments={comment.replyCount > comment.replies.nodes.length}
loadMore={() => loadMore(comment.id)}
/>
</div>}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="coral-load-more-replies">
<LoadMore
topLevel={false}
replyCount={replyCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
/>
</div>
</div>
);
}
@@ -41,10 +41,10 @@ export default class Embed extends React.Component {
return (
<div>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount} /></Tab>
<Tab>{t('framework.my_profile')}</Tab>
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
<TabBar onChange={this.changeTab} activeTab={activeTab} className='talk-stream-tabbar'>
<Tab className={'talk-stream-comment-count-tab'} id='stream'><Count count={totalCommentCount}/></Tab>
<Tab className={'talk-stream-profile-tab'} id='profile'>{t('framework.my_profile')}</Tab>
<Tab className={'talk-stream-configuration-tab'} id='config' restricted={!can(user, 'UPDATE_CONFIG')}>{t('framework.configure_stream')}</Tab>
</TabBar>
{commentId &&
<Button
@@ -3,18 +3,22 @@ import React from 'react';
import t from 'coral-framework/services/i18n';
// Render in place of a Comment when the author of the comment is ignored
const IgnoredCommentTombstone = () => (
<div>
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
{t('framework.comment_is_ignored')}
</p>
</div>
);
class IgnoredCommentTombstone extends React.Component {
render() {
return (
<div>
<hr aria-hidden={true} />
<p style={{
backgroundColor: '#F0F0F0',
textAlign: 'center',
padding: '1em',
color: '#3E4F71',
}}>
{t('framework.comment_is_ignored')}
</p>
</div>
);
}
}
export default IgnoredCommentTombstone;
@@ -9,6 +9,9 @@ class LoadMore extends React.Component {
}
replyCountFormat = (count) => {
if (!count) {
return t('framework.view_all_replies_unknown_number');
}
if (count === 1) {
return t('framework.view_reply');
}
@@ -2,22 +2,14 @@ import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
const onLoadMoreClick = ({loadMore, commentCount, setCommentCountCache}) => (e) => {
e.preventDefault();
setCommentCountCache(commentCount);
loadMore();
};
const NewCount = (props) => {
const newComments = props.commentCount - props.commentCountCache;
const NewCount = ({count, loadMore}) => {
return <div className='coral-new-comments coral-load-more'>
{
props.commentCountCache && newComments > 0 ?
<button onClick={onLoadMoreClick(props)}>
{newComments === 1
? t('framework.new_count', newComments, t('framework.comment'))
: t('framework.new_count', newComments, t('framework.comments'))}
count ?
<button onClick={loadMore}>
{count === 1
? t('framework.new_count', count, t('framework.comment'))
: t('framework.new_count', count, t('framework.comments'))}
</button>
: null
}
@@ -25,8 +17,7 @@ const NewCount = (props) => {
};
NewCount.propTypes = {
commentCount: PropTypes.number.isRequired,
commentCountCache: PropTypes.number,
count: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
};
@@ -1,7 +1,6 @@
import React, {PropTypes} from 'react';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
import Comment from '../containers/Comment';
import Comment from '../components/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'coral-plugin-infobox/InfoBox';
@@ -13,8 +12,81 @@ import t, {timeago} from 'coral-framework/services/i18n';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second comment of
// the current comment list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
function resetCursors(state, props) {
const comments = props.root.asset.comments;
if (comments && comments.nodes.length) {
const idCursors = [comments.nodes[0].id];
if (comments.nodes[1]) {
idCursors.push(comments.nodes[1].id);
}
return {idCursors};
}
return {idCursors: []};
}
// invalidateCursor is called whenever a comment is removed which is referenced
// by one of the 2 id cursors. It returns a new set of id cursors calculated
// using the help of the backup cursor.
function invalidateCursor(invalidated, state, props) {
const alt = invalidated === 1 ? 0 : 1;
const comments = props.root.asset.comments;
const idCursors = [];
if (state.idCursors[alt]) {
idCursors.push(state.idCursors[alt]);
const index = comments.nodes.findIndex((node) => node.id === idCursors[0]);
const nextInLine = comments.nodes[index + 1];
if (nextInLine) {
idCursors.push(nextInLine.id);
}
}
return {idCursors};
}
class Stream extends React.Component {
constructor(props) {
super(props);
this.state = resetCursors(this.state, props);
}
componentWillReceiveProps(next) {
const {root: {asset: {comments: prevComments}}} = this.props;
const {root: {asset: {comments: nextComments}}} = next;
if (!prevComments && nextComments) {
this.setState(resetCursors);
return;
}
if (
prevComments && nextComments &&
nextComments.nodes.length < prevComments.nodes.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
}
}
}
viewNewComments = () => {
this.setState(resetCursors);
};
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
@@ -23,6 +95,30 @@ class Stream extends React.Component {
}
};
// getVisibileComments returns a list containing comments
// which were authored by current user or comes after the `idCursor`.
getVisibleComments() {
const {root: {asset: {comments}}, auth: {user}} = this.props;
const idCursor = this.state.idCursors[0];
const userId = user ? user.id : null;
if (!comments) {
return [];
}
const view = [];
let pastCursor = false;
comments.nodes.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor || comment.user.id === userId) {
view.push(comment);
}
});
return view;
}
render() {
const {
commentClassNames,
@@ -38,9 +134,9 @@ class Stream extends React.Component {
auth: {loggedIn, user},
removeCommentTag,
pluginProps,
commentCountCache,
editName
} = this.props;
const view = this.getVisibleComments();
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
@@ -97,8 +193,6 @@ class Stream extends React.Component {
postComment={this.props.postComment}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
@@ -145,16 +239,15 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
liveUpdates={true}
/>
: <div className="commentStreamContainer">
<NewCount
commentCount={asset.commentCount}
commentCountCache={commentCountCache}
setCommentCountCache={this.props.setCommentCountCache}
loadMore={this.props.loadNewComments}
count={comments.nodes.length - view.length}
loadMore={this.viewNewComments}
/>
<div className="embed__stream">
{comments && comments.nodes.map((comment) => {
<TransitionGroup component='div' className="embed__stream">
{view.map((comment) => {
return commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
@@ -185,9 +278,10 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={this.props.editComment}
liveUpdates={false}
/>;
})}
</div>
</TransitionGroup>
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
@@ -1,5 +1,3 @@
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE';
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
@@ -14,7 +14,7 @@ import Embed from '../components/Embed';
import Stream from './Stream';
import {setActiveTab} from '../actions/embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
import {viewAllComments} from '../actions/stream';
const {logout, checkLogin} = authActions;
const {fetchAssetSuccess} = assetActions;
@@ -33,18 +33,11 @@ class EmbedContainer extends React.Component {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
const {setCommentCountCache, commentCountCache} = this.props;
const {asset} = nextProps.root;
if (commentCountCache === -1) {
setCommentCountCache(asset.commentCount);
}
}
}
componentDidUpdate(prevProps) {
if (!isEqual(prevProps.root.comment, this.props.root.comment)) {
if (!prevProps.root.comment && this.props.root.comment) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
@@ -87,7 +80,6 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
commentCountCache: state.stream.commentCountCache,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
@@ -103,7 +95,6 @@ const mapDispatchToProps = (dispatch) =>
setActiveTab,
viewAllComments,
fetchAssetSuccess,
setCommentCountCache
},
dispatch
);
+114 -148
View File
@@ -2,34 +2,94 @@ import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import update from 'immutability-helper';
import {notificationActions, authActions} from 'coral-framework';
import {editName} from 'coral-framework/actions/user';
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
import {setActiveReplyBox} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
import {Spinner} from 'coral-ui';
import {getDefinitionName} from 'coral-framework/utils';
import {
findCommentInEmbedQuery,
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
insertFetchedCommentsIntoEmbedQuery,
} from '../graphql/utils';
const {showSignInDialog} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
getCounts = (variables) => {
return this.props.data.fetchMore({
query: LOAD_COMMENT_COUNTS_QUERY,
variables,
subscriptions = [];
// Apollo requires this, even though we don't use it...
updateQuery: (data) => data,
subscribeToUpdates() {
const sub1 = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) {
return prev;
}
// Exit when comment is not in the query.
if (!findCommentInEmbedQuery(prev, commentEdited.id)) {
return prev;
}
if (['PREMOD', 'REJECTED'].includes(commentEdited.status)) {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
});
};
const sub2 = this.props.data.subscribeToMore({
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
// Ignore mutations from me.
// TODO: need way to detect mutations created by this client, and allow mutations from other clients.
if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) {
return prev;
}
// Exit if author is ignored.
if (
this.props.root.me &&
this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) {
return prev;
}
// Exit when comment is already in the query.
if (findCommentInEmbedQuery(prev, commentAdded.id)) {
return prev;
}
return insertCommentIntoEmbedQuery(prev, commentAdded);
}
});
this.subscriptions.push(sub1, sub2);
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
loadNewReplies = (parent_id) => {
const comment = this.props.root.comment
@@ -47,86 +107,11 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
const updateNode = (node) =>
update(node, {
replies: {
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
))
.sort(ascending)
},
},
});
// highlighted comment.
if (prev.comment) {
if (prev.comment.parent) {
return update(prev, {
comment: {
parent: {$apply: (comment) => updateNode(comment)},
}
});
}
return update(prev, {
comment: {$apply: (comment) => updateNode(comment)},
});
}
return update(prev, {
asset: {
comments: {
nodes: {
$apply: (nodes) => nodes.map(
(node) => node.id !== parent_id
? node
: updateNode(node)
)
},
},
},
});
return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id);
},
});
}
loadNewComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
cursor: this.props.root.asset.comments.startCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
startCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
)
.concat(nodes)
.sort(descending)
},
},
},
});
},
});
};
loadMoreComments = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -139,78 +124,77 @@ class StreamContainer extends React.Component {
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
if (!comments.nodes.length) {
return prev;
}
return update(prev, {
asset: {
comments: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$push: comments.nodes},
},
},
});
return insertFetchedCommentsIntoEmbedQuery(prev, comments);
},
});
};
componentDidMount() {
if (this.props.previousTab) {
this.props.data.refetch()
.then(({data: {asset: {commentCount}}}) => {
return this.props.setCommentCountCache(commentCount);
});
this.props.data.refetch();
}
this.countPoll = setInterval(() => {
this.getCounts(this.props.data.variables);
}, NEW_COMMENT_COUNT_POLL_INTERVAL);
this.subscribeToUpdates();
}
componentWillUnmount() {
this.unsubscribe();
clearInterval(this.countPoll);
}
render() {
if (this.props.refetching) {
return <Spinner />;
}
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewComments={this.loadNewComments}
loadNewReplies={this.loadNewReplies}
/>;
}
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
`;
const descending = (a, b) => ascending(a, b) * -1;
const LOAD_COMMENT_COUNTS_QUERY = gql`
query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) {
comment(id: $commentId) @include(if: $hasComment) {
id
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){
commentAdded(asset_id: $assetId){
parent {
id
replyCount(excludeIgnored: $excludeIgnored)
}
replyCount(excludeIgnored: $excludeIgnored)
...CoralEmbedStream_Stream_comment
}
asset(id: $assetId, url: $assetUrl) {
}
${commentFragment}
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription onCommentEdited($assetId: ID!){
commentEdited(asset_id: $assetId){
id
commentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10) @skip(if: $hasComment) {
nodes {
id
replyCount(excludeIgnored: $excludeIgnored)
}
body
status
editing {
edited
}
user {
id
}
}
}
@@ -241,24 +225,6 @@ const LOAD_MORE_QUERY = gql`
${Comment.fragments.comment}
`;
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
`;
const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
@@ -316,6 +282,7 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
@@ -332,7 +299,6 @@ const mapDispatchToProps = (dispatch) =>
addNotification,
setActiveReplyBox,
editName,
setCommentCountCache,
}, dispatch);
export default compose(
@@ -91,6 +91,9 @@ const extension = {
edited
editableUntil
}
parent {
id
}
}
`,
},
@@ -144,6 +147,9 @@ const extension = {
tags: tags.map((t) => ({name: t, __typename: 'Tag'})),
status: null,
replyCount: 0,
parent: parent_id
? {id: parent_id}
: null,
replies: {
__typename: 'CommentConnection',
nodes: [],
@@ -165,7 +171,7 @@ const extension = {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return prev;
}
return insertCommentIntoEmbedQuery(prev, parent_id, comment);
return insertCommentIntoEmbedQuery(prev, comment);
},
}
}),
+108 -41
View File
@@ -1,11 +1,33 @@
import update from 'immutability-helper';
function findAndInsertComment(parent, id, comment) {
const [connectionField, countField, action] = parent.comments
function applyToCommentsOrigin(root, callback) {
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => callback(node),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => callback(node),
},
});
}
return update(root, {
asset: {$apply: (asset) => callback(asset)},
});
}
function findAndInsertComment(parent, comment) {
const [connectionField, countField, action] = parent.__typename === 'Asset'
? ['comments', 'commentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (!id || parent.id === id) {
if (!comment.parent || parent.id === comment.parent.id) {
return update(parent, {
[connectionField]: {
nodes: {[action]: [comment]},
@@ -21,13 +43,13 @@ function findAndInsertComment(parent, id, comment) {
[connectionField]: {
nodes: {
$apply: (nodes) =>
nodes.map((node) => findAndInsertComment(node, id, comment))
nodes.map((node) => findAndInsertComment(node, comment))
},
},
});
}
export function insertCommentIntoEmbedQuery(root, id, comment) {
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
@@ -35,30 +57,11 @@ export function insertCommentIntoEmbedQuery(root, id, comment) {
totalCommentCount: {$apply: (c) => c + 1},
},
});
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
},
});
}
return update(root, {
comment: {
$apply: (node) => findAndInsertComment(node, id, comment),
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.comments
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
: ['replies', 'replyCount'];
@@ -78,7 +81,7 @@ function findAndRemoveComment(parent, id) {
};
if (parent[countField] && next.length !== connection.nodes.length) {
changes[countField] = {$set: changes[countField] - 1};
changes[countField] = {$set: parent[countField] - 1};
}
return update(parent, changes);
}
@@ -91,24 +94,88 @@ export function removeCommentFromEmbedQuery(root, id) {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
if (root.comment) {
if (root.comment.parent) {
return update(root, {
comment: {
parent: {
$apply: (node) => findAndRemoveComment(node, id),
},
},
});
function findComment(nodes, callback) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (callback(node)) {
return node;
}
return update(root, {
comment: {
$apply: (node) => findAndRemoveComment(node, id),
if (node.replies) {
const find = findComment(node.replies.nodes, callback);
if (find){
return find;
}
}
}
return false;
}
export function findCommentInEmbedQuery(root, callbackOrId) {
let callback = callbackOrId;
if (typeof callbackOrId === 'string') {
callback = (node) => node.id === callbackOrId;
}
if (root.comment) {
if (callback(root.comment)) {
return root.comment;
}
if (root.comment.parent && callback(root.comment.parent)) {
return root.comment.parent;
}
}
if (!root.asset.comments) {
return false;
}
return findComment(root.asset.comments.nodes, callback);
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
function findAndInsertFetchedComments(parent, comments, parent_id) {
const isAsset = parent.__typename === 'Asset';
const connectionField = isAsset ? 'comments' : 'replies';
if (!parent_id && connectionField === 'comments' || parent.id === parent_id) {
return update(parent, {
[connectionField]: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => {
if (isAsset) {
return nodes.concat(comments.nodes);
}
return nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
))
.sort(ascending);
}},
},
});
}
return update(root, {
asset: {$apply: (asset) => findAndRemoveComment(asset, id)},
const connection = parent[connectionField];
if (!connection) {
return parent;
}
return update(parent, {
[connectionField]: {
nodes: {
$apply: (nodes) =>
nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id))
},
},
});
}
export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id));
}
+6 -6
View File
@@ -2,30 +2,30 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/services/client';
import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import reducers from './reducers';
import {getStore, injectReducers} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import AppRouter from './AppRouter';
import {pym} from 'coral-framework';
import {loadPluginsTranslations} from 'coral-framework/helpers/plugins';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
const store = getStore();
const client = getClient();
loadPluginsTranslations();
injectPluginsReducers();
injectReducers(reducers);
// Don't run this in the popup.
if (!window.opener) {
store.dispatch(checkLogin());
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
store.dispatch(addExternalConfig(JSON.parse(config)));
store.dispatch(checkLogin());
});
}
@@ -3,6 +3,8 @@ import * as actions from '../constants/embed';
const initialState = {
activeTab: 'stream',
previousTab: '',
refetching: false,
refetchRequestId: 0,
};
export default function stream(state = initialState, action) {
@@ -13,6 +15,23 @@ export default function stream(state = initialState, action) {
activeTab: action.tab,
previousTab: state.activeTab,
};
case 'APOLLO_QUERY_INIT':
if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) {
return {
...state,
refetching: action.isRefetch ? true : state.refetching,
refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId,
};
}
return state;
case 'APOLLO_QUERY_RESULT':
if (action.operationName === 'CoralEmbedStream_Embed') {
return {
...state,
refetching: action.requestId === state.refetchRequestId ? false : state.refetching,
};
}
return state;
default:
return state;
}
+1
View File
@@ -147,6 +147,7 @@ function configurePymParent(pymParent, opts) {
* @param {String} [opts.title] - Title of Stream (rendered in iframe)
* @param {String} [opts.asset_url] - Asset URL
* @param {String} [opts.asset_id] - Asset ID
* @param {String} [opts.auth_token] - (optional) A jwt representing the session
*/
Talk.render = function(el, opts) {
if (!el) {
+11 -7
View File
@@ -5,14 +5,9 @@ import merge from 'lodash/merge';
import plugins from 'pluginsConfig';
import flatten from 'lodash/flatten';
import flattenDeep from 'lodash/flattenDeep';
import {loadTranslations} from 'coral-framework/services/i18n';
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
export const pluginReducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[o.plugin] : o.module.reducer}))
);
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
/**
* Returns React Elements for given slot.
@@ -98,3 +93,12 @@ function getTranslations() {
export function loadPluginsTranslations() {
getTranslations().forEach((t) => loadTranslations(t));
}
export function injectPluginsReducers() {
const reducers = merge(
...plugins
.filter((o) => o.module.reducer)
.map((o) => ({[o.plugin] : o.module.reducer}))
);
injectReducers(reducers);
}
+28 -6
View File
@@ -1,6 +1,30 @@
import bowser from 'bowser';
import * as Storage from './storage';
import merge from 'lodash/merge';
import {getStore} from 'coral-framework/services/store';
/**
* getAuthToken returns the active auth token or null
* Note: this method does not have access to the cookie based token used by
* browsers that don't allow us to use cross domain iframe local storage.
* @return {string|null}
*/
export const getAuthToken = () => {
let state = getStore().getState();
if (state.config.auth_token) {
// if an auth_token exists in config, use it.
return state.config.auth_token;
} else if (!bowser.safari && !bowser.ios) {
// Use local storage auth tokens where there's a stable api.
return Storage.getItem('token');
}
return null;
};
const buildOptions = (inputOptions = {}) => {
const defaultOptions = {
@@ -14,12 +38,10 @@ const buildOptions = (inputOptions = {}) => {
let options = merge({}, defaultOptions, inputOptions);
if (!bowser.safari && !bowser.ios) {
let authorization = Storage.getItem('token');
if (authorization) {
options.headers.Authorization = `Bearer ${authorization}`;
}
// Apply authToken header
let authToken = getAuthToken();
if (authToken !== null) {
options.headers.Authorization = `Bearer ${authToken}`;
}
if (options.method.toLowerCase() !== 'get') {
-2
View File
@@ -2,12 +2,10 @@ import auth from './auth';
import user from './user';
import asset from './asset';
import {reducer as commentBox} from '../../coral-plugin-commentbox';
import {pluginReducers} from '../helpers/plugins';
export default {
auth,
user,
asset,
commentBox,
...pluginReducers
};
+30 -22
View File
@@ -1,28 +1,36 @@
import ApolloClient, {addTypename} from 'apollo-client';
import {networkInterface} from './transport';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
let client;
// TODO: replace absolute reference with something loaded from the store/page.
// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', {
// reconnect: true
// });
// const networkInterface = addGraphQLSubscriptions(
// getNetworkInterface(),
// wsClient,
// );
export const client = new ApolloClient({
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface
});
export function getClient() {
if (client) {
return client;
}
export default client;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, {
reconnect: true
});
const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
networkInterface,
wsClient,
);
client = new ApolloClient({
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface: networkInterfaceWithSubscriptions,
});
return client;
}
+23 -23
View File
@@ -1,28 +1,7 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {client} from './client';
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const middlewares = [
applyMiddleware(
client.middleware(),
thunk,
apolloErrorReporter,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
import {getClient} from './client';
export function injectReducers(reducers) {
const store = getStore();
@@ -35,9 +14,30 @@ export function getStore() {
return window.coralStore;
}
const apolloErrorReporter = () => (next) => (action) => {
if (action.type === 'APOLLO_QUERY_ERROR') {
console.error(action.error);
}
return next(action);
};
const middlewares = [
applyMiddleware(
getClient().middleware(),
thunk,
apolloErrorReporter,
),
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
const coralReducers = {
...mainReducer,
apollo: client.reducer()
apollo: getClient().reducer()
};
window.coralStore = createStore(
@@ -1,16 +0,0 @@
import {print} from 'graphql-tag/printer';
// quick way to add the subscribe and unsubscribe functions to the network interface
const addGraphQLSubscriptions = (networkInterface, wsClient) => {
return Object.assign(networkInterface, {
subscribe: (request, handler) => wsClient.subscribe({
query: print(request.query),
variables: request.variables,
}, handler),
unsubscribe: (id) => {
wsClient.unsubscribe(id);
},
});
};
export default addGraphQLSubscriptions;
+4 -4
View File
@@ -1,6 +1,5 @@
import {createNetworkInterface} from 'apollo-client';
import * as Storage from '../helpers/storage';
import bowser from 'bowser';
import {getAuthToken} from '../helpers/request';
//==============================================================================
// NETWORK INTERFACE
@@ -23,8 +22,9 @@ networkInterface.use([{
req.options.headers = {}; // Create the header object if needed.
}
if (!bowser.safari && !bowser.ios) {
req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`;
let authToken = getAuthToken();
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
next();
@@ -36,18 +36,10 @@ class CommentBox extends React.Component {
}
};
}
static get defaultProps() {
return {
setCommentCountCache: () => {}
};
}
postComment = ({body}) => {
const {
commentPostedHandler,
postComment,
setCommentCountCache,
commentCountCache,
isReply,
assetId,
parentId,
addNotification,
@@ -60,8 +52,6 @@ class CommentBox extends React.Component {
...this.props.commentBox
};
!isReply && setCommentCountCache(commentCountCache + 1);
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach((hook) => hook());
@@ -74,19 +64,12 @@ class CommentBox extends React.Component {
notifyForNewCommentStatus(addNotification, postedComment.status);
if (postedComment.status === 'REJECTED') {
!isReply && setCommentCountCache(commentCountCache);
} else if (postedComment.status === 'PREMOD') {
!isReply && setCommentCountCache(commentCountCache);
}
if (commentPostedHandler) {
commentPostedHandler();
}
})
.catch((err) => {
console.error(err);
!isReply && setCommentCountCache(commentCountCache);
});
this.setState({postedCount: this.state.postedCount + 1});
@@ -190,7 +173,6 @@ CommentBox.propTypes = {
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
setCommentCountCache: PropTypes.func,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
+2 -3
View File
@@ -1,13 +1,12 @@
import React from 'react';
import styles from './Tab.css';
export default ({children, tabId, active, onTabClick, cStyle = 'base'}) => (
export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
<li
key={tabId}
className={active ? styles[`${cStyle}--active`] : ''}
className={`${active ? `${styles[`${cStyle}--active`]} coral-tab-active` : ''} coral-tab ${props.className}`}
onClick={() => onTabClick(tabId)}
>
{children}
</li>
);
+2 -2
View File
@@ -17,13 +17,13 @@ class TabBar extends React.Component {
const {children, activeTab, cStyle = 'base'} = this.props;
return (
<div>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''}`}>
<ul className={`${styles.base} ${cStyle ? styles[cStyle] : ''} ${this.props.className}`}>
{React.Children.toArray(children)
.filter((child) => !child.props.restricted)
.map((child, tabId) =>
React.cloneElement(child, {
tabId,
active: tabId === activeTab,
active: child.props.id === activeTab,
onTabClick: this.handleClickTab,
cStyle
})