mirror of
https://github.com/wassname/talk.git
synced 2026-07-13 03:20:05 +08:00
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:
+35
@@ -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
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -13,5 +13,5 @@ export default {
|
||||
community,
|
||||
moderation,
|
||||
install,
|
||||
config
|
||||
config,
|
||||
};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
<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]*)?'
|
||||
/>
|
||||
|
||||
{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')}
|
||||
|
||||
<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]*)?'
|
||||
/>
|
||||
|
||||
{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);
|
||||
|
||||
@@ -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> <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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const Subscription = {
|
||||
commentAdded(comment) {
|
||||
return comment;
|
||||
},
|
||||
commentEdited(comment) {
|
||||
return comment;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -883,6 +883,7 @@ type RootMutation {
|
||||
|
||||
type Subscription {
|
||||
commentAdded(asset_id: ID!): Comment
|
||||
commentEdited(asset_id: ID!): Comment
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
+5
-4
@@ -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}."
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user