mirror of
https://github.com/wassname/talk.git
synced 2026-06-29 07:42:02 +08:00
Merge branch 'auth-tokens' of github.com:coralproject/talk into auth-tokens
This commit is contained in:
@@ -20,5 +20,7 @@ plugins/*
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
!plugins/coral-plugin-like
|
||||
!plugins/coral-plugin-mod
|
||||
!plugins/coral-plugin-love
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2016 Mozilla Foundation
|
||||
Copyright 2017 Mozilla Foundation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Talk [](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
Online comments are broken. Our open-source Talk tool rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
endpoint when the server is running with built assets.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as actions from '../constants/auth';
|
||||
import * as Storage from 'coral-framework/helpers/storage';
|
||||
import coralApi from 'coral-framework/helpers/response';
|
||||
import {handleAuthToken} from 'coral-framework/actions/auth';
|
||||
|
||||
//==============================================================================
|
||||
// SIGN IN
|
||||
//==============================================================================
|
||||
|
||||
// Log In.
|
||||
export const handleLogin = (email, password, recaptchaResponse) => dispatch => {
|
||||
dispatch({type: actions.LOGIN_REQUEST});
|
||||
const params = {method: 'POST', body: {email, password}};
|
||||
@@ -9,27 +14,42 @@ export const handleLogin = (email, password, recaptchaResponse) => dispatch => {
|
||||
params.headers = {'X-Recaptcha-Response': recaptchaResponse};
|
||||
}
|
||||
return coralApi('/auth/local', params)
|
||||
.then(({user}) => {
|
||||
.then(({user, token}) => {
|
||||
if (!user) {
|
||||
Storage.removeItem('token');
|
||||
return dispatch(checkLoginFailure('not logged in'));
|
||||
}
|
||||
|
||||
dispatch(handleAuthToken(token));
|
||||
const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(checkLoginSuccess(user, isAdmin));
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
|
||||
dispatch({type: actions.LOGIN_MAXIMUM_EXCEEDED, message: error.translation_key});
|
||||
dispatch({
|
||||
type: actions.LOGIN_MAXIMUM_EXCEEDED,
|
||||
message: error.translation_key
|
||||
});
|
||||
} else {
|
||||
dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST});
|
||||
const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS});
|
||||
const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE});
|
||||
//==============================================================================
|
||||
// FORGOT PASSWORD
|
||||
//==============================================================================
|
||||
|
||||
const forgotPassowordRequest = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_REQUEST
|
||||
});
|
||||
|
||||
const forgotPassowordSuccess = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_SUCCESS
|
||||
});
|
||||
|
||||
const forgotPassowordFailure = () => ({
|
||||
type: actions.FETCH_FORGOT_PASSWORD_FAILURE
|
||||
});
|
||||
|
||||
export const requestPasswordReset = email => dispatch => {
|
||||
dispatch(forgotPassowordRequest(email));
|
||||
@@ -38,17 +58,31 @@ export const requestPasswordReset = email => dispatch => {
|
||||
.catch(error => dispatch(forgotPassowordFailure(error)));
|
||||
};
|
||||
|
||||
// Check Login
|
||||
//==============================================================================
|
||||
// CHECK LOGIN
|
||||
//==============================================================================
|
||||
|
||||
const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST});
|
||||
const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin});
|
||||
const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
|
||||
const checkLoginRequest = () => ({
|
||||
type: actions.CHECK_LOGIN_REQUEST
|
||||
});
|
||||
|
||||
const checkLoginSuccess = (user, isAdmin) => ({
|
||||
type: actions.CHECK_LOGIN_SUCCESS,
|
||||
user,
|
||||
isAdmin
|
||||
});
|
||||
|
||||
const checkLoginFailure = error => ({
|
||||
type: actions.CHECK_LOGIN_FAILURE,
|
||||
error
|
||||
});
|
||||
|
||||
export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginRequest());
|
||||
return coralApi('/auth')
|
||||
.then(({user}) => {
|
||||
if (!user) {
|
||||
Storage.removeItem('token');
|
||||
return dispatch(checkLoginFailure('not logged in'));
|
||||
}
|
||||
|
||||
@@ -60,16 +94,3 @@ export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginFailure(`${error.translation_key}`));
|
||||
});
|
||||
};
|
||||
|
||||
// LogOut Actions
|
||||
|
||||
const logOutRequest = () => ({type: actions.LOGOUT_REQUEST});
|
||||
const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS});
|
||||
const logOutFailure = () => ({type: actions.LOGOUT_FAILURE});
|
||||
|
||||
export const logout = () => dispatch => {
|
||||
dispatch(logOutRequest());
|
||||
return coralApi('/auth', {method: 'DELETE'})
|
||||
.then(() => dispatch(logOutSuccess()))
|
||||
.catch(error => dispatch(logOutFailure(error)));
|
||||
};
|
||||
|
||||
@@ -2,9 +2,7 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
|
||||
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
|
||||
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
|
||||
|
||||
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST';
|
||||
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
|
||||
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
|
||||
export const LOGOUT = 'LOGOUT';
|
||||
|
||||
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
|
||||
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import Layout from '../components/ui/Layout';
|
||||
import {checkLogin, handleLogin, logout, requestPasswordReset} from '../actions/auth';
|
||||
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
|
||||
import {fetchConfig} from '../actions/config';
|
||||
import {logout} from 'coral-framework/actions/auth';
|
||||
import {FullLoading} from '../components/FullLoading';
|
||||
import AdminLogin from '../components/AdminLogin';
|
||||
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
|
||||
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
|
||||
|
||||
class LayoutContainer extends Component {
|
||||
componentWillMount () {
|
||||
componentWillMount() {
|
||||
const {checkLogin, fetchConfig} = this.props;
|
||||
|
||||
checkLogin();
|
||||
fetchConfig();
|
||||
}
|
||||
render () {
|
||||
render() {
|
||||
const {
|
||||
isAdmin,
|
||||
loggedIn,
|
||||
@@ -24,19 +25,34 @@ class LayoutContainer extends Component {
|
||||
passwordRequestSuccess
|
||||
} = this.props.auth;
|
||||
|
||||
const {handleLogout, toggleShortcutModal, TALK_RECAPTCHA_PUBLIC} = this.props;
|
||||
if (loadingUser) { return <FullLoading />; }
|
||||
const {
|
||||
handleLogout,
|
||||
toggleShortcutModal,
|
||||
TALK_RECAPTCHA_PUBLIC
|
||||
} = this.props;
|
||||
if (loadingUser) {
|
||||
return <FullLoading />;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return <AdminLogin
|
||||
loginMaxExceeded={loginMaxExceeded}
|
||||
handleLogin={this.props.handleLogin}
|
||||
requestPasswordReset={this.props.requestPasswordReset}
|
||||
passwordRequestSuccess={passwordRequestSuccess}
|
||||
recaptchaPublic={TALK_RECAPTCHA_PUBLIC}
|
||||
errorMessage={loginError} />;
|
||||
return (
|
||||
<AdminLogin
|
||||
loginMaxExceeded={loginMaxExceeded}
|
||||
handleLogin={this.props.handleLogin}
|
||||
requestPasswordReset={this.props.requestPasswordReset}
|
||||
passwordRequestSuccess={passwordRequestSuccess}
|
||||
recaptchaPublic={TALK_RECAPTCHA_PUBLIC}
|
||||
errorMessage={loginError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isAdmin && loggedIn) {
|
||||
return <Layout handleLogout={handleLogout} toggleShortcutModal={toggleShortcutModal} {...this.props} />;
|
||||
return (
|
||||
<Layout
|
||||
handleLogout={handleLogout}
|
||||
toggleShortcutModal={toggleShortcutModal}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <FullLoading />;
|
||||
}
|
||||
@@ -44,19 +60,19 @@ class LayoutContainer extends Component {
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
auth: state.auth.toJS(),
|
||||
TALK_RECAPTCHA_PUBLIC: state.config.get('data').get('TALK_RECAPTCHA_PUBLIC', null)
|
||||
TALK_RECAPTCHA_PUBLIC: state.config
|
||||
.get('data')
|
||||
.get('TALK_RECAPTCHA_PUBLIC', null)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
checkLogin: () => dispatch(checkLogin()),
|
||||
fetchConfig: () => dispatch(fetchConfig()),
|
||||
handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)),
|
||||
handleLogin: (username, password, recaptchaResponse) =>
|
||||
dispatch(handleLogin(username, password, recaptchaResponse)),
|
||||
requestPasswordReset: email => dispatch(requestPasswordReset(email)),
|
||||
toggleShortcutModal: toggle => dispatch(toggleShortcutModal(toggle)),
|
||||
handleLogout: () => dispatch(logout())
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LayoutContainer);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import timeago from 'timeago.js';
|
||||
import Linkify from 'react-linkify';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {Link} from 'react-router';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import styles from './styles.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
import FlagBox from './FlagBox';
|
||||
import styles from './styles.css';
|
||||
import CommentType from './CommentType';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import BanUserButton from 'coral-admin/src/components/BanUserButton';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
@@ -18,11 +19,19 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) => {
|
||||
const Comment = ({
|
||||
actions = [],
|
||||
comment,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
...props
|
||||
}) => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map(link => link.raw) : [];
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
|
||||
const flagActions =
|
||||
comment.actions &&
|
||||
comment.actions.filter(a => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
@@ -33,12 +42,17 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
|
||||
// since words are checked against word boundaries on the backend,
|
||||
// this should be the behavior on the front end as well.
|
||||
// currently the highlighter plugin does not support this out of the box.
|
||||
const searchWords = [...suspectWords, ...bannedWords].filter(w => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
|
||||
}).concat(linkText);
|
||||
const searchWords = [...suspectWords, ...bannedWords]
|
||||
.filter(w => {
|
||||
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
|
||||
})
|
||||
.concat(linkText);
|
||||
|
||||
return (
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
@@ -46,53 +60,95 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
|
||||
{comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
{timeago().format(
|
||||
comment.created_at || Date.now() - props.index * 60 * 1000,
|
||||
lang.getLocale().replace('-', '_')
|
||||
)}
|
||||
</span>
|
||||
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} />
|
||||
<BanUserButton
|
||||
user={comment.user}
|
||||
onClick={() =>
|
||||
props.showBanUserDialog(
|
||||
comment.user,
|
||||
comment.id,
|
||||
comment.status,
|
||||
comment.status !== 'REJECTED'
|
||||
)}
|
||||
/>
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
{lang.t('comment.banned_user')}
|
||||
</span>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{lang.t('comment.banned_user')}
|
||||
</span>
|
||||
: null}
|
||||
<Slot fill="adminCommentInfoBar" comment={comment} />
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset && (
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate →</Link>
|
||||
)}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate →</Link>}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body} /> <a className={styles.external} href={`${comment.asset.url}#${comment.id}`} target="_blank"><Icon name='open_in_new' /> {lang.t('comment.view_context')}</a>
|
||||
textToHighlight={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}#${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {lang.t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<Slot fill="adminCommentContent" comment={comment} />
|
||||
<div className={styles.sideActions}>
|
||||
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
: null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active = (action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return <ActionButton key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() => comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})}
|
||||
rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />;
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Slot fill="adminSideActions" comment={comment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
flagActions && flagActions.length
|
||||
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
|
||||
: null
|
||||
}
|
||||
<div>
|
||||
<Slot fill="adminCommentDetailArea" comment={comment} />
|
||||
</div>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,10 +26,8 @@ export default function auth (state = initialState, action) {
|
||||
.set('loadingUser', false)
|
||||
.set('isAdmin', action.isAdmin)
|
||||
.set('user', action.user);
|
||||
case actions.LOGOUT_SUCCESS:
|
||||
case actions.LOGOUT:
|
||||
return initialState;
|
||||
case actions.LOGIN_REQUEST:
|
||||
return state.set('loginError', null);
|
||||
case actions.LOGIN_SUCCESS:
|
||||
return state.set('loginMaxExceeded', false).set('loginError', null);
|
||||
case actions.LOGIN_FAILURE:
|
||||
|
||||
@@ -3,7 +3,7 @@ import Pym from '../../node_modules/pym.js';
|
||||
const pym = new Pym.Child({polling: 100});
|
||||
export default pym;
|
||||
|
||||
export const link = (url) => (e) => {
|
||||
export const link = url => e => {
|
||||
e.preventDefault();
|
||||
pym.sendMessage('navigate', url);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import getNetworkInterface from './transport';
|
||||
import {networkInterface} from 'coral-framework/services/transport';
|
||||
import fragmentMatcher from './fragmentMatcher';
|
||||
|
||||
export const client = new ApolloClient({
|
||||
@@ -12,5 +12,5 @@ export const client = new ApolloClient({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
networkInterface: getNetworkInterface()
|
||||
networkInterface
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const fm = new IntrospectionFragmentMatcher({
|
||||
{name: 'DefaultAction'},
|
||||
{name: 'FlagAction'},
|
||||
{name: 'DontAgreeAction'}
|
||||
],
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
@@ -48,18 +48,18 @@ const fm = new IntrospectionFragmentMatcher({
|
||||
{name: 'DefaultActionSummary'},
|
||||
{name: 'FlagActionSummary'},
|
||||
{name: 'DontAgreeActionSummary'}
|
||||
],
|
||||
]
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'AssetActionSummary',
|
||||
possibleTypes: [
|
||||
{name: 'DefaultAssetActionSummary'},
|
||||
{name: 'FlagAssetActionSummary'},
|
||||
{name: 'FlagAssetActionSummary'}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import {createNetworkInterface} from 'apollo-client';
|
||||
|
||||
export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
|
||||
return new createNetworkInterface({
|
||||
uri: apiUrl,
|
||||
opts: {
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
|
||||
|
||||
export const addExternalConfig = config => ({
|
||||
type: ADD_EXTERNAL_CONFIG,
|
||||
config
|
||||
});
|
||||
@@ -7,4 +7,3 @@ export const setActiveTab = (tab) => (dispatch, getState) => {
|
||||
dispatch(viewAllComments());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,13 +13,87 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
.bylineSecondary {
|
||||
color: #696969;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRight > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
.topRight .popover {
|
||||
margin-top: 1em;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.topRight .link.active,
|
||||
.topRight .active .link {
|
||||
padding-bottom: 0.125em;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.topRightMenu > * {
|
||||
text-align: initial;
|
||||
.editCommentForm {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft .editWindowRemaining {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.editCommentForm .button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editWindowAlmostOver {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Wizard used for Ignore User, Delete Comment confirmations */
|
||||
.Wizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px; /* consider moving to better class */
|
||||
}
|
||||
|
||||
.Wizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.Wizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import classnames from 'classnames';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
|
||||
import {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
|
||||
const isStaff = tags => !tags.every(t => t.name !== 'STAFF');
|
||||
@@ -37,7 +39,17 @@ const ActionButton = ({children}) => {
|
||||
class Comment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {replyBoxVisible: false};
|
||||
|
||||
// timeout to keep track of Comment edit window expiration
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.onClickEdit = this.onClickEdit.bind(this);
|
||||
this.stopEditing = this.stopEditing.bind(this);
|
||||
this.state = {
|
||||
|
||||
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
@@ -53,7 +65,7 @@ class Comment extends React.Component {
|
||||
parentId: PropTypes.string,
|
||||
highlighted: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
@@ -84,7 +96,13 @@ class Comment extends React.Component {
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
@@ -97,17 +115,53 @@ class Comment extends React.Component {
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
};
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
render() {
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
}
|
||||
|
||||
onClickEdit (e) {
|
||||
e.preventDefault();
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
stopEditing () {
|
||||
if (this._isMounted) {
|
||||
this.setState({isEditing: false});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
|
||||
// if still in the edit window, set a timeout to re-render once it expires
|
||||
const msLeftToEdit = editWindowRemainingMs(this.props.comment);
|
||||
if (msLeftToEdit > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
|
||||
// re-render
|
||||
this.setState(this.state);
|
||||
}, msLeftToEdit);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
this._isMounted = false;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
comment,
|
||||
parentId,
|
||||
currentUser,
|
||||
asset,
|
||||
depth,
|
||||
postItem,
|
||||
postComment,
|
||||
addNotification,
|
||||
showSignInDialog,
|
||||
highlighted,
|
||||
@@ -190,8 +244,17 @@ class Comment extends React.Component {
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null}
|
||||
<PubDate created_at={comment.created_at} />
|
||||
: null }
|
||||
|
||||
<span className={styles.bylineSecondary}>
|
||||
<PubDate created_at={comment.created_at} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>(Edited)</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
fill="commentInfoBar"
|
||||
data={this.props.data}
|
||||
@@ -200,18 +263,47 @@ class Comment extends React.Component {
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
{currentUser && comment.user.id !== currentUser.id
|
||||
? <span className={styles.topRightMenu}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
</span>
|
||||
: null}
|
||||
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
{ (currentUser &&
|
||||
(comment.user.id === currentUser.id))
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
? <span className={classnames(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
@@ -278,7 +370,7 @@ class Comment extends React.Component {
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
@@ -294,7 +386,8 @@ class Comment extends React.Component {
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
@@ -330,3 +423,21 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
||||
// return whether the comment is editable
|
||||
function commentIsStillEditable (comment) {
|
||||
const editing = comment && comment.editing;
|
||||
if ( ! editing) {return false;}
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
const editWindowExpired = (editableUntil - new Date) < 0;
|
||||
return ! editWindowExpired;
|
||||
}
|
||||
|
||||
// return number of milliseconds before edit window expires
|
||||
function editWindowRemainingMs (comment) {
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
if ( ! editableUntil) {return;}
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = (editableUntil - now);
|
||||
return editWindowRemainingMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Countdown the number of seconds until a given Date
|
||||
*/
|
||||
export class CountdownSeconds extends React.Component {
|
||||
static propTypes = {
|
||||
until: PropTypes.instanceOf(Date).isRequired,
|
||||
classNameForMsRemaining: PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const {until} = this.props;
|
||||
const now = new Date();
|
||||
if (until - now > 0) {
|
||||
this.countdownInterval = setInterval(() => {
|
||||
|
||||
// re-render
|
||||
this.forceUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.countdownInterval) {
|
||||
this.countdownInterval = clearInterval(this.countdownInterval);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const now = new Date();
|
||||
const {until, classNameForMsRemaining} = this.props;
|
||||
const msRemaining = until - now;
|
||||
const secRemaining = msRemaining / 1000;
|
||||
const wholeSecRemaining = Math.floor(secRemaining);
|
||||
const plural = secRemaining !== 1;
|
||||
const units = lang.t(plural ? 'editComment.secondsPlural' : 'editComment.second');
|
||||
let classFromProp;
|
||||
if (typeof classNameForMsRemaining === 'function') {
|
||||
classFromProp = classNameForMsRemaining(msRemaining);
|
||||
}
|
||||
return (
|
||||
<span className={classFromProp}>
|
||||
{`${wholeSecRemaining} ${units}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox';
|
||||
import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Renders a Comment's body in such a way that the end-user can edit it and save changes
|
||||
*/
|
||||
export class EditableCommentContent extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
charCountEnable: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// logged in user
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
editComment: React.PropTypes.func,
|
||||
|
||||
// called when editing should be stopped
|
||||
stopEditing: React.PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editComment = this.editComment.bind(this);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = editableUntil && (editableUntil - now);
|
||||
if (editWindowRemainingMs > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, editWindowRemainingMs);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
async editComment(edit) {
|
||||
const {editComment, addNotification, stopEditing} = this.props;
|
||||
if (typeof editComment !== 'function') {return;}
|
||||
let response;
|
||||
let successfullyEdited = false;
|
||||
try {
|
||||
response = await editComment(edit);
|
||||
const errors = (response && response.data && response.data.editComment)
|
||||
? response.data.editComment.errors
|
||||
: null;
|
||||
if (errors && (errors.length === 1)) {
|
||||
throw errors[0];
|
||||
}
|
||||
successfullyEdited = true;
|
||||
} catch (error) {
|
||||
if (error.translation_key) {
|
||||
addNotification('error', lang.t(error.translation_key) || error.translation_key);
|
||||
} else if (error.networkError) {
|
||||
addNotification('error', lang.t('error.networkError'));
|
||||
} else {
|
||||
addNotification('error', lang.t('editComment.unexpectedError'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (successfullyEdited) {
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.addNotification, status);
|
||||
}
|
||||
if (successfullyEdited && typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const originalBody = this.props.comment.body;
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const editWindowExpired = (editableUntil - new Date()) < 0;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
saveCommentEnabled={(comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== originalBody) && ! editWindowExpired;
|
||||
}}
|
||||
saveComment={this.editComment}
|
||||
bodyLabel={lang.t('editComment.bodyInputLabel')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{lang.t('editComment.saveButton')}</span>}
|
||||
saveButtonCStyle="green"
|
||||
cancelButtonClicked={this.props.stopEditing}
|
||||
buttonClass={styles.button}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{
|
||||
editWindowExpired
|
||||
? <span>
|
||||
{lang.t('editComment.editWindowExpired')}
|
||||
{
|
||||
typeof this.props.stopEditing === 'function'
|
||||
? <span> <a className={styles.link} onClick={this.props.stopEditing}>{lang.t('editComment.editWindowExpiredClose')}</a></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
: <span>
|
||||
<Icon name="timer"/> {lang.t('editComment.editWindowTimerPrefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.IgnoreUserWizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './IgnoreUserWizard.css';
|
||||
import styles from './Comment.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
// Guides the user through ignoring another user, including confirming their decision
|
||||
@@ -58,7 +58,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
const {step} = this.state;
|
||||
const elForThisStep = elsForStep[step - 1];
|
||||
return (
|
||||
<div className={styles.IgnoreUserWizard}>
|
||||
<div className={styles.Wizard}>
|
||||
{ elForThisStep }
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ class Stream extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
root: {asset, asset: {comments}, comment, myIgnoredUsers},
|
||||
postItem,
|
||||
postComment,
|
||||
addNotification,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
@@ -84,7 +84,7 @@ class Stream extends React.Component {
|
||||
{user
|
||||
? <CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
postComment={this.props.postComment}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
setCommentCountCache={this.props.setCommentCountCache}
|
||||
@@ -122,7 +122,7 @@ class Stream extends React.Component {
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={this.props.postItem}
|
||||
postComment={this.props.postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
@@ -137,6 +137,7 @@ class Stream extends React.Component {
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>
|
||||
: <div>
|
||||
<NewCount
|
||||
@@ -160,7 +161,7 @@ class Stream extends React.Component {
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
@@ -178,6 +179,7 @@ class Stream extends React.Component {
|
||||
pluginProps={pluginProps}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>)
|
||||
)}
|
||||
</div>
|
||||
@@ -196,7 +198,7 @@ class Stream extends React.Component {
|
||||
|
||||
Stream.propTypes = {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: PropTypes.func,
|
||||
@@ -205,7 +207,10 @@ Stream.propTypes = {
|
||||
removeCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
|
||||
@@ -20,5 +20,4 @@
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
/*top: -0.25em;*/
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Given a comment, return when the comment can no longer be edited
|
||||
* @param {Object} comment
|
||||
* @returns {Date} when the comment can no longer be edited.
|
||||
*/
|
||||
export const getEditableUntilDate = (comment) => {
|
||||
const editing = comment && comment.editing;
|
||||
const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil));
|
||||
return editableUntil;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
|
||||
@@ -1,6 +1,6 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
@@ -41,6 +41,10 @@ export default withFragments({
|
||||
id
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import {compose, gql, graphql} from 'react-apollo';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@@ -8,23 +8,21 @@ import renderComponent from 'recompose/renderComponent';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {authActions, assetActions, pym} from 'coral-framework';
|
||||
import {getDefinitionName, separateDataAndRoot} from 'coral-framework/utils';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {withQuery} from 'coral-framework/hocs';
|
||||
import Embed from '../components/Embed';
|
||||
import {setCommentCountCache, viewAllComments} from '../actions/stream';
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import Stream from './Stream';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import {setCommentCountCache, viewAllComments} from '../actions/stream';
|
||||
|
||||
const {logout, checkLogin} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
class EmbedContainer extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
pym.sendMessage('childReady');
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if(this.props.root.me && !nextProps.root.me) {
|
||||
if (this.props.root.me && !nextProps.root.me) {
|
||||
|
||||
// Refetch because on logout `excludeIgnored` becomes `false`.
|
||||
// TODO: logout via mutation and obsolete this?
|
||||
@@ -32,7 +30,7 @@ class EmbedContainer extends React.Component {
|
||||
}
|
||||
|
||||
const {fetchAssetSuccess} = this.props;
|
||||
if(!isEqual(nextProps.root.asset, this.props.root.asset)) {
|
||||
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
|
||||
|
||||
// TODO: remove asset data from redux store.
|
||||
fetchAssetSuccess(nextProps.root.asset);
|
||||
@@ -47,7 +45,7 @@ class EmbedContainer extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(!isEqual(prevProps.root.comment, this.props.root.comment)) {
|
||||
if (!isEqual(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);
|
||||
@@ -75,7 +73,7 @@ const EMBED_QUERY = gql`
|
||||
${Stream.fragments.root}
|
||||
`;
|
||||
|
||||
export const withQuery = graphql(EMBED_QUERY, {
|
||||
export const withEmbedQuery = withQuery(EMBED_QUERY, {
|
||||
options: ({auth, commentId, assetId, assetUrl}) => ({
|
||||
variables: {
|
||||
assetId,
|
||||
@@ -85,7 +83,6 @@ export const withQuery = graphql(EMBED_QUERY, {
|
||||
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
|
||||
},
|
||||
}),
|
||||
props: ({data}) => separateDataAndRoot(data),
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
@@ -95,24 +92,25 @@ const mapStateToProps = state => ({
|
||||
assetId: state.stream.assetId,
|
||||
assetUrl: state.stream.assetUrl,
|
||||
activeTab: state.embed.activeTab,
|
||||
config: state.config
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({
|
||||
fetchAssetSuccess,
|
||||
checkLogin,
|
||||
setCommentCountCache,
|
||||
viewAllComments,
|
||||
logout,
|
||||
setActiveTab,
|
||||
}, dispatch);
|
||||
bindActionCreators(
|
||||
{
|
||||
logout,
|
||||
checkLogin,
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
setCommentCountCache
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
branch(
|
||||
props => !props.auth.checkedInitialLogin,
|
||||
renderComponent(Spinner),
|
||||
),
|
||||
withQuery,
|
||||
branch(props => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)),
|
||||
withEmbedQuery,
|
||||
)(EmbedContainer);
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import uniqBy from 'lodash/uniqBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream';
|
||||
import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {notificationActions, authActions} from 'coral-framework';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
@@ -236,12 +240,13 @@ const mapDispatchToProps = dispatch =>
|
||||
export default compose(
|
||||
withFragments(fragments),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
postComment,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
deleteAction,
|
||||
withPostComment,
|
||||
withPostFlag,
|
||||
withPostDontAgree,
|
||||
withAddCommentTag,
|
||||
withRemoveCommentTag,
|
||||
withIgnoreUser,
|
||||
withDeleteAction,
|
||||
withEditComment,
|
||||
)(StreamContainer);
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import {add} from 'coral-framework/services/graphqlRegistry';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const extension = {
|
||||
fragments: {
|
||||
EditCommentResponse: gql`
|
||||
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
|
||||
comment {
|
||||
status
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
StopIgnoringUserResponse: gql`
|
||||
fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
IgnoreUserResponse: gql`
|
||||
fragment CoralEmbedStream_IgnoreUserResponse on IgnoreUserResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
RemoveCommentTagResponse: gql`
|
||||
fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
AddCommentTagResponse: gql`
|
||||
fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
DeleteActionResponse: gql`
|
||||
fragment CoralEmbedStream_DeleteActionResponse on DeleteActionResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateFlagResponse: gql`
|
||||
fragment CoralEmbedStream_CreateFlagResponse on CreateFlagResponse {
|
||||
flag {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateDontAgreeResponse : gql`
|
||||
fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse {
|
||||
dontagree {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateCommentResponse: gql`
|
||||
fragment CoralEmbedStream_CreateCommentResponse on CreateCommentResponse {
|
||||
comment {
|
||||
...CoralEmbedStream_CreateCommentResponse_Comment
|
||||
replies {
|
||||
...CoralEmbedStream_CreateCommentResponse_Comment
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
|
||||
fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
tags {
|
||||
name
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
mutations: {
|
||||
IgnoreUser: () => ({
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'myIgnoredUsers',
|
||||
],
|
||||
}),
|
||||
StopIgnoringUser: () => ({
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'myIgnoredUsers',
|
||||
],
|
||||
}),
|
||||
PostComment: ({
|
||||
variables: {comment: {asset_id, body, parent_id, tags = []}},
|
||||
state: {auth},
|
||||
}) => ({
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
comment: {
|
||||
user: {
|
||||
id: auth.toJS().user.id,
|
||||
name: auth.toJS().user.username
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
body,
|
||||
parent_id,
|
||||
asset_id,
|
||||
action_summaries: [],
|
||||
tags,
|
||||
status: null,
|
||||
id: 'pending'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return previousData;
|
||||
}
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
// If posting a reply
|
||||
if (parent_id) {
|
||||
updatedAsset = {
|
||||
...previousData,
|
||||
asset: {
|
||||
...previousData.asset,
|
||||
comments: previousData.asset.comments.map((oldComment) => {
|
||||
return oldComment.id === parent_id
|
||||
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
|
||||
: oldComment;
|
||||
})
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
||||
// If posting a top-level comment
|
||||
updatedAsset = {
|
||||
...previousData,
|
||||
asset: {
|
||||
...previousData.asset,
|
||||
commentCount: previousData.asset.commentCount + 1,
|
||||
comments: [comment, ...previousData.asset.comments]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAsset;
|
||||
}
|
||||
}
|
||||
}),
|
||||
EditComment: ({
|
||||
variables: {id, edit},
|
||||
}) => ({
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => {
|
||||
const updateCommentWithEdit = (comment, edit) => {
|
||||
const {body} = edit;
|
||||
const editedComment = update(comment, {
|
||||
$merge: {
|
||||
body
|
||||
},
|
||||
editing: {$merge:{edited:true}}
|
||||
});
|
||||
return editedComment;
|
||||
};
|
||||
const commentIsStillVisible = (comment) => {
|
||||
return ! ((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status)));
|
||||
};
|
||||
const resultReflectingEdit = update(previousData, {
|
||||
asset: {
|
||||
comments: {
|
||||
$apply: comments => {
|
||||
return comments.filter(commentIsStillVisible).map(comment => {
|
||||
let replyWasEditedToBeHidden = false;
|
||||
if (comment.id === id) {
|
||||
return updateCommentWithEdit(comment, edit);
|
||||
}
|
||||
const commentWithUpdatedReplies = update(comment, {
|
||||
replies: {
|
||||
$apply: (comments) => {
|
||||
return comments
|
||||
.filter(c => {
|
||||
if (commentIsStillVisible(c)) {
|
||||
return true;
|
||||
}
|
||||
replyWasEditedToBeHidden = true;
|
||||
return false;
|
||||
})
|
||||
.map(comment => {
|
||||
if (comment.id === id) {
|
||||
return updateCommentWithEdit(comment, edit);
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented.
|
||||
if (replyWasEditedToBeHidden) {
|
||||
return update(commentWithUpdatedReplies, {
|
||||
replyCount: {
|
||||
$apply: (replyCount) => {
|
||||
return replyCount - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return commentWithUpdatedReplies;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return resultReflectingEdit;
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
add(extension);
|
||||
@@ -4,10 +4,13 @@ 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 localStore, {injectReducers} from 'coral-framework/services/store';
|
||||
import AppRouter from './AppRouter';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
injectReducers(reducers);
|
||||
|
||||
@@ -16,6 +19,12 @@ const store = (window.opener && window.opener.coralStore) ? window.opener.coralS
|
||||
// Don't run this in the popup.
|
||||
if (store === localStore) {
|
||||
store.dispatch(checkLogin());
|
||||
|
||||
pym.sendMessage('getConfig');
|
||||
|
||||
pym.onMessage('config', config => {
|
||||
store.dispatch(addExternalConfig(JSON.parse(config)));
|
||||
});
|
||||
}
|
||||
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ADD_EXTERNAL_CONFIG:
|
||||
return {
|
||||
...state,
|
||||
...action.config
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import stream from './stream';
|
||||
import embed from './embed';
|
||||
import config from './config';
|
||||
import stream from './stream';
|
||||
|
||||
export default {
|
||||
stream,
|
||||
embed,
|
||||
stream,
|
||||
config
|
||||
};
|
||||
|
||||
@@ -191,12 +191,13 @@ hr {
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-textarea {
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
padding: 1em;
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid #9E9E9E;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
@@ -317,9 +318,7 @@ button.comment__action-button[disabled],
|
||||
}
|
||||
|
||||
.coral-plugin-pubdate-text {
|
||||
color: #696969;
|
||||
display: inline-block;
|
||||
font-size: .75rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,18 +22,18 @@ const snackbarStyles = {
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, \'Helvetica Neue\', Verdana, sans-serif'
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
|
||||
};
|
||||
|
||||
// This function should return value of window.Coral
|
||||
const Coral = {};
|
||||
const Talk = Coral.Talk = {};
|
||||
const Talk = (Coral.Talk = {});
|
||||
|
||||
// build the URL to load in the pym iframe
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
(talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/'
|
||||
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?'
|
||||
].join('');
|
||||
|
||||
@@ -44,11 +44,21 @@ function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
|
||||
// Set up postMessage listeners/handlers on the pymParent
|
||||
// e.g. to resize the iframe, and navigate the host page
|
||||
function configurePymParent(pymParent) {
|
||||
function configurePymParent(pymParent, opts) {
|
||||
let notificationOffset = 200;
|
||||
let ready = false;
|
||||
let cachedHeight;
|
||||
const snackbar = document.createElement('div');
|
||||
|
||||
// Sends config to pymChild
|
||||
function sendConfig(config) {
|
||||
pymParent.sendMessage('config', JSON.stringify(config));
|
||||
}
|
||||
|
||||
// Sends config to the child
|
||||
pymParent.onMessage('getConfig', function() {
|
||||
sendConfig(opts || {});
|
||||
});
|
||||
|
||||
snackbar.id = 'coral-notif';
|
||||
|
||||
for (let key in snackbarStyles) {
|
||||
@@ -69,12 +79,12 @@ function configurePymParent(pymParent) {
|
||||
}
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-clear-notification', function () {
|
||||
pymParent.onMessage('coral-clear-notification', function() {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function () {
|
||||
pymParent.onMessage('coral-view-all-comments', function() {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
@@ -82,7 +92,7 @@ function configurePymParent(pymParent) {
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function (message) {
|
||||
pymParent.onMessage('coral-alert', function(message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
snackbar.style.opacity = 0;
|
||||
@@ -110,39 +120,21 @@ function configurePymParent(pymParent) {
|
||||
pymParent.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// Tell child when parent's DOMContentLoaded
|
||||
pymParent.onMessage('childReady', function () {
|
||||
const interval = setInterval(function () {
|
||||
if (ready) {
|
||||
window.clearInterval(interval);
|
||||
|
||||
// TODO: It's weird to me that this is sent here
|
||||
pymParent.sendMessage('DOMContentLoaded');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
pymParent.onMessage('navigate', function (url) {
|
||||
pymParent.onMessage('navigate', function(url) {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// wait till images and other iframes are loaded before scrolling the page.
|
||||
// or do we want to be more aggressive and scroll when we hit DOM ready?
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
ready = true;
|
||||
});
|
||||
|
||||
// get dimensions of viewport
|
||||
const viewport = () => {
|
||||
let e = window, a = 'inner';
|
||||
if ( !( 'innerWidth' in window ) ){
|
||||
if (!('innerWidth' in window)) {
|
||||
a = 'client';
|
||||
e = document.documentElement || document.body;
|
||||
}
|
||||
return {
|
||||
width : e[`${a}Width`],
|
||||
height : e[`${a}Height`]
|
||||
width: e[`${a}Width`],
|
||||
height: e[`${a}Height`]
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -156,18 +148,24 @@ function configurePymParent(pymParent) {
|
||||
* @param {String} [opts.asset_url] - Asset URL
|
||||
* @param {String} [opts.asset_id] - Asset ID
|
||||
*/
|
||||
Talk.render = function (el, opts) {
|
||||
Talk.render = function(el, opts) {
|
||||
if (!el) {
|
||||
throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.');
|
||||
throw new Error(
|
||||
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
|
||||
);
|
||||
}
|
||||
if (typeof el !== 'object') {
|
||||
throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`);
|
||||
throw new Error(
|
||||
`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`
|
||||
);
|
||||
}
|
||||
opts = opts || {};
|
||||
|
||||
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
|
||||
if (!opts.talk) {
|
||||
throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL');
|
||||
throw new Error(
|
||||
'Coral.Talk.render() expects opts.talk as the Talk Base URL'
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure el has an id, as pym can't directly accept the HTMLElement.
|
||||
@@ -186,16 +184,21 @@ Talk.render = function (el, opts) {
|
||||
try {
|
||||
query.asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
console.warn('This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.');
|
||||
console.warn(
|
||||
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
|
||||
);
|
||||
query.asset_url = window.location.origin + window.location.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
configurePymParent(new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
}));
|
||||
configurePymParent(
|
||||
new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
}),
|
||||
opts
|
||||
);
|
||||
};
|
||||
|
||||
export default Coral;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Storage from '../helpers/storage';
|
||||
import * as actions from '../constants/auth';
|
||||
import coralApi, {base} from '../helpers/response';
|
||||
import client from 'coral-framework/services/client';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
import translations from './../translations';
|
||||
@@ -108,14 +109,14 @@ export const changeView = view => dispatch => {
|
||||
});
|
||||
|
||||
switch (view) {
|
||||
case 'SIGNUP':
|
||||
window.resizeTo(500, 800);
|
||||
break;
|
||||
case 'FORGOT':
|
||||
window.resizeTo(500, 400);
|
||||
break;
|
||||
default:
|
||||
window.resizeTo(500, 550);
|
||||
case 'SIGNUP':
|
||||
window.resizeTo(500, 800);
|
||||
break;
|
||||
case 'FORGOT':
|
||||
window.resizeTo(500, 400);
|
||||
break;
|
||||
default:
|
||||
window.resizeTo(500, 550);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,7 +139,8 @@ const signInFailure = error => ({
|
||||
// AUTH TOKEN
|
||||
//==============================================================================
|
||||
|
||||
const handleAuthToken = token => dispatch => {
|
||||
export const handleAuthToken = token => dispatch => {
|
||||
Storage.setItem('exp', jwtDecode(token).exp);
|
||||
Storage.setItem('token', token);
|
||||
dispatch({type: 'HANDLE_AUTH_TOKEN'});
|
||||
};
|
||||
@@ -156,11 +158,13 @@ export const fetchSignIn = formData => dispatch => {
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.metadata) {
|
||||
|
||||
// the user might not have a valid email. prompt the user user re-request the confirmation email
|
||||
dispatch(
|
||||
signInFailure(lang.t('error.emailNotVerified', error.metadata))
|
||||
);
|
||||
} else {
|
||||
|
||||
// invalid credentials
|
||||
dispatch(signInFailure(lang.t('error.emailPasswordError')));
|
||||
}
|
||||
@@ -288,8 +292,12 @@ export const fetchForgotPassword = email => dispatch => {
|
||||
//==============================================================================
|
||||
|
||||
export const logout = () => dispatch => {
|
||||
Storage.clear();
|
||||
dispatch({type: actions.LOGOUT});
|
||||
return coralApi('/auth', {method: 'DELETE'})
|
||||
.then(() => {
|
||||
dispatch({type: actions.LOGOUT});
|
||||
Storage.removeItem('token');
|
||||
fetchMe();
|
||||
});
|
||||
};
|
||||
|
||||
//==============================================================================
|
||||
@@ -310,6 +318,7 @@ export const checkLogin = () => dispatch => {
|
||||
coralApi('/auth')
|
||||
.then(result => {
|
||||
if (!result.user) {
|
||||
Storage.removeItem('token');
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
@@ -321,6 +330,7 @@ export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginFailure(`${error.translation_key}`));
|
||||
});
|
||||
};
|
||||
|
||||
export const validForm = () => ({type: actions.VALID_FORM});
|
||||
export const invalidForm = error => ({type: actions.INVALID_FORM, error});
|
||||
|
||||
@@ -351,6 +361,7 @@ export const requestConfirmEmail = (email, redirectUri) => dispatch => {
|
||||
dispatch(verifyEmailSuccess());
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
// email might have already been verifyed
|
||||
dispatch(verifyEmailFailure(err));
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.debug {
|
||||
background-color: coral;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './Slot.css';
|
||||
import {connect} from 'react-redux';
|
||||
import {getSlotElements} from 'coral-framework/helpers/plugins';
|
||||
|
||||
export default function Slot ({fill, inline = false, ...rest}) {
|
||||
function Slot ({fill, inline = false, plugin_config: config, ...rest}) {
|
||||
return (
|
||||
<div className={cn({[styles.inline]: inline})}>
|
||||
{getSlotElements(fill, rest)}
|
||||
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug})}>
|
||||
{getSlotElements(fill, {...rest, config})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,3 +15,8 @@ export default function Slot ({fill, inline = false, ...rest}) {
|
||||
Slot.propTypes = {
|
||||
fill: React.PropTypes.string
|
||||
};
|
||||
|
||||
const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config});
|
||||
|
||||
export default connect(mapStateToProps, null)(Slot);
|
||||
|
||||
|
||||
@@ -8,4 +8,3 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST';
|
||||
export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS';
|
||||
export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE';
|
||||
|
||||
export const UPDATE_CONFIG = 'UPDATE_CONFIG';
|
||||
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
export const ADD_ITEM = 'ADD_ITEM';
|
||||
@@ -0,0 +1,2 @@
|
||||
// fragments defined here are automatically registered.
|
||||
export default {};
|
||||
@@ -1,8 +0,0 @@
|
||||
fragment actionSummaryView on ActionSummary {
|
||||
__typename
|
||||
count
|
||||
current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#import "../fragments/actionSummaryView.graphql"
|
||||
|
||||
fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
tags {
|
||||
name
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
}
|
||||
action_summaries {
|
||||
...actionSummaryView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import withMutation from '../hocs/withMutation';
|
||||
|
||||
export const withPostComment = withMutation(
|
||||
gql`
|
||||
mutation PostComment($comment: CreateCommentInput!) {
|
||||
createComment(comment: $comment) {
|
||||
...CreateCommentResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
postComment: comment => {
|
||||
return mutate({
|
||||
variables: {
|
||||
comment
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export const withEditComment = withMutation(
|
||||
gql`
|
||||
mutation EditComment($id: ID!, $asset_id: ID!, $edit: EditCommentInput) {
|
||||
editComment(id:$id, asset_id:$asset_id, edit:$edit) {
|
||||
...EditCommentResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
editComment: (id, asset_id, edit) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
asset_id,
|
||||
edit,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export const withPostFlag = withMutation(
|
||||
gql`
|
||||
mutation PostFlag($flag: CreateFlagInput!) {
|
||||
createFlag(flag: $flag) {
|
||||
...CreateFlagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
postFlag: (flag) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
flag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withPostDontAgree = withMutation(
|
||||
gql`
|
||||
mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) {
|
||||
createDontAgree(dontagree: $dontagree) {
|
||||
...CreateDontAgreeResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
postDontAgree: (dontagree) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
dontagree
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withDeleteAction = withMutation(
|
||||
gql`
|
||||
mutation DeleteAction($id: ID!) {
|
||||
deleteAction(id:$id) {
|
||||
...DeleteActionResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
deleteAction: (id) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withAddCommentTag = withMutation(
|
||||
gql`
|
||||
mutation AddCommentTag($id: ID!, $tag: String!) {
|
||||
addCommentTag(id:$id, tag:$tag) {
|
||||
...AddCommentTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
addCommentTag: ({id, tag}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
tag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withRemoveCommentTag = withMutation(
|
||||
gql`
|
||||
mutation RemoveCommentTag($id: ID!, $tag: String!) {
|
||||
removeCommentTag(id:$id, tag:$tag) {
|
||||
...RemoveCommentTagResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
removeCommentTag: ({id, tag}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
tag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withIgnoreUser = withMutation(
|
||||
gql`
|
||||
mutation IgnoreUser($id: ID!) {
|
||||
ignoreUser(id:$id) {
|
||||
...IgnoreUserResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
ignoreUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const withStopIgnoringUser = withMutation(
|
||||
gql`
|
||||
mutation StopIgnoringUser($id: ID!) {
|
||||
stopIgnoringUser(id:$id) {
|
||||
...StopIgnoringUserResponse
|
||||
}
|
||||
}
|
||||
`, {
|
||||
props: ({mutate}) => ({
|
||||
stopIgnoringUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
mutation AddCommentTag ($id: ID!, $tag: String!) {
|
||||
addCommentTag(id:$id, tag:$tag) {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mutation deleteAction ($id: ID!) {
|
||||
deleteAction(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mutation ignoreUser ($id: ID!) {
|
||||
ignoreUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import POST_COMMENT from './postComment.graphql';
|
||||
import POST_FLAG from './postFlag.graphql';
|
||||
import POST_DONT_AGREE from './postDontAgree.graphql';
|
||||
import DELETE_ACTION from './deleteAction.graphql';
|
||||
import ADD_COMMENT_TAG from './addCommentTag.graphql';
|
||||
import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
|
||||
import IGNORE_USER from './ignoreUser.graphql';
|
||||
import STOP_IGNORING_USER from './stopIgnoringUser.graphql';
|
||||
|
||||
import commentView from '../fragments/commentView.graphql';
|
||||
|
||||
export const postComment = graphql(POST_COMMENT, {
|
||||
options: () => ({
|
||||
fragments: commentView
|
||||
}),
|
||||
props: ({ownProps, mutate}) => ({
|
||||
postItem: comment => {
|
||||
const {asset_id, body, parent_id, tags = []} = comment;
|
||||
return mutate({
|
||||
variables: {
|
||||
comment
|
||||
},
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
comment: {
|
||||
user: {
|
||||
id: ownProps.auth.user.id,
|
||||
name: ownProps.auth.user.username
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
body,
|
||||
parent_id,
|
||||
asset_id,
|
||||
action_summaries: [],
|
||||
tags,
|
||||
status: null,
|
||||
id: 'pending'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQueries: {
|
||||
EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
|
||||
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
// If posting a reply
|
||||
if (parent_id) {
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: oldData.asset.comments.map((oldComment) => {
|
||||
return oldComment.id === parent_id
|
||||
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
|
||||
: oldComment;
|
||||
})
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
||||
// If posting a top-level comment
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
commentCount: oldData.asset.commentCount + 1,
|
||||
comments: [comment, ...oldData.asset.comments]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAsset;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export const postFlag = graphql(POST_FLAG, {
|
||||
props: ({mutate}) => ({
|
||||
postFlag: (flag) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
flag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const postDontAgree = graphql(POST_DONT_AGREE, {
|
||||
props: ({mutate}) => ({
|
||||
postDontAgree: (dontagree) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
dontagree
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const deleteAction = graphql(DELETE_ACTION, {
|
||||
props: ({mutate}) => ({
|
||||
deleteAction: (id) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const addCommentTag = graphql(ADD_COMMENT_TAG, {
|
||||
props: ({mutate}) => ({
|
||||
addCommentTag: ({id, tag}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
tag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
|
||||
props: ({mutate}) => ({
|
||||
removeCommentTag: ({id, tag}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
tag
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
export const ignoreUser = graphql(IGNORE_USER, {
|
||||
props: ({mutate}) => ({
|
||||
ignoreUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'myIgnoredUsers',
|
||||
]
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
|
||||
props: ({mutate}) => {
|
||||
return {
|
||||
stopIgnoringUser: ({id}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'myIgnoredUsers',
|
||||
]
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
mutation CreateComment ($comment: CreateCommentInput!) {
|
||||
createComment(comment: $comment) {
|
||||
comment {
|
||||
...commentView
|
||||
replyCount
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
mutation CreateDontAgree($dontagree: CreateDontAgreeInput!) {
|
||||
createDontAgree(dontagree:$dontagree) {
|
||||
dontagree {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
mutation CreateFlag($flag: CreateFlagInput!) {
|
||||
createFlag(flag:$flag) {
|
||||
flag {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
mutation RemoveCommentTag ($id: ID!, $tag: String!) {
|
||||
removeCommentTag(id:$id, tag:$tag) {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mutation stopIgnoringUser ($id: ID!) {
|
||||
stopIgnoringUser(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
import MY_IGNORED_USERS from './myIgnoredUsers.graphql';
|
||||
|
||||
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
|
||||
|
||||
export const myIgnoredUsers = graphql(MY_IGNORED_USERS, {
|
||||
props: ({data}) => {
|
||||
return ({
|
||||
myIgnoredUsersData: data
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
query myCommentHistory {
|
||||
me {
|
||||
comments {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
query myIgnoredUsers {
|
||||
myIgnoredUsers {
|
||||
id,
|
||||
username,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getDisplayName(WrappedComponent) {
|
||||
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import merge from 'lodash/merge';
|
||||
import flatten from 'lodash/flatten';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import uniq from 'lodash/uniq';
|
||||
import pick from 'lodash/pick';
|
||||
import plugins from 'pluginsConfig';
|
||||
import {gql} from 'react-apollo';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
|
||||
export const pluginReducers = merge(
|
||||
...plugins
|
||||
@@ -21,23 +21,32 @@ export function getSlotElements(slot, props = {}) {
|
||||
.filter(o => o.module.slots[slot])
|
||||
.map(o => o.module.slots[slot]));
|
||||
return components
|
||||
.map((component, i) => React.createElement(component, {...props, key: i}));
|
||||
.map((component, i) => React.createElement(component, {key: i, ...props}));
|
||||
}
|
||||
|
||||
function getComponentFragments(components) {
|
||||
return components
|
||||
const res = components
|
||||
.map(c => c.fragments)
|
||||
.filter(fragments => fragments)
|
||||
.reduce((res, fragments) => {
|
||||
Object.keys(fragments).forEach(key => {
|
||||
if (!(key in res)) {
|
||||
res[key] = {spreads: '', definitions: ''};
|
||||
res[key] = {spreads: [], definitions: []};
|
||||
}
|
||||
res[key].spreads += `...${getDefinitionName(fragments[key])}\n`;
|
||||
res[key].definitions = gql`${res[key].definitions}${fragments[key]}`;
|
||||
res[key].spreads.push(getDefinitionName(fragments[key]));
|
||||
res[key].definitions.push(fragments[key]);
|
||||
});
|
||||
return res;
|
||||
}, {});
|
||||
|
||||
Object.keys(res).forEach(key => {
|
||||
|
||||
// Assemble arguments for `gql` to call it directly without using template literals.
|
||||
res[key].spreads = `...${res[key].spreads.join('\n...')}\n`;
|
||||
res[key].definitions = mergeDocuments(res[key].definitions);
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,3 +82,9 @@ export function getSlotsFragments(slots) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getGraphQLExtensions() {
|
||||
return plugins
|
||||
.map(o => pick(o.module, ['mutations', 'queries', 'fragments']))
|
||||
.filter(o => o);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
let available, error;
|
||||
|
||||
function storageAvailable(type) {
|
||||
let storage = window[type], x = '__storage_test__';
|
||||
try {
|
||||
let storage = window[type], x = '__storage_test__';
|
||||
storage.setItem(x, x);
|
||||
storage.removeItem(x);
|
||||
return true;
|
||||
@@ -14,17 +14,17 @@ function storageAvailable(type) {
|
||||
// everything except Firefox
|
||||
(e.code === 22 ||
|
||||
|
||||
// Firefox
|
||||
// Firefox
|
||||
|
||||
e.code === 1014 ||
|
||||
e.code === 1014 ||
|
||||
|
||||
// test name field too, because code might not be present
|
||||
// test name field too, because code might not be present
|
||||
|
||||
// everything except Firefox
|
||||
e.name === 'QuotaExceededError' ||
|
||||
// everything except Firefox
|
||||
e.name === 'QuotaExceededError' ||
|
||||
|
||||
// Firefox
|
||||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
|
||||
// Firefox
|
||||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
|
||||
|
||||
// acknowledge QuotaExceededError only if there's something already stored
|
||||
storage.length !== 0
|
||||
@@ -32,34 +32,62 @@ function storageAvailable(type) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getItem(item = '') {
|
||||
function lazyCheckStorage() {
|
||||
if (typeof available === 'undefined') {
|
||||
available = storageAvailable('localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
export function getItem(item = '') {
|
||||
lazyCheckStorage();
|
||||
|
||||
if (available) {
|
||||
return localStorage.getItem(item);
|
||||
} else {
|
||||
console.error(`Cannot get from localStorage. localStorage is not available. ${error}`);
|
||||
console.error(
|
||||
`Cannot get from localStorage. localStorage is not available. ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function setItem(item = '', value) {
|
||||
if (typeof available === 'undefined') {
|
||||
available = storageAvailable('localStorage');
|
||||
}
|
||||
lazyCheckStorage();
|
||||
|
||||
if (available) {
|
||||
return localStorage.setItem(item, value);
|
||||
} else {
|
||||
console.error(`Cannot set localStorage. localStorage is not available. ${error}`);
|
||||
console.error(
|
||||
`Cannot set localStorage. localStorage is not available. ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeItem(item = '') {
|
||||
lazyCheckStorage();
|
||||
|
||||
if (available) {
|
||||
return localStorage.removeItem(item);
|
||||
} else {
|
||||
console.error(
|
||||
`Cannot remove item from localStorage. localStorage is not available. ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
lazyCheckStorage();
|
||||
|
||||
if (available) {
|
||||
return localStorage.clear();
|
||||
} else {
|
||||
console.error(`Cannot clear localStorage. localStorage is not available. ${error}`);
|
||||
console.error(
|
||||
`Cannot clear localStorage. localStorage is not available. ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable this to debug WEB Storage events
|
||||
// window.addEventListener('storage', function(e) {
|
||||
// const msg = `${e.key} " was changed in page ${e.url} from ${e.oldValue} to ${e.newValue}`;
|
||||
// console.log(msg);
|
||||
// });
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function capitalize(str) {
|
||||
const newString = new String(str);
|
||||
return newString.charAt(0).toUpperCase() + newString.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {default as withFragments} from './withFragments';
|
||||
export {default as withMutation} from './withMutation';
|
||||
export {default as withQuery} from './withQuery';
|
||||
export {default as withReaction} from './withReaction';
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from 'react';
|
||||
import {getDisplayName} from '../helpers/hoc';
|
||||
|
||||
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
|
||||
|
||||
function getDisplayName(WrappedComponent) {
|
||||
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
}
|
||||
|
||||
export default fragments => WrappedComponent => {
|
||||
class WithFragments extends React.Component {
|
||||
render() {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as React from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import merge from 'lodash/merge';
|
||||
import uniq from 'lodash/uniq';
|
||||
import flatten from 'lodash/flatten';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
|
||||
import {store} from 'coral-framework/services/store';
|
||||
import {getDefinitionName} from '../utils';
|
||||
|
||||
/**
|
||||
* Exports a HOC with the same signature as `graphql`, that will
|
||||
* apply mutation options registered in the graphRegistry.
|
||||
*/
|
||||
export default (document, config) => WrappedComponent => {
|
||||
config = {
|
||||
...config,
|
||||
options: config.options || {},
|
||||
props: config.props || (data => ({mutate: data.mutate()})),
|
||||
};
|
||||
const wrappedProps = (data) => {
|
||||
const name = getDefinitionName(document);
|
||||
const callbacks = getMutationOptions(name);
|
||||
const mutate = (base) => {
|
||||
const variables = base.variables || config.options.variables;
|
||||
const configs = callbacks.map(cb => cb({variables, state: store.getState()}));
|
||||
|
||||
const optimisticResponse = merge(
|
||||
base.optimisticResponse || config.options.optimisticResponse,
|
||||
...configs.map(cfg => cfg.optimisticResponse),
|
||||
);
|
||||
|
||||
const refetchQueries = flatten(uniq([
|
||||
base.refetchQueries || config.options.refetchQueries,
|
||||
...configs.map(cfg => cfg.refetchQueries),
|
||||
].filter(i => i)));
|
||||
|
||||
const updateCallbacks =
|
||||
[base.update || config.options.update]
|
||||
.concat(...configs.map(cfg => cfg.update))
|
||||
.filter(i => i);
|
||||
|
||||
const update = (proxy, result) => {
|
||||
updateCallbacks.forEach(cb => cb(proxy, result));
|
||||
};
|
||||
|
||||
const updateQueries =
|
||||
[
|
||||
base.updateQueries || config.options.updateQueries,
|
||||
...configs.map(cfg => cfg.updateQueries)
|
||||
]
|
||||
.filter(i => i)
|
||||
.reduce((res, map) => {
|
||||
Object.keys(map).forEach(key => {
|
||||
if (!(key in res)) {
|
||||
res[key] = map[key];
|
||||
} else {
|
||||
const existing = res[key];
|
||||
res[key] = (prev, result) => {
|
||||
const next = existing(prev, result);
|
||||
return map[key](next, result);
|
||||
};
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}, {});
|
||||
|
||||
const wrappedConfig = {
|
||||
variables,
|
||||
optimisticResponse,
|
||||
refetchQueries,
|
||||
updateQueries,
|
||||
update,
|
||||
};
|
||||
if (isEmpty(wrappedConfig.optimisticResponse)) {
|
||||
delete wrappedConfig.optimisticResponse;
|
||||
}
|
||||
return data.mutate(wrappedConfig);
|
||||
};
|
||||
return config.props({...data, mutate});
|
||||
};
|
||||
|
||||
// Lazily resolve fragments from graphRegistry to support circular dependencies.
|
||||
let memoized = null;
|
||||
const getWrapped = () => {
|
||||
if (!memoized) {
|
||||
memoized = graphql(resolveFragments(document), {...config, props: wrappedProps})(WrappedComponent);
|
||||
}
|
||||
return memoized;
|
||||
};
|
||||
|
||||
return (props) => {
|
||||
const Wrapped = getWrapped();
|
||||
return <Wrapped {...props} />;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
|
||||
import {getDefinitionName, separateDataAndRoot} from '../utils';
|
||||
|
||||
/**
|
||||
* Exports a HOC with the same signature as `graphql`, that will
|
||||
* apply query options registered in the graphRegistry.
|
||||
*/
|
||||
export default (document, config) => WrappedComponent => {
|
||||
config = {
|
||||
...config,
|
||||
options: config.options || {},
|
||||
props: config.props || (({data}) => separateDataAndRoot(data)),
|
||||
};
|
||||
|
||||
const wrappedOptions = (data) => {
|
||||
const base = (typeof config.options === 'function') ? config.options(data) : config.options;
|
||||
const name = getDefinitionName(document);
|
||||
const configs = getQueryOptions(name);
|
||||
const reducerCallbacks =
|
||||
[base.reducer || (i => i)]
|
||||
.concat(...configs.map(cfg => cfg.reducer))
|
||||
.filter(i => i);
|
||||
|
||||
const reducer = reducerCallbacks.reduce(
|
||||
(a, b) => (prev, ...rest) =>
|
||||
b(a(prev, ...rest), ...rest),
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
reducer,
|
||||
};
|
||||
};
|
||||
|
||||
let memoized = null;
|
||||
const getWrapped = () => {
|
||||
if (!memoized) {
|
||||
memoized = graphql(resolveFragments(document), {...config, options: wrappedOptions})(WrappedComponent);
|
||||
}
|
||||
return memoized;
|
||||
};
|
||||
|
||||
return (props) => {
|
||||
const Wrapped = getWrapped();
|
||||
return <Wrapped {...props} />;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import uuid from 'uuid/v4';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {getDisplayName} from '../helpers/hoc';
|
||||
import {compose, gql, graphql} from 'react-apollo';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {showSignInDialog} from 'coral-framework/actions/auth';
|
||||
import {capitalize} from 'coral-framework/helpers/strings';
|
||||
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
|
||||
|
||||
export default reaction => WrappedComponent => {
|
||||
if (typeof reaction !== 'string') {
|
||||
console.error('Reaction must be a valid string');
|
||||
return null;
|
||||
}
|
||||
|
||||
reaction = reaction.toLowerCase();
|
||||
|
||||
class WithReactions extends React.Component {
|
||||
render() {
|
||||
const {comment} = this.props;
|
||||
|
||||
const reactionSummary = getMyActionSummary(
|
||||
`${capitalize(reaction)}ActionSummary`,
|
||||
comment
|
||||
);
|
||||
|
||||
const count = getTotalActionCount(
|
||||
`${capitalize(reaction)}ActionSummary`,
|
||||
comment
|
||||
);
|
||||
|
||||
const alreadyReacted = () => !!reactionSummary;
|
||||
|
||||
const withReactionProps = {reactionSummary, count, alreadyReacted};
|
||||
|
||||
return <WrappedComponent {...this.props} {...withReactionProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
const isReaction = a =>
|
||||
a.__typename === `${capitalize(reaction)}ActionSummary`;
|
||||
|
||||
const COMMENT_FRAGMENT = gql`
|
||||
fragment ${capitalize(reaction)}Button_updateFragment on Comment {
|
||||
action_summaries {
|
||||
... on ${capitalize(reaction)}ActionSummary {
|
||||
count
|
||||
current_user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const withDeleteReaction = graphql(
|
||||
gql`
|
||||
mutation deleteReaction($id: ID!) {
|
||||
deleteAction(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({mutate, ownProps}) => ({
|
||||
deleteReaction: () => {
|
||||
|
||||
const reactionSummary = getMyActionSummary(
|
||||
`${capitalize(reaction)}ActionSummary`,
|
||||
ownProps.comment
|
||||
);
|
||||
|
||||
const reactionData = {
|
||||
id: reactionSummary.current_user.id,
|
||||
commentId: ownProps.comment.id
|
||||
};
|
||||
|
||||
return mutate({
|
||||
variables: {id: reactionData.id},
|
||||
optimisticResponse: {
|
||||
deleteAction: {
|
||||
__typename: 'DeleteActionResponse',
|
||||
errors: null
|
||||
}
|
||||
},
|
||||
update: proxy => {
|
||||
const fragmentId = `Comment_${reactionData.commentId}`;
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({
|
||||
fragment: COMMENT_FRAGMENT,
|
||||
id: fragmentId
|
||||
});
|
||||
|
||||
// Check whether we liked this comment.
|
||||
const idx = data.action_summaries.findIndex(isReaction);
|
||||
if (
|
||||
idx < 0 ||
|
||||
get(data.action_summaries[idx], 'current_user.id') !== reactionData.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.action_summaries[idx] = {
|
||||
...data.action_summaries[idx],
|
||||
count: data.action_summaries[idx].count - 1,
|
||||
current_user: null
|
||||
};
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({
|
||||
fragment: COMMENT_FRAGMENT,
|
||||
id: fragmentId,
|
||||
data
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const withPostReaction = graphql(
|
||||
gql`
|
||||
mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) {
|
||||
create${capitalize(reaction)}(${reaction}: $${reaction}) {
|
||||
${reaction} {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({mutate, ownProps}) => ({
|
||||
postReaction: () => {
|
||||
|
||||
const reactionData = {
|
||||
item_id: ownProps.comment.id,
|
||||
item_type: 'COMMENTS'
|
||||
};
|
||||
|
||||
return mutate({
|
||||
variables: {[reaction]: reactionData},
|
||||
optimisticResponse: {
|
||||
[`create${capitalize(reaction)}`]: {
|
||||
__typename: `Create${capitalize(reaction)}Response`,
|
||||
errors: null,
|
||||
[reaction]: {
|
||||
__typename: `${capitalize(reaction)}Action`,
|
||||
id: uuid()
|
||||
}
|
||||
}
|
||||
},
|
||||
update: (proxy, mutationResult) => {
|
||||
const fragmentId = `Comment_${reactionData.item_id}`;
|
||||
|
||||
// Read the data from our cache for this query.
|
||||
const data = proxy.readFragment({
|
||||
fragment: COMMENT_FRAGMENT,
|
||||
id: fragmentId
|
||||
});
|
||||
|
||||
// Add our comment from the mutation to the end.
|
||||
let idx = data.action_summaries.findIndex(isReaction);
|
||||
|
||||
// Check whether we already reactioned this comment.
|
||||
if (idx >= 0 && data.action_summaries[idx].current_user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx < 0) {
|
||||
|
||||
// Add initial action when it doesn't exist.
|
||||
data.action_summaries.push({
|
||||
__typename: `${capitalize(reaction)}ActionSummary`,
|
||||
count: 0,
|
||||
current_user: null
|
||||
});
|
||||
idx = data.action_summaries.length - 1;
|
||||
}
|
||||
|
||||
data.action_summaries[idx] = {
|
||||
...data.action_summaries[idx],
|
||||
count: data.action_summaries[idx].count + 1,
|
||||
current_user: mutationResult.data[
|
||||
`create${capitalize(reaction)}`
|
||||
][reaction]
|
||||
};
|
||||
|
||||
// Write our data back to the cache.
|
||||
proxy.writeFragment({
|
||||
fragment: COMMENT_FRAGMENT,
|
||||
id: fragmentId,
|
||||
data
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({showSignInDialog}, dispatch);
|
||||
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment ${capitalize(reaction)}Button_root on RootQuery {
|
||||
me {
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment ${capitalize(reaction)}Button_comment on Comment {
|
||||
action_summaries {
|
||||
... on ${capitalize(reaction)}ActionSummary {
|
||||
count
|
||||
current_user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
}),
|
||||
connect(null, mapDispatchToProps),
|
||||
withDeleteReaction,
|
||||
withPostReaction
|
||||
);
|
||||
|
||||
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
|
||||
|
||||
return enhance(WithReactions);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import getNetworkInterface from './transport';
|
||||
import {networkInterface} from './transport';
|
||||
|
||||
// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
|
||||
|
||||
@@ -11,8 +11,6 @@ import getNetworkInterface from './transport';
|
||||
// getNetworkInterface(),
|
||||
// wsClient,
|
||||
// );
|
||||
const networkInterface = getNetworkInterface();
|
||||
|
||||
export const client = new ApolloClient({
|
||||
connectToDevTools: true,
|
||||
queryTransformer: addTypename,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import {getDefinitionName, mergeDocuments} from 'coral-framework/utils';
|
||||
import {getGraphQLExtensions} from 'coral-framework/helpers/plugins';
|
||||
import globalFragments from 'coral-framework/graphql/fragments';
|
||||
import uniq from 'lodash/uniq';
|
||||
|
||||
const fragments = {};
|
||||
const mutationOptions = {};
|
||||
const queryOptions = {};
|
||||
|
||||
const getTypeName = (ast) => ast.definitions[0].typeCondition.name.value;
|
||||
|
||||
/**
|
||||
* Add fragment
|
||||
*
|
||||
* Example:
|
||||
* addFragment('MyFragment', gql`
|
||||
* fragment Plugin_MyFragment on Comment {
|
||||
* body
|
||||
* }
|
||||
* `);
|
||||
*/
|
||||
export function addFragment(key, document) {
|
||||
const type = getTypeName(document);
|
||||
const name = getDefinitionName(document);
|
||||
if (!(key in fragments)) {
|
||||
fragments[key] = {type, names: [name], documents: [document]};
|
||||
} else {
|
||||
if (type !== fragments[key].type) {
|
||||
console.error(`Type mismatch ${type} !== ${fragments[key].type}`);
|
||||
}
|
||||
fragments[key].names.push(name);
|
||||
fragments[key].documents.push(document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mutation options.
|
||||
*
|
||||
* Example:
|
||||
* // state is the current redux state, which is sometimes
|
||||
* // necessary to fill the optimistic response.
|
||||
* addMutationOptions('PostComment', ({variables, state}) => ({
|
||||
* optimisticResponse: {
|
||||
* CreateComment: {
|
||||
* extra: '',
|
||||
* },
|
||||
* },
|
||||
* refetchQueries: [],
|
||||
* updateQueries: {
|
||||
* EmbedQuery: (previous, data) => {
|
||||
* return previous;
|
||||
* },
|
||||
* },
|
||||
* update: (proxy, result) => {
|
||||
* },
|
||||
* })
|
||||
*/
|
||||
export function addMutationOptions(key, config) {
|
||||
if (!(key in mutationOptions)) {
|
||||
mutationOptions[key] = [config];
|
||||
} else {
|
||||
mutationOptions[key].push(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add query options.
|
||||
*
|
||||
* Example:
|
||||
* addQueryOptions('EmbedQuery', {
|
||||
* reducer: (previousResult, action, variables) => previousResult,
|
||||
* });
|
||||
*/
|
||||
export function addQueryOptions(key, config) {
|
||||
if (!(key in queryOptions)) {
|
||||
queryOptions[key] = [config];
|
||||
} else {
|
||||
queryOptions[key].push(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all fragments, mutation options, and query options defined in the object.
|
||||
*
|
||||
* Example:
|
||||
* add({
|
||||
* fragments: {
|
||||
* CreateCommentResponse: gql`
|
||||
* fragment CoralRandomEmoji_CreateCommentResponse on CreateCommentResponse {
|
||||
* [...]
|
||||
* }`,
|
||||
* },
|
||||
* mutations: {
|
||||
* // state is the current redux state, which is sometimes
|
||||
* // necessary to fill the optimistic response.
|
||||
* PostComment: ({variables, state}) => ({
|
||||
* optimisticResponse: {
|
||||
* [...]
|
||||
* },
|
||||
* refetchQueries: [],
|
||||
* updateQueries: {
|
||||
* EmbedQuery: (previous, data) => {
|
||||
* return previous;
|
||||
* },
|
||||
* },
|
||||
* update: (proxy, result) => {
|
||||
* },
|
||||
* })
|
||||
* },
|
||||
* queries: {
|
||||
* EmbedQuery: {
|
||||
* reducer: (previousResult, action, variables) => {
|
||||
* return previousResult;
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function add(extension) {
|
||||
Object.keys(extension.fragments || []).forEach(key => addFragment(key, extension.fragments[key]));
|
||||
Object.keys(extension.mutations || []).forEach(key => addMutationOptions(key, extension.mutations[key]));
|
||||
Object.keys(extension.queries || []).forEach(key => addQueryOptions(key, extension.queries[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of mutation options.
|
||||
*/
|
||||
export function getMutationOptions(key) {
|
||||
init();
|
||||
return mutationOptions[key] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of query options.
|
||||
*/
|
||||
export function getQueryOptions(key) {
|
||||
init();
|
||||
return queryOptions[key] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a document with a fragment named `key`, which contains
|
||||
* all fragments added under this key.
|
||||
*/
|
||||
export function getFragmentDocument(key) {
|
||||
init();
|
||||
|
||||
if (!(key in fragments)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let documents = fragments[key] ? fragments[key].documents : [];
|
||||
let fields = fragments[key] ? `...${fragments[key].names.join('\n...')}\n` : ' __typename';
|
||||
|
||||
// Assemble arguments for `gql` to call it directly without using template literals.
|
||||
const main = `
|
||||
fragment ${key} on ${fragments[key].type} {
|
||||
${fields}
|
||||
}
|
||||
`;
|
||||
return mergeDocuments([main, ...documents]);
|
||||
}
|
||||
|
||||
// The fragments and configs are lazily loaded to allow circular dependencies to work.
|
||||
// TODO: We might want to change this to an explicit add after we have lazy Queries and Mutations.
|
||||
let initialized = false;
|
||||
|
||||
function init() {
|
||||
if (initialized) { return; }
|
||||
initialized = true;
|
||||
|
||||
// Add fragments from framework.
|
||||
[globalFragments].forEach(map =>
|
||||
Object.keys(map).forEach(key => addFragment(key, map[key]))
|
||||
);
|
||||
|
||||
// Add configs from plugins.
|
||||
getGraphQLExtensions().forEach(ext => add(ext));
|
||||
}
|
||||
|
||||
export function resolveFragments(document) {
|
||||
if (document.loc.source) {
|
||||
|
||||
// resolve fragments from registry
|
||||
const matchedSubFragments = document.loc.source.body.match(/\.\.\.(.*)/g) || [];
|
||||
const subFragments =
|
||||
uniq(matchedSubFragments.map(f => f.replace('...', '')))
|
||||
.map(key => getFragmentDocument(key))
|
||||
.filter(i => i);
|
||||
|
||||
if (subFragments.length > 0) {
|
||||
return mergeDocuments([document, ...subFragments]);
|
||||
}
|
||||
} else {
|
||||
console.warn('Can only resolve fragments from documents definied using the gql tag.');
|
||||
}
|
||||
return document;
|
||||
}
|
||||
@@ -1,11 +1,31 @@
|
||||
import {createNetworkInterface} from 'apollo-client';
|
||||
import * as Storage from '../helpers/storage';
|
||||
|
||||
export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
|
||||
return new createNetworkInterface({
|
||||
uri: apiUrl,
|
||||
opts: {
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
//==============================================================================
|
||||
// NETWORK INTERFACE
|
||||
//==============================================================================
|
||||
|
||||
const networkInterface = createNetworkInterface({
|
||||
uri: '/api/v1/graph/ql',
|
||||
opts: {
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// MIDDLEWARES
|
||||
//==============================================================================
|
||||
|
||||
networkInterface.use([{
|
||||
applyMiddleware(req, next) {
|
||||
if (!req.options.headers) {
|
||||
req.options.headers = {}; // Create the header object if needed.
|
||||
}
|
||||
req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`;
|
||||
next();
|
||||
}
|
||||
}]);
|
||||
|
||||
export {
|
||||
networkInterface
|
||||
};
|
||||
|
||||
@@ -22,9 +22,21 @@
|
||||
"comment": "comment",
|
||||
"comments": "comments",
|
||||
"commentIsIgnored": "This comment is hidden because you ignored this user.",
|
||||
"editComment": {
|
||||
"bodyInputLabel": "Edit this comment",
|
||||
"saveButton": "Save changes",
|
||||
"editWindowExpired": "You can no longer edit this comment. The time window to do so has expired. Why not post another one?",
|
||||
"editWindowExpiredClose": "Close",
|
||||
"editWindowTimerPrefix": "Edit Window: ",
|
||||
"second": "second",
|
||||
"secondsPlural": "seconds",
|
||||
"unexpectedError": "Unexpected error while saving changes. Sorry!"
|
||||
},
|
||||
"error": {
|
||||
"editWindowExpired": "You can no longer edit this comment. The time window to do so has expired.",
|
||||
"emailNotVerified": "Email address {0} not verified.",
|
||||
"email": "Not a valid E-Mail",
|
||||
"networkError": "Failed to connect to server. Check your internet connection and try again.",
|
||||
"password": "Password must be at least 8 characters",
|
||||
"username": "Usernames can contain letters, numbers and _ only",
|
||||
"confirmPassword": "Passwords don't match. Please, check again",
|
||||
@@ -61,9 +73,21 @@
|
||||
"comments": "commentarios",
|
||||
"commentIsIgnored": "Este comentario está escondido porque has ignorado al usuario.",
|
||||
"showAllComments": "Mostrar todos los comentarios",
|
||||
"editComment": {
|
||||
"bodyInputLabel": "Editar este comentario",
|
||||
"saveButton": "Guardar cambios",
|
||||
"editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado. ¿Por qué no publicar otro?",
|
||||
"editWindowExpiredClose": "Cerca",
|
||||
"editWindowTimerPrefix": "Ventana de edición: ",
|
||||
"second": "segundo",
|
||||
"secondsPlural": "segundos",
|
||||
"unexpectedError": "Unexpected error while saving changes. Sorry!"
|
||||
},
|
||||
"error": {
|
||||
"editWindowExpired": "Ya no puedes editar este comentario. La ventana de tiempo para hacerlo ha caducado.",
|
||||
"emailNotVerified": "E-mail {0} no verificado.",
|
||||
"email": "No es un e-mail válido",
|
||||
"networkError": "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo.",
|
||||
"password": "La contraseña debe tener por lo menos 8 caracteres",
|
||||
"username": "Los nombres pueden contener letras, números y _",
|
||||
"organizationName": "El nombre de la organización debe contener letras y/o números.",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {gql} from 'react-apollo';
|
||||
|
||||
export const getTotalActionCount = (type, comment) => {
|
||||
return comment.action_summaries
|
||||
.filter(s => s.__typename === type)
|
||||
@@ -61,3 +63,11 @@ export function separateDataAndRoot(
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeDocuments(documents) {
|
||||
const main = typeof documents[0] === 'string' ? documents[0] : documents[0].loc.source.body;
|
||||
const substitutions = documents.slice(1);
|
||||
const literals = [main, ...substitutions.map(() => '\n')];
|
||||
return gql.apply(null, [literals, ...substitutions]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {withReaction} from '../coral-framework/hocs';
|
||||
@@ -1,31 +1,49 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import {connect} from 'react-redux';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import {connect} from 'react-redux';
|
||||
import {CommentForm} from './CommentForm';
|
||||
|
||||
const name = 'coral-plugin-commentbox';
|
||||
export const name = 'coral-plugin-commentbox';
|
||||
|
||||
// Given a newly posted comment's status, show a notification to the user
|
||||
// if needed
|
||||
export const notifyForNewCommentStatus = (addNotification, status) => {
|
||||
if (status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
} else if (status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Container for posting a new Comment
|
||||
*/
|
||||
class CommentBox extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
username: '',
|
||||
body: '',
|
||||
|
||||
// incremented on successful post to clear form
|
||||
postedCount: 0,
|
||||
hooks: {
|
||||
preSubmit: [],
|
||||
postSubmit: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
postComment = () => {
|
||||
static get defaultProps() {
|
||||
return {
|
||||
setCommentCountCache: () => {}
|
||||
};
|
||||
}
|
||||
postComment = ({body}) => {
|
||||
const {
|
||||
commentPostedHandler,
|
||||
postItem,
|
||||
postComment,
|
||||
setCommentCountCache,
|
||||
commentCountCache,
|
||||
isReply,
|
||||
@@ -37,7 +55,7 @@ class CommentBox extends React.Component {
|
||||
let comment = {
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
body: this.state.body,
|
||||
body,
|
||||
...this.props.commentBox
|
||||
};
|
||||
|
||||
@@ -46,18 +64,18 @@ class CommentBox extends React.Component {
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach(hook => hook());
|
||||
|
||||
postItem(comment, 'comments')
|
||||
postComment(comment, 'comments')
|
||||
.then(({data}) => {
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
this.state.hooks.postSubmit.forEach(hook => hook(data));
|
||||
|
||||
notifyForNewCommentStatus(addNotification, postedComment.status);
|
||||
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
} else if (postedComment.status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
}
|
||||
|
||||
@@ -69,7 +87,8 @@ class CommentBox extends React.Component {
|
||||
console.error(err);
|
||||
!isReply && setCommentCountCache(commentCountCache);
|
||||
});
|
||||
this.setState({body: ''});
|
||||
|
||||
this.setState({postedCount: this.state.postedCount + 1});
|
||||
}
|
||||
|
||||
registerHook = (hookType = '', hook = () => {}) => {
|
||||
@@ -126,79 +145,50 @@ class CommentBox extends React.Component {
|
||||
const {styles, isReply, authorId, maxCharCount} = this.props;
|
||||
let {cancelButtonClicked} = this.props;
|
||||
|
||||
const length = this.state.body.length;
|
||||
const enablePostComment = !length || (maxCharCount && length > maxCharCount);
|
||||
|
||||
if (isReply && typeof cancelButtonClicked !== 'function') {
|
||||
console.warn('the CommentBox component should have a cancelButtonClicked callback defined if it lives in a Reply');
|
||||
cancelButtonClicked = () => {};
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div
|
||||
className={`${name}-container`}>
|
||||
<label
|
||||
htmlFor={ isReply ? 'replyText' : 'commentText'}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}>
|
||||
{isReply ? lang.t('reply') : lang.t('comment')}
|
||||
</label>
|
||||
<textarea
|
||||
className={`${name}-textarea`}
|
||||
style={styles && styles.textarea}
|
||||
value={this.state.body}
|
||||
placeholder={lang.t('comment')}
|
||||
id={isReply ? 'replyText' : 'commentText'}
|
||||
onChange={this.handleChange}
|
||||
rows={3}/>
|
||||
<Slot fill='commentInputArea' />
|
||||
</div>
|
||||
<div className={`${name}-char-count ${length > maxCharCount ? `${name}-char-max` : ''}`}>
|
||||
{maxCharCount && `${maxCharCount - length} ${lang.t('characters-remaining')}`}
|
||||
</div>
|
||||
<div className={`${name}-button-container`}>
|
||||
<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
inline
|
||||
/>
|
||||
{
|
||||
isReply && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={`${name}-cancel-button`}
|
||||
onClick={() => cancelButtonClicked('')}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{ authorId && (
|
||||
<Button
|
||||
cStyle={enablePostComment ? 'lightGrey' : 'darkGrey'}
|
||||
className={`${name}-button`}
|
||||
onClick={this.postComment}
|
||||
disabled={enablePostComment ? 'disabled' : ''}>
|
||||
{lang.t('post')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<CommentForm
|
||||
styles={styles}
|
||||
key={this.state.postedCount}
|
||||
defaultValue={this.props.defaultValue}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
bodyLabel={isReply ? lang.t('reply') : lang.t('comment')}
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
bodyPlaceholder={lang.t('comment')}
|
||||
bodyInputId={isReply ? 'replyText' : 'commentText'}
|
||||
saveComment={authorId && this.postComment}
|
||||
buttonContainerStart={<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
inline
|
||||
/>}
|
||||
cancelButtonClicked={cancelButtonClicked}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
CommentBox.propTypes = {
|
||||
|
||||
// Initial value for underlying comment body textarea
|
||||
defaultValue: PropTypes.string,
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
commentPostedHandler: PropTypes.func,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
isReply: PropTypes.bool.isRequired,
|
||||
canPost: PropTypes.bool,
|
||||
setCommentCountCache: PropTypes.func,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({commentBox}) => ({commentBox});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import classnames from 'classnames';
|
||||
import {I18n} from '../coral-framework';
|
||||
import translations from './translations.json';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
|
||||
import {name} from './CommentBox';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Common UI for Creating or Editing a Comment
|
||||
*/
|
||||
export class CommentForm extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// Initial value for underlying comment body textarea
|
||||
defaultValue: PropTypes.string,
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
cancelButtonClicked: PropTypes.func,
|
||||
|
||||
// Save the comment in the form.
|
||||
// Will be passed { body: String }
|
||||
saveComment: PropTypes.func.isRequired,
|
||||
|
||||
// DOM ID for form input that edits comment body
|
||||
bodyInputId: PropTypes.string,
|
||||
|
||||
// screen reader label for input that edits comment body
|
||||
bodyLabel: PropTypes.string,
|
||||
|
||||
// Placeholder for input that edits comment body
|
||||
bodyPlaceholder: PropTypes.string,
|
||||
|
||||
// render at start of button container (useful for extra buttons)
|
||||
buttonContainerStart: PropTypes.node,
|
||||
|
||||
// render inside submit button
|
||||
submitText: PropTypes.node,
|
||||
|
||||
styles: PropTypes.shape({
|
||||
textarea: PropTypes.string
|
||||
}),
|
||||
|
||||
// cStyle for enabled save <coral-ui/Button>
|
||||
saveButtonCStyle: PropTypes.string,
|
||||
|
||||
// return whether the save button should be enabled for the provided
|
||||
// comment ({ body }) (for reasons other than charCount)
|
||||
saveCommentEnabled: PropTypes.func,
|
||||
|
||||
// className to add to buttons
|
||||
buttonClass: PropTypes.string,
|
||||
}
|
||||
static get defaultProps() {
|
||||
return {
|
||||
bodyLabel: lang.t('comment'),
|
||||
bodyPlaceholder: lang.t('comment'),
|
||||
submitText: lang.t('post'),
|
||||
saveButtonCStyle: 'darkGrey',
|
||||
saveCommentEnabled: () => true,
|
||||
};
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onBodyChange = this.onBodyChange.bind(this);
|
||||
this.onClickSubmit = this.onClickSubmit.bind(this);
|
||||
this.state = {
|
||||
body: props.defaultValue || ''
|
||||
};
|
||||
}
|
||||
onBodyChange(e) {
|
||||
this.setState({body: e.target.value});
|
||||
}
|
||||
onClickSubmit(e) {
|
||||
e.preventDefault();
|
||||
const {saveComment} = this.props;
|
||||
const {body} = this.state;
|
||||
saveComment({body});
|
||||
}
|
||||
render() {
|
||||
const {maxCharCount, styles, saveCommentEnabled, buttonClass, charCountEnable} = this.props;
|
||||
|
||||
const body = this.state.body;
|
||||
const length = body.length;
|
||||
const isRespectingMaxCount = (length) => charCountEnable && maxCharCount && length > maxCharCount;
|
||||
const disablePostComment = !length || isRespectingMaxCount(length) || !saveCommentEnabled({body});
|
||||
|
||||
return <div>
|
||||
<div className={`${name}-container`}>
|
||||
<label
|
||||
htmlFor={this.props.bodyInputId}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}>
|
||||
{this.props.bodyLabel}
|
||||
</label>
|
||||
<textarea
|
||||
style={styles && styles.textarea}
|
||||
className={`${name}-textarea`}
|
||||
value={this.state.body}
|
||||
placeholder={this.props.bodyPlaceholder}
|
||||
id={this.props.bodyInputId}
|
||||
onChange={this.onBodyChange}
|
||||
rows={3}/>
|
||||
<Slot fill='commentInputArea' />
|
||||
</div>
|
||||
{
|
||||
this.props.charCountEnable &&
|
||||
<div className={`${name}-char-count ${length > maxCharCount ? `${name}-char-max` : ''}`}>
|
||||
{maxCharCount && `${maxCharCount - length} ${lang.t('characters-remaining')}`}
|
||||
</div>
|
||||
}
|
||||
<div className={`${name}-button-container`}>
|
||||
{ this.props.buttonContainerStart }
|
||||
{
|
||||
typeof this.props.cancelButtonClicked === 'function' && (
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
className={classnames(`${name}-cancel-button`, buttonClass)}
|
||||
onClick={this.props.cancelButtonClicked}>
|
||||
{lang.t('cancel')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
cStyle={disablePostComment ? 'lightGrey' : this.props.saveButtonCStyle}
|
||||
className={classnames(`${name}-button`, buttonClass)}
|
||||
onClick={this.onClickSubmit}
|
||||
disabled={disablePostComment ? 'disabled' : ''}>
|
||||
{this.props.submitText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@ class FlagButton extends Component {
|
||||
message: '',
|
||||
step: 0,
|
||||
posted: false,
|
||||
localPost: null,
|
||||
localDelete: false
|
||||
localPost: null
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
@@ -27,17 +26,12 @@ class FlagButton extends Component {
|
||||
|
||||
// When the "report" button is clicked expand the menu
|
||||
onReportClick = () => {
|
||||
const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const localFlagged = (flaggedByCurrentUser && !localDelete) || localPost;
|
||||
const {currentUser} = this.props;
|
||||
if (!currentUser) {
|
||||
this.props.showSignInDialog();
|
||||
return;
|
||||
}
|
||||
if (localFlagged) {
|
||||
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
|
||||
deleteAction(localPost || flag.current_user.id);
|
||||
} else if (this.state.showMenu){
|
||||
if (this.state.showMenu) {
|
||||
this.closeMenu();
|
||||
} else {
|
||||
this.setState({showMenu: true});
|
||||
@@ -136,14 +130,14 @@ class FlagButton extends Component {
|
||||
|
||||
render () {
|
||||
const {getPopupMenu, flaggedByCurrentUser} = this.props;
|
||||
const {localPost, localDelete} = this.state;
|
||||
const flagged = (flaggedByCurrentUser && !localDelete) || localPost;
|
||||
const {localPost} = this.state;
|
||||
const flagged = flaggedByCurrentUser || localPost;
|
||||
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
<button
|
||||
ref={ref => this.flagButton = ref}
|
||||
onClick={!this.props.banned ? this.onReportClick : null}
|
||||
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
|
||||
className={`${name}-button`}>
|
||||
{
|
||||
flagged
|
||||
|
||||
@@ -12,7 +12,7 @@ class ReplyBox extends Component {
|
||||
render() {
|
||||
const {
|
||||
styles,
|
||||
postItem,
|
||||
postComment,
|
||||
assetId,
|
||||
authorId,
|
||||
addNotification,
|
||||
@@ -32,7 +32,7 @@ class ReplyBox extends Component {
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
assetId={assetId}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
isReply={true} />
|
||||
</div>;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ ReplyBox.propTypes = {
|
||||
parentId: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
authorId: PropTypes.string.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
assetId: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import {compose, graphql, gql} from 'react-apollo';
|
||||
import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import {myCommentHistory, myIgnoredUsers} from 'coral-framework/graphql/queries';
|
||||
import {stopIgnoringUser} from 'coral-framework/graphql/mutations';
|
||||
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {link} from 'coral-framework/services/PymConnection';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
@@ -18,76 +17,110 @@ import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activeTab: 0,
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.handleTabChange = this.handleTabChange.bind(this);
|
||||
this.state = {
|
||||
activeTab: 0
|
||||
};
|
||||
}
|
||||
|
||||
handleTabChange(tab) {
|
||||
handleTabChange = tab => {
|
||||
this.setState({
|
||||
activeTab: tab
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props;
|
||||
const {me} = this.props.data;
|
||||
const {
|
||||
auth,
|
||||
data,
|
||||
asset,
|
||||
showSignInDialog,
|
||||
stopIgnoringUser,
|
||||
myIgnoredUsersData
|
||||
} = this.props;
|
||||
|
||||
if (data.loading) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
const {me} = data;
|
||||
|
||||
if (!me) {
|
||||
if (!auth.loggedIn) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
const localProfile = this.props.user.profiles.find(p => p.provider === 'local');
|
||||
if (data.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const localProfile = this.props.user.profiles.find(
|
||||
p => p.provider === 'local'
|
||||
);
|
||||
|
||||
const emailAddress = localProfile && localProfile.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{this.props.user.username}</h2>
|
||||
{ emailAddress
|
||||
? <p>{ emailAddress }</p>
|
||||
: null
|
||||
}
|
||||
{emailAddress ? <p>{emailAddress}</p> : null}
|
||||
|
||||
{
|
||||
myIgnoredUsersData.myIgnoredUsers && myIgnoredUsersData.myIgnoredUsers.length
|
||||
? (
|
||||
<div>
|
||||
{myIgnoredUsersData.myIgnoredUsers &&
|
||||
myIgnoredUsersData.myIgnoredUsers.length
|
||||
? <div>
|
||||
<h3>Ignored users</h3>
|
||||
<IgnoredUsers
|
||||
users={myIgnoredUsersData.myIgnoredUsers}
|
||||
stopIgnoring={stopIgnoringUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
: null}
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>My comments</h3>
|
||||
{
|
||||
me.comments.length ?
|
||||
<CommentHistory
|
||||
comments={me.comments}
|
||||
asset={asset}
|
||||
link={link}
|
||||
/>
|
||||
:
|
||||
<p>{lang.t('userNoComment')}</p>
|
||||
}
|
||||
{me.comments.length
|
||||
? <CommentHistory comments={me.comments} asset={asset} link={link} />
|
||||
: <p>{lang.t('userNoComment')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: These currently relies on refetching (see ignoreUser and stopIgnoringUser mutations).
|
||||
//
|
||||
const withMyIgnoredUsersQuery = graphql(
|
||||
gql`
|
||||
query myIgnoredUsers {
|
||||
myIgnoredUsers {
|
||||
id,
|
||||
username,
|
||||
}
|
||||
}`,
|
||||
{
|
||||
props: ({data}) => {
|
||||
return {
|
||||
myIgnoredUsersData: data
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const withMyCommentHistoryQuery = graphql(
|
||||
gql`
|
||||
query myCommentHistory {
|
||||
me {
|
||||
comments {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
user: state.user.toJS(),
|
||||
asset: state.asset.toJS(),
|
||||
@@ -99,7 +132,7 @@ const mapDispatchToProps = dispatch =>
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
myCommentHistory,
|
||||
myIgnoredUsers,
|
||||
stopIgnoringUser,
|
||||
withMyCommentHistoryQuery,
|
||||
withMyIgnoredUsersQuery,
|
||||
withStopIgnoringUser
|
||||
)(ProfileContainer);
|
||||
|
||||
@@ -3,9 +3,10 @@ We can build plugins to extend the functionality of Talk.
|
||||
|
||||
This guide is a walkthrough of our plugin architecture and components that we provide that allow you to build on top of Core coral components without having to understand the concepts there in. It is organized into three sections:
|
||||
|
||||
* Plugin architecture
|
||||
* [Plugin Architecture](#plugin-architecture)
|
||||
* Using our building block components
|
||||
* Styling
|
||||
* [Reactions](#reactions)
|
||||
* [Styling](#styling-plugins)
|
||||
|
||||
Advanced users will quickly realize that our plugins have complete access to core code. If you would like to write advanced plugins that reach outside of our published API as described in this document, please see [our notes on experimental pluginss](PLUGINS-experimental.md).
|
||||
|
||||
@@ -97,12 +98,77 @@ Slots properties take an`Array` so we can add as many components as we want.
|
||||
|
||||
In order to allow you to build more complex plugins, we have wrapped some of our functionality in higher order components that expose a simple api.
|
||||
|
||||
### Reactions
|
||||
## Reactions
|
||||
|
||||
Reactions provide users the ability to 'like', 'respect', etc... comments.
|
||||
|
||||
Note: some server side work will need to accompany this client side component. See the like and respect plugins as examples.
|
||||
|
||||
### Building Reactions
|
||||
|
||||
#### Our `client/index.js` :
|
||||
|
||||
```js
|
||||
import LoveButton from './LoveButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [LoveButton]
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
In this example we add our reaction component to the `commentReaction` Slot
|
||||
|
||||
#### Our Reaction component:
|
||||
|
||||
```js
|
||||
import React from 'react';
|
||||
import {withReaction} from 'coral-plugin-api';
|
||||
|
||||
class LoveButton extends React.Component {
|
||||
handleClick = () => {
|
||||
const {
|
||||
postReaction,
|
||||
deleteReaction,
|
||||
alreadyReacted
|
||||
} = this.props;
|
||||
|
||||
if (alreadyReacted()) {
|
||||
deleteReaction();
|
||||
} else {
|
||||
postReaction();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {count} = this.props;
|
||||
return (
|
||||
<button onClick={this.handleClick}>
|
||||
<span>Love</span>
|
||||
<span>{count > 0 && count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withReaction('love')(LoveButton);
|
||||
```
|
||||
|
||||
|
||||
This feature introduces `withReaction` HOC. `withReaction` takes, as argument, a reaction string and it allows our component to receive specific props for handling reactions.
|
||||
|
||||
* `postReaction` - Posts the reaction
|
||||
|
||||
* `deleteReaction` - Removes the reaction
|
||||
|
||||
* `alreadyReacted` - A function that returns a boolean.
|
||||
|
||||
* `count` - The reaction count
|
||||
|
||||
|
||||
For full reference: Please, check `coral-plugin-love`: [LoveButton.js](https://github.com/coralproject/talk/blob/master/plugins/coral-plugin-love/client/LoveButton.js)
|
||||
|
||||
### Comment Stream
|
||||
|
||||
Comment streams may be created with filtering and ordering in place:
|
||||
@@ -170,6 +236,34 @@ Our `style.css` should could look like this.
|
||||
}
|
||||
```
|
||||
|
||||
## Plugin Hooks
|
||||
The plugins injected in the CommentBox such as `commentInputDetailArea` will inherit through props tools for handling hooks.
|
||||
|
||||
### Available hook types:
|
||||
`preSubmit` : To perform actions before submitting the comment.
|
||||
`postSubmit` : To perform actions after submitting the comment.
|
||||
|
||||
### Register Hooks
|
||||
`registerHook` is a function that takes: the hook type, a hook function and returns the hook data.
|
||||
|
||||
#### Usage:
|
||||
```js
|
||||
this.addCommentTagHook = this.props.registerHook('postSubmit', (data) => {
|
||||
const {comment} = data.createComment;
|
||||
this.props.addCommentTag({
|
||||
id: comment.id,
|
||||
tag: 'OFF_TOPIC'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Unregister Hooks
|
||||
|
||||
`unregisterHook` will remove the hook.
|
||||
|
||||
```js
|
||||
this.props.unregisterHook(this.addCommentTagHook);
|
||||
```
|
||||
|
||||
### The server folder and the index file
|
||||
Read more about the `/server` and how to extend Talk here.
|
||||
|
||||
@@ -153,6 +153,12 @@ const ErrLoginAttemptMaximumExceeded = new APIError('You have made too many inco
|
||||
status: 429
|
||||
});
|
||||
|
||||
class ErrEditWindowHasEnded extends APIError {
|
||||
constructor(message) {
|
||||
super(message || 'Edit window is over.', {status: 403, translation_key: 'error.editWindowExpired'});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ExtendableError,
|
||||
APIError,
|
||||
@@ -174,5 +180,6 @@ module.exports = {
|
||||
ErrPermissionUpdateUsername,
|
||||
ErrSettingsInit,
|
||||
ErrInstallLock,
|
||||
ErrLoginAttemptMaximumExceeded
|
||||
ErrLoginAttemptMaximumExceeded,
|
||||
ErrEditWindowHasEnded,
|
||||
};
|
||||
|
||||
+49
-11
@@ -61,11 +61,11 @@ const createComment = ({user, loaders: {Comments}, pubsub}, {body, asset_id, par
|
||||
|
||||
/**
|
||||
* Filters the comment object and outputs wordlist results.
|
||||
* @param {Object} context graphql context
|
||||
* @param {String} body body of a comment
|
||||
* @param {String} body body of a comment
|
||||
* @param {String} [asset_id] id of asset comment is posted on
|
||||
* @return {Object} resolves to the wordlist results
|
||||
*/
|
||||
const filterNewComment = (context, {body, asset_id}) => {
|
||||
const filterNewComment = ({body, asset_id}) => {
|
||||
|
||||
// Create a new instance of the Wordlist.
|
||||
const wl = new Wordlist();
|
||||
@@ -73,20 +73,19 @@ const filterNewComment = (context, {body, asset_id}) => {
|
||||
// Load the wordlist and filter the comment content.
|
||||
return Promise.all([
|
||||
wl.load().then(() => wl.scan('body', body)),
|
||||
AssetsService.rectifySettings(AssetsService.findById(asset_id))
|
||||
asset_id && AssetsService.rectifySettings(AssetsService.findById(asset_id))
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* This resolves a given comment's status to take into account moderator actions
|
||||
* are applied.
|
||||
* @param {Object} context graphql context
|
||||
* @param {String} body body of the comment
|
||||
* @param {String} asset_id asset for the comment
|
||||
* @param {String} [asset_id] asset for the comment
|
||||
* @param {Object} [wordlist={}] the results of the wordlist scan
|
||||
* @return {Promise} resolves to the comment's status
|
||||
*/
|
||||
const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, settings) => {
|
||||
const resolveNewCommentStatus = ({asset_id, body}, wordlist = {}, settings = {}) => {
|
||||
|
||||
// Decide the status based on whether or not the current asset/settings
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
@@ -98,7 +97,7 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, setti
|
||||
status = Promise.resolve('REJECTED');
|
||||
} else if (settings.premodLinksEnable && linkify.test(body)) {
|
||||
status = Promise.resolve('PREMOD');
|
||||
} else {
|
||||
} else if (asset_id) {
|
||||
status = AssetsService
|
||||
.rectifySettings(AssetsService.findById(asset_id).then((asset) => {
|
||||
if (!asset) {
|
||||
@@ -125,6 +124,8 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, setti
|
||||
}
|
||||
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
|
||||
});
|
||||
} else {
|
||||
status = 'NONE';
|
||||
}
|
||||
|
||||
return status;
|
||||
@@ -142,12 +143,12 @@ const createPublicComment = (context, commentInput) => {
|
||||
|
||||
// First we filter the comment contents to ensure that we note any validation
|
||||
// issues.
|
||||
return filterNewComment(context, commentInput)
|
||||
return filterNewComment(commentInput)
|
||||
|
||||
// We then take the wordlist and the comment into consideration when
|
||||
// considering what status to assign the new comment, and resolve the new
|
||||
// status to set the comment to.
|
||||
.then(([wordlist, settings]) => resolveNewCommentStatus(context, commentInput, wordlist, settings)
|
||||
.then(([wordlist, settings]) => resolveNewCommentStatus(commentInput, wordlist, settings)
|
||||
|
||||
// Then we actually create the comment with the new status.
|
||||
.then((status) => createComment(context, commentInput, status))
|
||||
@@ -219,13 +220,45 @@ const addCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
|
||||
|
||||
/**
|
||||
* Removes a tag from a Comment
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {String} tag name of the tag
|
||||
*/
|
||||
const removeCommentTag = ({user, loaders: {Comments}}, {id, tag}) => {
|
||||
return CommentsService.removeTag(id, tag);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit a Comment
|
||||
* @param {String} id identifier of the comment (uuid)
|
||||
* @param {Object} edit describes how to edit the comment
|
||||
* @param {String} edit.body the new Comment body
|
||||
*/
|
||||
const editComment = async ({user, loaders: {Comments}}, {id, asset_id, edit}) => {
|
||||
const {body} = edit;
|
||||
const determineStatusForComment = async ({body, asset_id}) => {
|
||||
const [wordlist, settings] = await filterNewComment({asset_id, body});
|
||||
const status = await resolveNewCommentStatus({asset_id, body}, wordlist, settings);
|
||||
return status;
|
||||
};
|
||||
const status = await determineStatusForComment({body, asset_id});
|
||||
try {
|
||||
await CommentsService.edit(id, asset_id, user.id, Object.assign({status}, edit));
|
||||
} catch (error) {
|
||||
switch (error.name) {
|
||||
case 'CommentNotFound':
|
||||
throw new errors.APIError('Comment not found', {
|
||||
status: 404,
|
||||
translation_key: 'NOT_FOUND',
|
||||
});
|
||||
case 'NotAuthorizedToEdit':
|
||||
throw errors.ErrNotAuthorized;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return {status};
|
||||
};
|
||||
|
||||
module.exports = (context) => {
|
||||
let mutators = {
|
||||
Comment: {
|
||||
@@ -233,6 +266,7 @@ module.exports = (context) => {
|
||||
setCommentStatus: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
addCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
removeCommentTag: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
editComment: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,5 +286,9 @@ module.exports = (context) => {
|
||||
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
|
||||
}
|
||||
|
||||
if (context.user) {
|
||||
mutators.Comment.editComment = (action) => editComment(context, action);
|
||||
}
|
||||
|
||||
return mutators;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const CommentsService = require('../../services/comments');
|
||||
|
||||
const Comment = {
|
||||
parent({parent_id}, _, {loaders: {Comments}}) {
|
||||
if (parent_id == null) {
|
||||
@@ -45,6 +47,14 @@ const Comment = {
|
||||
},
|
||||
asset({asset_id}, _, {loaders: {Assets}}) {
|
||||
return Assets.getByID.load(asset_id);
|
||||
},
|
||||
editing(comment) {
|
||||
const editableUntil = CommentsService.getEditableUntilDate(comment);
|
||||
const edited = comment.body_history.length > 1;
|
||||
return {
|
||||
edited,
|
||||
editableUntil,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const RootMutation = {
|
||||
createComment(_, {comment}, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.create(comment));
|
||||
},
|
||||
editComment(_, args, {mutators: {Comment}}) {
|
||||
return wrapResponse('comment')(Comment.editComment(args));
|
||||
},
|
||||
createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) {
|
||||
return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}));
|
||||
},
|
||||
|
||||
@@ -163,6 +163,11 @@ input CommentCountQuery {
|
||||
tag: [String]
|
||||
}
|
||||
|
||||
type EditInfo {
|
||||
edited: Boolean!
|
||||
editableUntil: Date
|
||||
}
|
||||
|
||||
# Comment is the base representation of user interaction in Talk.
|
||||
type Comment {
|
||||
|
||||
@@ -204,6 +209,9 @@ type Comment {
|
||||
|
||||
# The time when the comment was created
|
||||
created_at: Date!
|
||||
|
||||
# describes how the comment can be edited
|
||||
editing: EditInfo
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -721,6 +729,23 @@ type StopIgnoringUserResponse implements Response {
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# Input to editComment mutation
|
||||
input EditCommentInput {
|
||||
# Update body of the comment
|
||||
body: String!
|
||||
}
|
||||
|
||||
type CommentInfoAfterEdit {
|
||||
# New status of the edited comment
|
||||
status: COMMENT_STATUS!
|
||||
}
|
||||
|
||||
type EditCommentResponse implements Response {
|
||||
comment: CommentInfoAfterEdit!
|
||||
# An array of errors relating to the mutation that occured.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
type RootMutation {
|
||||
|
||||
@@ -736,6 +761,9 @@ type RootMutation {
|
||||
# Delete an action based on the action id.
|
||||
deleteAction(id: ID!): DeleteActionResponse
|
||||
|
||||
# Edit a comment
|
||||
editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse
|
||||
|
||||
# Sets User status. Requires the `ADMIN` role.
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
|
||||
|
||||
|
||||
@@ -51,6 +51,23 @@ const TagSchema = new Schema({
|
||||
_id: false
|
||||
});
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
@@ -66,6 +83,7 @@ const CommentSchema = new Schema({
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [StatusSchema],
|
||||
|
||||
+12
-7
@@ -58,12 +58,14 @@
|
||||
"commander": "^2.9.0",
|
||||
"connect-redis": "^3.1.0",
|
||||
"cross-spawn": "^5.1.0",
|
||||
"csurf": "^1.9.0",
|
||||
"dataloader": "^1.3.0",
|
||||
"debug": "^2.6.3",
|
||||
"dotenv": "^4.0.0",
|
||||
"ejs": "^2.5.6",
|
||||
"env-rewrite": "^1.0.2",
|
||||
"express": "^4.15.2",
|
||||
"express-session": "^1.15.1",
|
||||
"form-data": "^2.1.2",
|
||||
"gql-merge": "^0.0.4",
|
||||
"graphql": "^0.9.1",
|
||||
@@ -73,12 +75,15 @@
|
||||
"graphql-subscriptions": "^0.3.1",
|
||||
"graphql-tools": "^0.10.1",
|
||||
"helmet": "^3.5.0",
|
||||
"immutability-helper": "^2.2.0",
|
||||
"inquirer": "^3.0.6",
|
||||
"joi": "^10.4.1",
|
||||
"jsonwebtoken": "^7.4.0",
|
||||
"jsonwebtoken": "^7.3.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"kue": "^0.11.5",
|
||||
"linkify-it": "^2.0.3",
|
||||
"lodash": "^4.16.6",
|
||||
"marked": "^0.3.6",
|
||||
"metascraper": "^1.0.6",
|
||||
"minimist": "^1.2.0",
|
||||
"mongoose": "^4.9.1",
|
||||
@@ -91,10 +96,16 @@
|
||||
"passport": "^0.3.2",
|
||||
"passport-jwt": "^2.2.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"prop-types": "^15.5.8",
|
||||
"react-apollo": "^1.1.0",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"recompose": "^0.23.1",
|
||||
"redis": "^2.7.1",
|
||||
"resolve": "^1.3.2",
|
||||
"semver": "^5.3.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"subscriptions-transport-ws": "^0.5.5-alpha.0",
|
||||
"timekeeper": "^1.0.0",
|
||||
"uuid": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -148,7 +159,6 @@
|
||||
"keymaster": "^1.6.2",
|
||||
"license-webpack-plugin": "^0.4.2",
|
||||
"material-design-lite": "^1.2.1",
|
||||
"marked": "^0.3.6",
|
||||
"mocha": "^3.1.2",
|
||||
"mocha-junit-reporter": "^1.12.1",
|
||||
"nightwatch": "^0.9.11",
|
||||
@@ -170,16 +180,11 @@
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.0",
|
||||
"react-tagsinput": "^3.14.0",
|
||||
"prop-types": "^15.5.8",
|
||||
"react-apollo": "^1.1.0",
|
||||
"react-recaptcha": "^2.2.6",
|
||||
"recompose": "^0.23.1",
|
||||
"redux": "^3.6.0",
|
||||
"redux-mock-store": "^1.2.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
"regenerator": "^0.8.46",
|
||||
"selenium-standalone": "^5.11.2",
|
||||
"simplemde": "^1.11.2",
|
||||
"style-loader": "^0.16.0",
|
||||
"subscriptions-transport-ws": "^0.5.5-alpha.0",
|
||||
"supertest": "^2.0.1",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import {I18n} from 'coral-framework';
|
||||
import translations from './translations.json';
|
||||
import {withReaction} from 'coral-plugin-api';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class LoveButton extends React.Component {
|
||||
handleClick = () => {
|
||||
const {
|
||||
postReaction,
|
||||
deleteReaction,
|
||||
showSignInDialog,
|
||||
alreadyReacted
|
||||
} = this.props;
|
||||
const {root: {me}, comment} = this.props;
|
||||
|
||||
// If the current user does not exist, trigger sign in dialog.
|
||||
if (!me) {
|
||||
showSignInDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the current user is banned, do nothing.
|
||||
if (me.status === 'BANNED') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alreadyReacted()) {
|
||||
deleteReaction();
|
||||
} else {
|
||||
postReaction();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {count, alreadyReacted} = this.props;
|
||||
return (
|
||||
<button
|
||||
className={`${styles.button} ${alreadyReacted() ? styles.loved : ''}`}
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
<span>{lang.t(alreadyReacted() ? 'loved' : 'love')}</span>
|
||||
<Icon name="favorite" />
|
||||
<span>{count > 0 && count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withReaction('love')(LoveButton);
|
||||
@@ -0,0 +1,7 @@
|
||||
import LoveButton from './LoveButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentReactions: [LoveButton]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
.respect {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button {
|
||||
color: #2a2a2a;
|
||||
margin: 5px 10px 5px 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
color: #767676;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.loved {
|
||||
color: #e52338;
|
||||
|
||||
&:hover {
|
||||
color: #e52839;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"en": {
|
||||
"love": "Love",
|
||||
"loved": "Loved"
|
||||
},
|
||||
"es": {
|
||||
"love": "Amo",
|
||||
"loved": "Amé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const {readFileSync} = require('fs');
|
||||
const path = require('path');
|
||||
const wrapResponse = require('../../graph/helpers/response');
|
||||
|
||||
module.exports = {
|
||||
typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'),
|
||||
resolvers: {
|
||||
RootMutation: {
|
||||
createLove(_, {love: {item_id, item_type}}, {mutators: {Action}}) {
|
||||
return wrapResponse('love')(Action.create({item_id, item_type, action_type: 'LOVE'}));
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
Action: {
|
||||
__resolveType: {
|
||||
post({action_type}) {
|
||||
switch (action_type) {
|
||||
case 'LOVE':
|
||||
return 'LoveAction';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ActionSummary: {
|
||||
__resolveType: {
|
||||
post({action_type}) {
|
||||
switch (action_type) {
|
||||
case 'LOVE':
|
||||
return 'LoveActionSummary';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
enum ACTION_TYPE {
|
||||
|
||||
# Represents a Love.
|
||||
LOVE
|
||||
}
|
||||
|
||||
enum ASSET_METRICS_SORT {
|
||||
|
||||
# Represents a LoveAction.
|
||||
LOVE
|
||||
}
|
||||
|
||||
input CreateLoveInput {
|
||||
|
||||
# The item's id for which we are to create a love.
|
||||
item_id: ID!
|
||||
|
||||
# The type of the item for which we are to create the love.
|
||||
item_type: ACTION_ITEM_TYPE!
|
||||
}
|
||||
|
||||
# LoveAction is used by users who "love" a specific entity.
|
||||
type LoveAction implements Action {
|
||||
|
||||
# The ID of the action.
|
||||
id: ID!
|
||||
|
||||
# The author of the action.
|
||||
user: User
|
||||
|
||||
# The time when the Action was updated.
|
||||
updated_at: Date
|
||||
|
||||
# The time when the Action was created.
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
type LoveActionSummary implements ActionSummary {
|
||||
|
||||
# The count of actions with this group.
|
||||
count: Int
|
||||
|
||||
# The current user's action.
|
||||
current_user: LoveAction
|
||||
}
|
||||
|
||||
# A summary of counts related to all the Loves on an Asset.
|
||||
type LoveAssetActionSummary implements AssetActionSummary {
|
||||
|
||||
# Number of loves associated with actionable types on this this Asset.
|
||||
actionCount: Int
|
||||
|
||||
# Number of unique actionable types that are referenced by the loves.
|
||||
actionableItemCount: Int
|
||||
}
|
||||
|
||||
type CreateLoveResponse implements Response {
|
||||
|
||||
# The love that was created.
|
||||
love: LoveAction
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
|
||||
# Creates a love on an entity.
|
||||
createLove(love: CreateLoveInput!): CreateLoveResponse
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"mocha": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import styles from './styles.css'
|
||||
|
||||
export default (props) => (
|
||||
<div className={styles.box}>
|
||||
Comment Status: {props.comment.status}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import Box from './Box';
|
||||
import {Button} from 'coral-ui'
|
||||
import styles from './styles.css';
|
||||
|
||||
export default class Footer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
show: false
|
||||
};
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
this.setState(state => ({
|
||||
show: !state.show
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const {show} = this.state;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Button cStyle="darkGrey" onClick={this.handleClick}>
|
||||
Show Comment Status
|
||||
</Button>
|
||||
{show ? <Box comment={this.props.comment} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
|
||||
.box {
|
||||
font-size: 12px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Container from './components/Container';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
adminCommentDetailArea: [Container],
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
const {readFileSync} = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {};
|
||||
@@ -1,15 +1,15 @@
|
||||
import {compose, gql, graphql} from 'react-apollo';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import get from 'lodash/get';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {withFragments, withMutation} from 'coral-framework/hocs';
|
||||
import {showSignInDialog} from 'coral-framework/actions/auth';
|
||||
import RespectButton from '../components/RespectButton';
|
||||
|
||||
const isRespectAction = (a) => a.__typename === 'RespectActionSummary';
|
||||
|
||||
const COMMENT_FRAGMENT = gql`
|
||||
fragment RespectButton_updateFragment on Comment {
|
||||
fragment CoralRespect_UpdateFragment on Comment {
|
||||
action_summaries {
|
||||
... on RespectActionSummary {
|
||||
count
|
||||
@@ -21,8 +21,8 @@ const COMMENT_FRAGMENT = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
const withDeleteAction = graphql(gql`
|
||||
mutation deleteAction($id: ID!) {
|
||||
const withDeleteAction = withMutation(gql`
|
||||
mutation CoralRespect_DeleteAction($id: ID!) {
|
||||
deleteAction(id:$id) {
|
||||
errors {
|
||||
translation_key
|
||||
@@ -66,8 +66,8 @@ const withDeleteAction = graphql(gql`
|
||||
}),
|
||||
});
|
||||
|
||||
const withPostRespect = graphql(gql`
|
||||
mutation createRespect($respect: CreateRespectInput!) {
|
||||
const withPostRespect = withMutation(gql`
|
||||
mutation CoralRespect_CreateRespect($respect: CreateRespectInput!) {
|
||||
createRespect(respect: $respect) {
|
||||
respect {
|
||||
id
|
||||
@@ -137,14 +137,14 @@ const mapDispatchToProps = dispatch =>
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment RespectButton_root on RootQuery {
|
||||
fragment CoralRespect_RespectButton_root on RootQuery {
|
||||
me {
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
comment: gql`
|
||||
fragment RespectButton_comment on Comment {
|
||||
fragment CoralRespect_RespectButton_comment on Comment {
|
||||
action_summaries {
|
||||
... on RespectActionSummary {
|
||||
count
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const {passport, HandleGenerateCredentials} = require('../../../services/passport');
|
||||
const {passport, HandleGenerateCredentials, HandleLogout} = require('../../../services/passport');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,6 +19,11 @@ router.get('/', (req, res, next) => {
|
||||
res.json({user: req.user});
|
||||
});
|
||||
|
||||
/**
|
||||
* This blacklists the token used to authenticate.
|
||||
*/
|
||||
router.delete('/', HandleLogout);
|
||||
|
||||
//==============================================================================
|
||||
// PASSPORT ROUTES
|
||||
//==============================================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user