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
+35
View File
@@ -280,6 +280,41 @@ send data to the client. If the type in question contains args, clients may subs
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
#### Field: `tokenUserNotFound`
```js
tokenUserNotFound: async ({jwt, token}) => {
let profile = await someExternalService(token);
if (!profile) {
return null;
}
let user = await UserModel.findOneAndUpdate({
id: profile.id
}, {
id: profile.id,
username: profile.username,
lowercaseUsername: profile.username.toLowerCase(),
roles: [],
profiles: []
}, {
setDefaultsOnInsert: true,
new: true,
upsert: true
});
return user;
}
```
The `tokenUserNotFound` hook allows auth integrations to hook into the event
when a valid token is provided but a user can't be found in the database that
matches the provided id.
The function is async, and should return the user object that was created in the
database, or null if the user wasn't found. The `jwt` paramenter of the object
is the unpacked token, while `token` is the original jwt token string.
#### Field: `router`
```js
+1 -1
View File
@@ -5,7 +5,7 @@
*/
const program = require('./commander');
const parseDuration = require('parse-duration');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
const mongoose = require('../services/mongoose');
@@ -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
})
+6
View File
@@ -340,6 +340,12 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Execute the edit.
const comment = await CommentsService.edit(id, context.user.id, {body, status});
if (context.pubsub) {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
}
return comment;
};
+1 -1
View File
@@ -49,7 +49,7 @@ const Comment = {
},
async editing(comment, _, {loaders: {Settings}}) {
const settings = await Settings.load();
const editableUntil = new Date(Number(comment.created_at) + settings.editCommentWindowLength);
const editableUntil = new Date(Number(new Date(comment.created_at)) + settings.editCommentWindowLength);
return {
edited: comment.edited,
editableUntil: editableUntil
+3
View File
@@ -1,6 +1,9 @@
const Subscription = {
commentAdded(comment) {
return comment;
},
commentEdited(comment) {
return comment;
}
};
+5
View File
@@ -25,6 +25,11 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
filter: (comment) => comment.asset_id === args.asset_id
},
}),
commentEdited: (options, args) => ({
commentEdited: {
filter: (comment) => comment.asset_id === args.asset_id
},
}),
});
/**
+1
View File
@@ -883,6 +883,7 @@ type RootMutation {
type Subscription {
commentAdded(asset_id: ID!): Comment
commentEdited(asset_id: ID!): Comment
}
################################################################################
+5 -4
View File
@@ -158,8 +158,8 @@ en:
edit_window_expired_close: "Close"
edit_window_timer_prefix: "Edit Window: "
second: "second"
secondsPlural: "seconds"
unexpectedError: "Unexpected error while saving changes. Sorry!"
seconds_plural: "seconds"
unexpected_error: "Unexpected error while saving changes. Sorry!"
email:
confirm:
has_been_requested: "A email confirmation has been requested for the following account:"
@@ -202,7 +202,7 @@ en:
flag_reason: "Reason for reporting (Optional)"
flag_username: "Report username"
framework:
banned_account_msg: "Your account is currently suspended. This means that you cannot Like Report or write comments. Please contact us if you have any questions."
banned_account_msg: "Your account is currently banned. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions."
because_you_ignored: "Because you ignored the following commenters, their comments are hidden."
comment: comment
comment_is_ignored: "This comment is hidden because you ignored this user."
@@ -224,6 +224,7 @@ en:
success_bio_update: "Your biography has been updated"
success_name_update: "Your username has been updated"
success_update_settings: "The changes you have made have been applied to the comment stream on this article"
view_all_replies_unknown_number: "view all replies"
view_all_replies: "view {0} replies"
view_all_replies_initial: "view all {0} replies"
view_more_comments: "view more comments"
@@ -322,7 +323,7 @@ en:
bio: bio
cancel: "Cancel"
days: "{0} days"
description_0: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
description_0: "Would you like to temporarily suspend this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
description_1: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
description_notify: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
description_reject: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}."
+1
View File
@@ -223,6 +223,7 @@ es:
success_bio_update: "Tu biografia fue actualizada"
success_name_update: "Tu nombre de usuario ha sido actualizado"
success_update_settings: "La configuración de este articulo fue actualizada"
view_all_replies_unknown_number: "ver todas las respuestas"
view_all_replies: "ver {0} respuestas"
view_all_replies_initial: "ver todas las {0} respuestas"
view_more_comments: "Ver más comentarios"
+4 -1
View File
@@ -104,7 +104,10 @@ const CommentSchema = new Schema({
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
toJSON: {
virtuals: true,
},
});
CommentSchema.virtual('edited').get(function() {
+2 -1
View File
@@ -92,11 +92,11 @@
"minimist": "^1.2.0",
"mongoose": "^4.9.8",
"morgan": "^1.8.1",
"ms": "^2.0.0",
"natural": "^0.5.0",
"node-emoji": "^1.5.1",
"node-fetch": "^1.6.3",
"nodemailer": "^2.6.4",
"parse-duration": "^0.1.1",
"passport": "^0.3.2",
"passport-jwt": "^2.2.1",
"passport-local": "^1.0.0",
@@ -104,6 +104,7 @@
"react-apollo": "^1.1.0",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
"recompose": "^0.23.1",
"redis": "^2.7.1",
"resolve": "^1.3.2",
@@ -18,7 +18,6 @@ import {
facebookCallback,
invalidForm,
validForm,
checkLogin
} from 'coral-framework/actions/auth';
class SignInContainer extends React.Component {
@@ -38,10 +37,6 @@ class SignInContainer extends React.Component {
};
}
componentWillMount() {
this.props.checkLogin();
}
componentDidMount() {
window.addEventListener('storage', this.handleAuth);
@@ -187,7 +182,6 @@ const mapStateToProps = (state) => ({
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
checkLogin,
facebookCallback,
fetchSignUp,
fetchSignIn,
@@ -9,10 +9,10 @@ const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
<div>
{
loggedIn ? (
<div className={styles.userBox}>
<div className={`${styles.userBox} talk-stream-auth-userbox`}>
<span className={styles.userBoxLoggedIn}>{t('sign_in.logged_in_as')}</span>
<a onClick={onShowProfile}>{user.username}</a>. {t('sign_in.not_you')}
<a className={styles.logout} onClick={() => logout()}>
<a className={`${styles.logout} talk-stream-userbox-logout`} onClick={() => logout()}>
{t('sign_in.logout')}
</a>
</div>
@@ -7,6 +7,8 @@ import cn from 'classnames';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
const name = 'coral-plugin-respect';
class RespectButton extends Component {
handleClick = () => {
@@ -47,13 +49,13 @@ class RespectButton extends Component {
let count = getTotalActionCount('RespectActionSummary', comment);
return (
<div className={styles.respect}>
<div className={cn(styles.respect, `${name}-container`)}>
<button
className={cn(styles.button, {[styles.respected]: myRespect})}
className={cn(styles.button, {[styles.respected]: myRespect}, `${name}-button`)}
onClick={this.handleClick} >
<span>{t(myRespect ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: myRespect})} />
{count > 0 && count}
<span className={`${name}-button-text`}>{t(myRespect ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: myRespect}, `${name}-icon`)} />
<span className={`${name}-count`}>{count > 0 && count}</span>
</button>
</div>
);
@@ -1,4 +1,3 @@
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import ViewingOptions from '../components/ViewingOptions';
+22 -4
View File
@@ -10,6 +10,7 @@ const uuid = require('uuid');
const debug = require('debug')('talk:passport');
const {createClient} = require('./redis');
const bowser = require('bowser');
const ms = require('ms');
// Create a redis client to use for authentication.
const client = createClient();
@@ -39,7 +40,8 @@ const SetTokenForSafari = (req, res, token) => {
if (browser.ios || browser.safari) {
res.cookie('authorization', token, {
httpOnly: true,
expires: new Date(Date.now() + 900000)
secure: process.env.NODE_ENV === 'production',
expires: new Date(Date.now() + ms(JWT_EXPIRY))
});
}
};
@@ -172,6 +174,7 @@ const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
});
});
const jwt = require('jsonwebtoken');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
@@ -185,6 +188,19 @@ let cookieExtractor = function(req) {
return token;
};
// Override the JwtVerifier method on the JwtStrategy so we can pack the
// original token into the payload.
JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => {
return jwt.verify(token, secretOrKey, options, (err, jwt) => {
if (err) {
return callback(err);
}
// Attach the original token onto the payload.
return callback(false, {token, jwt});
});
};
// Extract the JWT from the 'Authorization' header with the 'Bearer' scheme.
passport.use(new JwtStrategy({
@@ -207,10 +223,10 @@ passport.use(new JwtStrategy({
// Enable only the HS256 algorithm.
algorithms: ['HS256'],
// Pass the request objecto back to the callback so we can attach the JWT to
// Pass the request object back to the callback so we can attach the JWT to
// it.
passReqToCallback: true
}, async (req, jwt, done) => {
}, async (req, {token, jwt}, done) => {
// Load the user from the environment, because we just got a user from the
// header.
@@ -219,7 +235,9 @@ passport.use(new JwtStrategy({
// Check to see if the token has been revoked
await CheckBlacklisted(jwt);
let user = await UsersService.findById(jwt.sub);
// Try to get the user from the database or crack it from the token and
// plugin integrations.
let user = await UsersService.findOrCreateByIDToken(jwt.sub, {token, jwt});
// Attach the JWT to the request.
req.jwt = jwt;
+51
View File
@@ -9,6 +9,7 @@ const {
JWT_SECRET,
ROOT_URL
} = require('../config');
const debug = require('debug')('talk:services:users');
const redis = require('./redis');
const redisClient = redis.createClient();
@@ -526,6 +527,29 @@ module.exports = class UsersService {
return UserModel.findOne({id});
}
/**
*
* @param {String} id the id of the current user
* @param {Object} token a jwt token used to sign in the user
*/
static async findOrCreateByIDToken(id, token) {
// Try to get the user.
let user = await UserModel.findOne({
id
});
// If the user was not found, try to look it up.
if (user === null) {
// If the user wasn't found, it will return null and the variable will be
// unchanged.
user = await lookupUserNotFound(token);
}
return user;
}
/**
* Finds users in an array of ids.
* @param {Array} ids array of user identifiers (uuid)
@@ -903,3 +927,30 @@ module.exports = class UsersService {
});
}
};
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
let tokenUserNotFoundHooks = null;
// Provide a function that can loop over the hooks and search for a provider
// can crack the token to a user.
const lookupUserNotFound = async (token) => {
if (!Array.isArray(tokenUserNotFoundHooks)) {
tokenUserNotFoundHooks = require('./plugins')
.get('server', 'tokenUserNotFound')
.map(({plugin, tokenUserNotFound}) => {
debug(`added plugin '${plugin.name}' to tokenUserNotFound hooks`);
return tokenUserNotFound;
});
}
for (let hook of tokenUserNotFoundHooks) {
let user = await hook(token);
if (user !== null && typeof user !== 'undefined') {
return user;
}
}
return null;
};
+1
View File
@@ -29,6 +29,7 @@
talk: '/',
asset_url: '<%= asset_url ? asset_url : '' %>',
asset_id: '<%= asset_id ? asset_id : '' %>',
auth_token: '',
plugin_config: {
test: 'data',
debug: false
+5 -5
View File
@@ -5365,6 +5365,10 @@ ms@0.7.3, ms@^0.7.1:
version "0.7.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.3.tgz#708155a5e44e33f5fd0fc53e81d0d40a91be1fff"
ms@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
muri@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/muri/-/muri-1.2.1.tgz#ec7ea5ce6ca6a523eb1ab35bacda5fa816c9aa3c"
@@ -5861,10 +5865,6 @@ parse-asn1@^5.0.0:
evp_bytestokey "^1.0.0"
pbkdf2 "^3.0.3"
parse-duration@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-0.1.1.tgz#13114ddc9891c1ecd280036244554de43647a226"
parse-glob@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
@@ -6909,7 +6909,7 @@ react-toastify@^1.5.0:
prop-types "^15.5.8"
react-transition-group "^1.1.2"
react-transition-group@^1.1.2:
react-transition-group@^1.1.2, react-transition-group@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.1.3.tgz#5e02cf6e44a863314ff3c68a0c826c2d9d70b221"
dependencies: