Merge branch 'master' into i18n-refactor

This commit is contained in:
gaba
2017-05-01 11:28:06 -07:00
101 changed files with 2961 additions and 2106 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ EXPOSE 5000
COPY . /usr/src/app
# Install app dependencies and build static assets.
RUN yarn install --frozen-lockfile && \
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
cli plugins reconcile && \
yarn build && \
yarn install --production && \
+1 -1
View File
@@ -243,7 +243,7 @@ file under the `scripts` key including:
# Setup
Once you've installed Talk (either via Docker or source), you still need to
setup the application. If you are unfamiliar with any terminoligy used in the
setup the application. If you are unfamiliar with any terminology used in the
setup process, refer to the `TERMINOLOGY.md` document.
## Via Web
+32
View File
@@ -145,6 +145,10 @@ type RootMutation {
type RootQuery {
people: [Person!]
}
type Subscription {
leader: Person
}
```
Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
@@ -259,6 +263,23 @@ If your post function accepts four parameters, then it can modify the field
result. It is *required* that the function resolves a promise (or returns) with
the modified value or simply the original if you didn't modify it.
#### Field: `setupFunctions`
```js
setupFunctions: {
leader: (options, args) => ({
leader: {
filter: (person) => person.place === 1
},
}),
}
```
Setup functions allow you to create filters that control which pubsub.publish() events
send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription.
For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions).
#### Field: `router`
```js
@@ -375,6 +396,10 @@ module.exports = {
type RootQuery {
people: [Person!]
}
type Subscription {
leader: Person
}
`,
context: {
Slack: () => ({
@@ -430,6 +455,13 @@ module.exports = {
}
}
}
},
setupFunctions: {
leader: (options, args) => ({
leader: {
filter: (person) => person.place === 1
}
}
}
};
+4 -33
View File
@@ -5,13 +5,11 @@ const path = require('path');
const helmet = require('helmet');
const {passport} = require('./services/passport');
const plugins = require('./services/plugins');
const session = require('express-session');
const enabled = require('debug').enabled;
const RedisStore = require('connect-redis')(session);
const redis = require('./services/redis');
const csrf = require('csurf');
const errors = require('./errors');
const graph = require('./graph');
const session = require('./services/session');
const {createGraphOptions} = require('./graph');
const apollo = require('graphql-server-express');
const app = express();
@@ -43,34 +41,7 @@ app.set('view engine', 'ejs');
// SESSION MIDDLEWARE
//==============================================================================
const session_opts = {
secret: process.env.TALK_SESSION_SECRET,
httpOnly: true,
rolling: true,
saveUninitialized: true,
resave: true,
unset: 'destroy',
name: 'talk.sid',
cookie: {
secure: false,
maxAge: 8.64e+7, // 24 hours for session token expiry
},
store: new RedisStore({
client: redis.createClient(),
})
};
if (app.get('env') === 'production') {
// Enable the secure cookie when we are in production mode.
session_opts.cookie.secure = true;
} else if (app.get('env') === 'test') {
// Add in the secret during tests.
session_opts.secret = 'keyboard cat';
}
app.use(session(session_opts));
app.use(session);
//==============================================================================
// PASSPORT MIDDLEWARE
@@ -96,7 +67,7 @@ app.use(passport.session());
//==============================================================================
// GraphQL endpoint.
app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions));
app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
// Only include the graphiql tool if we aren't in production mode.
if (app.get('env') !== 'production') {
+20 -9
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env node
const app = require('../app');
const program = require('./commander');
const http = require('http');
const app = require('../app');
const {createServer} = require('http');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('./util');
const {createSubscriptionManager} = require('../graph/subscriptions');
/**
* Get port from environment and store in Express.
@@ -20,7 +21,7 @@ app.set('port', port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
const server = createServer(app);
/**
* Event listener for HTTP server "error" event.
@@ -76,20 +77,29 @@ function normalizePort(val) {
function onListening() {
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${ addr}`
: `port ${ addr.port}`;
console.log(`Listening on ${ bind}`);
? `pipe ${addr}`
: `port ${addr.port}`;
console.log(`API Server Listening on ${bind}`);
}
/**
* Start the app.
*/
function startApp() {
function startApp(program) {
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.listen(port, () => {
// Mount the websocket server if requested.
if (program.websockets) {
console.log(`Websocket Server Listening on ${port}`);
// Mount the subscriptions server on the application server.
createSubscriptionManager(server);
}
});
server.on('error', onError);
server.on('listening', onListening);
}
@@ -100,10 +110,11 @@ function startApp() {
program
.option('-j, --jobs', 'enable job processing on this thread')
.option('-w, --websockets', 'enable the websocket (subscriptions) handler on this thread')
.parse(process.argv);
// Start the application serving.
startApp();
startApp(program);
// Enable job processing on the thread if enabled.
if (program.jobs) {
+6
View File
@@ -39,6 +39,12 @@ const routes = (
{/* Moderation Routes */}
<Route path='moderate' component={ModerationLayout}>
<Route path='all' components={ModerationContainer}>
<Route path=':id' components={ModerationContainer} />
</Route>
<Route path='accepted' components={ModerationContainer}>
<Route path=':id' components={ModerationContainer} />
</Route>
<Route path='premod' components={ModerationContainer}>
<Route path=':id' components={ModerationContainer} />
</Route>
@@ -1,17 +1,31 @@
import React from 'react';
import React, {PropTypes} from 'react';
import styles from './ModerationList.css';
import {Button} from 'coral-ui';
import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap';
const ActionButton = ({type = '', ...props}) => {
const ActionButton = ({type = '', status, ...props}) => {
const typeName = type.toLowerCase();
const active = ((type === 'REJECT' && status === 'REJECTED') || (type === 'APPROVE' && status === 'ACCEPTED'));
let text = menuActionsMap[type].text;
if (text === 'Approve' && active) {
text = 'Approved';
} else if (text === 'Reject' && active) {
text = 'Rejected';
}
return (
<Button
className={`${type.toLowerCase()} ${styles.actionButton}`}
cStyle={type.toLowerCase()}
className={`${typeName} ${styles.actionButton} ${active ? styles[`${typeName}__active`] : ''}`}
cStyle={typeName}
icon={menuActionsMap[type].icon}
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
>{menuActionsMap[type].text}</Button>
>{text}</Button>
);
};
ActionButton.propTypes = {
status: PropTypes.string
};
export default ActionButton;
@@ -28,6 +28,8 @@ const shortcuts = [
export default class ModerationKeysModal extends React.Component {
static propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
hideShortcutsNote: PropTypes.func.isRequired,
shortcutsNoteVisible: PropTypes.string.isRequired
}
@@ -188,3 +188,15 @@
margin: 0;
width: 140px;
}
.approve__active {
box-shadow: none;
color: white;
background-color: #519954;
}
.reject__active, .rejected__active {
color: white;
background-color: #D03235;
box-shadow: none;
}
@@ -6,7 +6,7 @@ import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from '../../translations.json';
import {Logo} from './Logo';
const CoralHeader = ({handleLogout, restricted = false}) => (
const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => (
<Header className={styles.header}>
<Logo className={styles.logo} />
{
@@ -55,7 +55,8 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
<div>
<IconButton name="settings" id="menu-settings"/>
<Menu target="menu-settings" align="right">
<MenuItem onClick={handleLogout}>Sign Out</MenuItem>
<MenuItem onClick={() => showShortcuts(true)}>{lang.t('configure.shortcuts')}</MenuItem>
<MenuItem onClick={handleLogout}>{lang.t('configure.sign-out')}</MenuItem>
</Menu>
</div>
</li>
@@ -72,6 +73,7 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
);
CoralHeader.propTypes = {
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
restricted: PropTypes.bool // hide elemnts from a user that's logged out
};
@@ -4,9 +4,13 @@ import Header from './Header';
import Drawer from './Drawer';
import styles from './Layout.css';
const Layout = ({children, handleLogout = () => {}, restricted = false, ...props}) => (
const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => (
<LayoutMDL fixedDrawer>
<Header handleLogout={handleLogout} restricted={restricted} {...props} />
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
restricted={restricted}
{...props} />
<Drawer handleLogout={handleLogout} restricted={restricted} {...props} />
<div className={styles.layout}>
{children}
@@ -16,6 +20,7 @@ const Layout = ({children, handleLogout = () => {}, restricted = false, ...props
Layout.propTypes = {
handleLogout: PropTypes.func,
toggleShortcutModal: PropTypes.func,
restricted: PropTypes.bool // hide elements from a user that's logged out
};
@@ -4,7 +4,6 @@ import translations from 'coral-admin/src/translations.json';
import styles from './Community.css';
import Table from './Table';
import Loading from './Loading';
import {Pager, Icon} from 'coral-ui';
import EmptyCard from '../../components/EmptyCard';
@@ -29,8 +28,8 @@ const tableHeaders = [
}
];
const People = ({isFetching, commenters, searchValue, onSearchChange, ...props}) => {
const hasResults = !isFetching && !!commenters.length;
const People = ({commenters, searchValue, onSearchChange, ...props}) => {
const hasResults = !!commenters.length;
return (
<div className={styles.container}>
<div className={styles.leftColumn}>
@@ -47,7 +46,6 @@ const People = ({isFetching, commenters, searchValue, onSearchChange, ...props})
</div>
</div>
<div className={styles.mainContent}>
{ isFetching && <Loading /> }
{
hasResults
? <Table
@@ -2,6 +2,7 @@ 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 {FullLoading} from '../components/FullLoading';
import AdminLogin from '../components/AdminLogin';
@@ -23,7 +24,7 @@ class LayoutContainer extends Component {
passwordRequestSuccess
} = this.props.auth;
const {handleLogout, TALK_RECAPTCHA_PUBLIC} = this.props;
const {handleLogout, toggleShortcutModal, TALK_RECAPTCHA_PUBLIC} = this.props;
if (loadingUser) { return <FullLoading />; }
if (!isAdmin) {
return <AdminLogin
@@ -34,7 +35,9 @@ class LayoutContainer extends Component {
recaptchaPublic={TALK_RECAPTCHA_PUBLIC}
errorMessage={loginError} />;
}
if (isAdmin && loggedIn) { return <Layout handleLogout={handleLogout} {...this.props} />; }
if (isAdmin && loggedIn) {
return <Layout handleLogout={handleLogout} toggleShortcutModal={toggleShortcutModal} {...this.props} />;
}
return <FullLoading />;
}
}
@@ -49,6 +52,7 @@ const mapDispatchToProps = dispatch => ({
fetchConfig: () => dispatch(fetchConfig()),
handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)),
requestPasswordReset: email => dispatch(requestPasswordReset(email)),
toggleShortcutModal: toggle => dispatch(toggleShortcutModal(toggle)),
handleLogout: () => dispatch(logout())
});
@@ -135,6 +135,12 @@ class ModerationContainer extends Component {
const comments = data[activeTab];
let activeTabCount;
switch(activeTab) {
case 'all':
activeTabCount = data.allCount;
break;
case 'accepted':
activeTabCount = data.acceptedCount;
break;
case 'premod':
activeTabCount = data.premodCount;
break;
@@ -151,6 +157,8 @@ class ModerationContainer extends Component {
<ModerationHeader asset={asset} />
<ModerationMenu
asset={asset}
allCount={data.allCount}
acceptedCount={data.acceptedCount}
premodCount={data.premodCount}
rejectedCount={data.rejectedCount}
flaggedCount={data.flaggedCount}
@@ -21,7 +21,6 @@ const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loa
key={i}
index={i}
comment={comment}
commentType={activeTab}
selected={i === selectedIndex}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
@@ -23,6 +23,12 @@ const Comment = ({actions = [], comment, ...props}) => {
const linkText = links ? links.map(link => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
return (
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
@@ -36,7 +42,7 @@ const Comment = ({actions = [], comment, ...props}) => {
{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 !== 'REJECTED')} />
<CommentType type={props.commentType} />
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned' ?
<span className={styles.banned}>
@@ -64,6 +70,7 @@ const Comment = ({actions = [], comment, ...props}) => {
<ActionButton key={i}
type={action}
user={comment.user}
status={comment.status}
acceptComment={() => props.acceptComment({commentId: comment.id})}
rejectComment={() => props.rejectComment({commentId: comment.id})}
/>
@@ -23,7 +23,7 @@ LoadMore.propTypes = {
comments: PropTypes.array.isRequired,
loadMore: PropTypes.func.isRequired,
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged']).isRequired,
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all', 'accepted']).isRequired,
assetId: PropTypes.string,
showLoadMore: PropTypes.bool.isRequired
};
@@ -4,22 +4,18 @@ import styles from './styles.css';
import {SelectField, Option} from 'react-mdl-selectfield';
import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
import {Icon} from 'coral-ui';
import {Link} from 'react-router';
const lang = new I18n(translations);
const ModerationMenu = (
{asset, premodCount, rejectedCount, flaggedCount, selectSort, sort}
{asset, allCount, acceptedCount, premodCount, rejectedCount, flaggedCount, selectSort, sort}
) => {
const premodPath = asset
? `/admin/moderate/premod/${asset.id}`
: '/admin/moderate/premod';
const rejectPath = asset
? `/admin/moderate/rejected/${asset.id}`
: '/admin/moderate/rejected';
const flagPath = asset
? `/admin/moderate/flagged/${asset.id}`
: '/admin/moderate/flagged';
function getPath (type) {
return asset ? `/admin/moderate/${type}/${asset.id}` : `/admin/moderate/${type}`;
}
return (
<div className="mdl-tabs">
@@ -27,22 +23,34 @@ const ModerationMenu = (
<div className={styles.tabBarPadding} />
<div>
<Link
to={premodPath}
to={getPath('all')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
<Icon name='question_answer' className={styles.tabIcon} /> {lang.t('modqueue.all')} <CommentCount count={allCount} />
</Link>
<Link
to={flagPath}
to={getPath('accepted')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
<Icon name='check' className={styles.tabIcon} /> {lang.t('modqueue.approved')} <CommentCount count={acceptedCount} />
</Link>
<Link
to={rejectPath}
to={getPath('premod')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
<Icon name='access_time' className={styles.tabIcon} /> {lang.t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
<Link
to={getPath('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='flag' className={styles.tabIcon} /> {lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
</Link>
<Link
to={getPath('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='close' className={styles.tabIcon} /> {lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
</div>
<SelectField
@@ -59,6 +67,7 @@ const ModerationMenu = (
};
ModerationMenu.propTypes = {
allCount: PropTypes.number.isRequired,
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
flaggedCount: PropTypes.number.isRequired,
@@ -418,3 +418,8 @@ span {
.loadMore:hover {
background-color: #4399FF;
}
.tabIcon {
position: relative;
top: 7px;
}
@@ -54,6 +54,19 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
},
updateQueries: {
ModQueue: (oldData) => {
const comment = oldData.all.find(c => c.id === commentId);
let accepted;
let acceptedCount = oldData.acceptedCount;
// if the comment was already in the Approved queue, don't re-add it
if (comment.status === 'ACCEPTED') {
accepted = [...oldData.accepted];
} else {
comment.status = 'ACCEPTED';
acceptedCount++;
accepted = [comment, ...oldData.accepted];
}
const premod = oldData.premod.filter(c => c.id !== commentId);
const flagged = oldData.flagged.filter(c => c.id !== commentId);
const rejected = oldData.rejected.filter(c => c.id !== commentId);
@@ -65,9 +78,11 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
...oldData,
premodCount,
flaggedCount,
acceptedCount,
rejectedCount,
premod,
flagged,
accepted,
rejected,
};
}
@@ -82,21 +97,35 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
},
updateQueries: {
ModQueue: (oldData) => {
const comment = oldData.premod.concat(oldData.flagged).filter(c => c.id === commentId)[0];
const rejected = [comment].concat(oldData.rejected);
const comment = oldData.all.find(c => c.id === commentId);
let rejected;
let rejectedCount = oldData.rejectedCount;
// if the item was already in the Rejected queue, don't put it in again
if (comment.status === 'REJECTED') {
rejected = oldData.rejected;
} else {
comment.status = 'REJECTED';
rejectedCount++;
rejected = [comment, ...oldData.rejected];
}
const premod = oldData.premod.filter(c => c.id !== commentId);
const flagged = oldData.flagged.filter(c => c.id !== commentId);
const accepted = oldData.accepted.filter(c => c.id !== commentId);
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
const rejectedCount = oldData.rejectedCount + 1;
const acceptedCount = accepted.length < oldData.accepted.length ? oldData.acceptedCount - 1 : oldData.acceptedCount;
return {
...oldData,
premodCount,
flaggedCount,
acceptedCount,
rejectedCount,
premod,
flagged,
accepted,
rejected
};
}
@@ -36,6 +36,12 @@ export const getMetrics = graphql(METRICS, {
export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => {
let statuses;
switch(tab) {
case 'all':
statuses = null;
break;
case 'accepted':
statuses = ['ACCEPTED'];
break;
case 'premod':
statuses = ['PREMOD'];
break;
@@ -1,6 +1,20 @@
#import "../fragments/commentView.graphql"
query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...commentView
}
accepted: comments(query: {
statuses: [ACCEPTED],
asset_id: $asset_id,
sort: $sort
}) {
...commentView
}
premod: comments(query: {
statuses: [PREMOD],
asset_id: $asset_id,
@@ -28,6 +42,13 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
title
url
}
allCount: commentCount(query: {
asset_id: $asset_id
})
acceptedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
@@ -7,6 +7,32 @@ const fm = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
__schema: {
types: [
{
kind: 'INTERFACE',
name: 'UserError',
possibleTypes: [
{name: 'GenericUserError'},
{name: 'ValidationUserError'}
]
},
{
kind: 'INTERFACE',
name: 'Response',
possibleTypes: [
{name: 'CreateCommentResponse'},
{name: 'CreateLikeResponse'},
{name: 'CreateFlagResponse'},
{name: 'CreateDontAgreeResponse'},
{name: 'DeleteActionResponse'},
{name: 'SetUserStatusResponse'},
{name: 'SuspendUserResponse'},
{name: 'SetCommentStatusResponse'},
{name: 'AddCommentTagResponse'},
{name: 'RemoveCommentTagResponse'},
{name: 'IgnoreUserResponse'},
{name: 'StopIgnoringUserResponse'}
]
},
{
kind: 'INTERFACE',
name: 'Action',
@@ -24,6 +50,15 @@ const fm = new IntrospectionFragmentMatcher({
{name: 'LikeActionSummary'},
{name: 'DontAgreeActionSummary'}
],
},
{
kind: 'INTERFACE',
name: 'AssetActionSummary',
possibleTypes: [
{name: 'DefaultAssetActionSummary'},
{name: 'FlagAssetActionSummary'},
{name: 'LikeAssetActionSummary'}
]
}
],
},
+7
View File
@@ -36,6 +36,7 @@
"modqueue": {
"likes": "likes",
"all": "all",
"approved": "approved",
"premod": "pre-mod",
"rejected": "rejected",
"flagged": "flagged",
@@ -79,6 +80,8 @@
"copy": "Copy to Clipboard"
},
"configure": {
"sign-out": "Sign Out",
"shortcuts": "Shortcuts",
"closed-stream-settings": "Closed Stream Message",
"open-stream-configuration": "This comment stream is currently open. By closing this comment stream, no new comments may be submitted and all previous comments will still be displayed.",
"close-stream-configuration": "This comment stream is currently closed. By opening this comment stream, new comments may be submitted and displayed",
@@ -225,6 +228,8 @@
"loading": "Cargando resultados"
},
"modqueue": {
"all": "todos",
"approved": "aprobado",
"likes": "gustos",
"premod": "pre-mod",
"rejected": "rechazado",
@@ -257,6 +262,8 @@
"username_flags": "marcas para este nombre de usuario"
},
"configure": {
"sign-out": "Desconectar",
"shortcuts": "Atajos",
"closed-stream-settings": "Mensaje a enviar cuando los comentarios están cerrados en el artículo",
"open-stream-configuration": "Este hilo de comentarios esta abierto. Al cerrarlo, ningún nuevo comentario será publicado y todos los comentarios anteriores serán mostrados.",
"close-stream-configuration": "Este hilo de comentario está en este momento cerrado. Al abrirlo, nuevos comentarios serán publicaods y mostrados.",
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {Router, Route, browserHistory} from 'react-router';
import Embed from './Embed';
import Embed from './containers/Embed';
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
const routes = (
-348
View File
@@ -1,348 +0,0 @@
import React from 'react';
import {compose} from 'react-apollo';
import {connect} from 'react-redux';
import isEqual from 'lodash/isEqual';
import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from 'coral-framework/translations';
const lang = new I18n(translations);
import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui';
const {logout, showSignInDialog, requestConfirmEmail, openSignInPopUp, checkLogin} = authActions;
const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
import {queryStream} from 'coral-framework/graphql/queries';
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
import {editName} from 'coral-framework/actions/user';
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
import Stream from './Stream';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import {ModerationLink} from 'coral-plugin-moderation';
import Count from 'coral-plugin-comment-count/CommentCount';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import UserBox from 'coral-sign-in/components/UserBox';
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
import HighlightedComment from './Comment';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
class Embed extends React.Component {
constructor(props) {
super(props);
this.state = {
activeTab: 0,
showSignInDialog: false,
activeReplyBox: ''
};
}
changeTab = (tab) => {
// Everytime the comes from another tab, the Stream needs to be updated.
if (tab === 0) {
this.props.viewAllComments();
this.props.data.refetch();
}
this.setState({
activeTab: tab
});
}
static propTypes = {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired,
// dispatch action to add a tag to a comment
addCommentTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
}
componentDidMount () {
pym.sendMessage('childReady');
this.props.checkLogin();
}
componentWillUnmount () {
clearInterval(this.state.countPoll);
}
componentWillReceiveProps (nextProps) {
const {loadAsset} = this.props;
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
loadAsset(nextProps.data.asset);
const {getCounts, updateCountCache, asset: {countCache}} = this.props;
const {asset} = nextProps.data;
if (!countCache) {
updateCountCache(asset.id, asset.commentCount);
}
this.setState({
countPoll: setInterval(() => {
const {asset} = this.props.data;
getCounts({
asset_id: asset.id,
limit: asset.comments.length,
sort: 'REVERSE_CHRONOLOGICAL'
});
}, NEW_COMMENT_COUNT_POLL_INTERVAL)
});
}
}
componentDidUpdate(prevProps) {
if(!isEqual(prevProps.data.comment, this.props.data.comment)) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
}
}
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
} else {
this.setState({activeReplyBox: reactKey});
}
}
render () {
const {activeTab} = this.state;
const {closedAt, countCache = {}} = this.props.asset;
const {asset, refetch, comment} = this.props.data;
const {loggedIn, isAdmin, user, showSignInDialog} = this.props.auth;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
const highlightedComment = comment && comment.parent ? comment.parent : comment;
const openStream = closedAt === null;
const banned = user && user.status === 'BANNED';
const hasOlderComments = !!(
asset &&
asset.lastComment &&
asset.lastComment.id !== asset.comments[asset.comments.length - 1].id
);
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 200
} : {};
if (!asset) {
return <Spinner />;
}
// Find the created_at date of the first comment. If no comments exist, set the date to a week ago.
const firstCommentDate = asset.comments[0]
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const userBox = <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>;
// TODO: This is a quickfix and will be replaced after our refactor.
const ignoredUsers = this.props.userData.ignoredUsers;
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
return (
<div style={expandForLogin}>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={asset.totalCommentCount}/></Tab>
<Tab>{lang.t('my_profile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{
highlightedComment &&
<Button
cStyle='darkGrey'
style={{float: 'right'}}
onClick={() => {
this.props.viewAllComments();
this.props.data.refetch();
}}>{lang.t('showAllComments')}</Button>
}
<TabContent show={activeTab === 0}>
{ loggedIn ? userBox : null }
{
openStream
? <div id="commentBox">
<InfoBox
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<QuestionBox
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
/>
<RestrictedContent restricted={banned} restrictedComp={
<SuspendedAccount
canEditName={user && user.canEditName}
editName={this.props.editName}
/>
}>
{
user
? <CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
updateCountCache={this.props.updateCountCache}
countCache={countCache[asset.id]}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
currentUser={this.props.auth.user}
authorId={user.id}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount} />
: null
}
</RestrictedContent>
</div>
: <p>{asset.settings.closedMessage}</p>
}
{!loggedIn && <Button id='coralSignInButton' onClick={this.props.showSignInDialog} full>Sign in to comment</Button>}
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
{
highlightedComment
? <HighlightedComment
refetch={refetch}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
addNotification={this.props.addNotification}
depth={0}
postItem={this.props.postItem}
asset={asset}
currentUser={user}
highlighted={comment.id}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
commentIsIgnored={commentIsIgnored}
key={highlightedComment.id}
reactKey={highlightedComment.id}
comment={highlightedComment} />
: <div>
<NewCount
commentCount={asset.commentCount}
countCache={countCache[asset.id]}
loadMore={this.props.loadMore}
firstCommentDate={firstCommentDate}
assetId={asset.id}
updateCountCache={this.props.updateCountCache}
/>
<div className="embed__stream">
<Stream
open={openStream}
addNotification={this.props.addNotification}
postItem={this.props.postItem}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.state.activeReplyBox}
asset={asset}
currentUser={user}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
addCommentTag={this.props.addCommentTag}
removeCommentTag={this.props.removeCommentTag}
ignoreUser={this.props.ignoreUser}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments}
maxCharCount={asset.settings.charCount}
charCountEnable={asset.settings.charCountEnable}
ignoredUsers={this.props.userData.ignoredUsers} />
</div>
<LoadMore
topLevel={true}
assetId={asset.id}
comments={asset.comments}
moreComments={hasOlderComments}
loadMore={this.props.loadMore} />
</div>
}
</TabContent>
<TabContent show={activeTab === 1}>
<ProfileContainer
loggedIn={loggedIn}
userData={this.props.userData}
/>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
{ loggedIn ? userBox : null }
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
</div>
</div>
);
}
}
const mapStateToProps = state => ({
auth: state.auth.toJS(),
userData: state.user.toJS(),
asset: state.asset.toJS(),
});
const mapDispatchToProps = dispatch => ({
requestConfirmEmail: () => dispatch(requestConfirmEmail()),
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
addNotification: (type, text) => addNotification(type, text),
clearNotification: () => dispatch(clearNotification()),
editName: (username) => dispatch(editName(username)),
showSignInDialog: () => dispatch(showSignInDialog()),
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
viewAllComments: () => dispatch(viewAllComments()),
logout: () => dispatch(logout()),
openSignInPopUp: cb => dispatch(openSignInPopUp(cb)),
checkLogin: () => dispatch(checkLogin()),
dispatch: d => dispatch(d),
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
postComment,
postFlag,
postLike,
postDontAgree,
addCommentTag,
removeCommentTag,
ignoreUser,
deleteAction,
queryStream,
)(Embed);
-103
View File
@@ -1,103 +0,0 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
class Stream extends React.Component {
static propTypes = {
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
asset: PropTypes.object.isRequired,
open: PropTypes.bool.isRequired,
comments: PropTypes.array.isRequired,
currentUser: PropTypes.shape({
username: PropTypes.string,
id: PropTypes.string
}),
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
// list of user ids that should be rendered as ignored
ignoredUsers: React.PropTypes.arrayOf(React.PropTypes.string)
}
constructor(props) {
super(props);
this.state = {activeReplyBox: '', countPoll: null};
}
render () {
const {
comments,
currentUser,
asset,
postItem,
addNotification,
postFlag,
postLike,
open,
postDontAgree,
loadMore,
deleteAction,
showSignInDialog,
addCommentTag,
removeCommentTag,
pluginProps,
ignoreUser,
ignoredUsers,
charCountEnable,
maxCharCount,
} = this.props;
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
return (
<div id='stream'>
{
comments.map(comment =>
commentIsIgnored(comment)
? <IgnoredCommentTombstone
key={comment.id}
/>
: <Comment
disableReply={!open}
setActiveReplyBox={this.props.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
maxCharCount={maxCharCount}
charCountEnable={charCountEnable}
pluginProps={pluginProps}
/>
)
}
</div>
);
}
}
export default Stream;
@@ -0,0 +1,10 @@
import * as actions from '../constants/embed';
import {viewAllComments} from './stream';
export const setActiveTab = (tab) => (dispatch, getState) => {
dispatch({type: actions.SET_ACTIVE_TAB, tab});
if (getState().stream.commentId) {
dispatch(viewAllComments());
}
};
@@ -0,0 +1,40 @@
import {pym} from 'coral-framework';
import * as actions from '../constants/stream';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount});
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
let param;
let params_arr = [];
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
if (queryString !== '') {
params_arr = queryString.split('&');
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
param = params_arr[i].split('=')[0];
if (param === key) {
params_arr.splice(i, 1);
}
}
rtn = `${rtn}?${params_arr.join('&')}`;
}
return rtn;
}
export const viewAllComments = () => {
// remove the comment_id url param
const modifiedUrl = removeParam('comment_id', location.href);
try {
// "window" here refers to the embedded iframe
window.history.replaceState({}, document.title, modifiedUrl);
// also change the parent url
pym.sendMessage('coral-view-all-comments');
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_ALL_COMMENTS};
};
@@ -18,8 +18,8 @@ import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
import LoadMore from 'coral-embed-stream/src/LoadMore';
import Slot from 'coral-framework/components/Slot';
import LoadMore from './LoadMore';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {TopRightMenu} from './TopRightMenu';
import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils';
@@ -178,8 +178,14 @@ class Comment extends React.Component {
? <TagLabel><BestIndicator /></TagLabel>
: null }
<PubDate created_at={comment.created_at} />
<Slot fill="commentInfoBar" comment={comment} commentId={comment.id} inline/>
<Slot
fill="commentInfoBar"
data={this.props.data}
root={this.props.root}
comment={comment}
commentId={comment.id}
inline
/>
{ (currentUser && (comment.user.id !== currentUser.id))
? <span className={styles.topRightMenu}>
<TopRightMenu
@@ -191,7 +197,9 @@ class Comment extends React.Component {
}
<Content body={comment.body} />
<Slot fill="commentContent" />
<div className="commentActionsLeft comment__action-container">
<Slot fill="commentReactions" inline />
<ActionButton>
{/* TODO implmement iPerformedThisAction for the like */}
<LikeButton
@@ -221,7 +229,14 @@ class Comment extends React.Component {
removeBest={removeBestTag} />
</IfUserCanModifyBest>
</ActionButton>
<Slot fill="commentDetail" comment={comment} commentId={comment.id} inline/>
<Slot
fill="commentActions"
data={this.props.data}
root={this.props.root}
comment={comment}
commentId={comment.id}
inline
/>
</div>
<div className="commentActionsRight comment__action-container">
<ActionButton>
@@ -263,6 +278,8 @@ class Comment extends React.Component {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
@@ -0,0 +1,87 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
const lang = new I18n(translations);
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
import Stream from '../containers/Stream';
import Count from 'coral-plugin-comment-count/CommentCount';
import UserBox from 'coral-sign-in/components/UserBox';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
export default class Embed extends React.Component {
changeTab = (tab) => {
switch(tab) {
case 0:
this.props.setActiveTab('stream');
break;
case 1:
this.props.setActiveTab('profile');
// TODO: move data fetching to profile container.
this.props.data.refetch();
break;
case 2:
this.props.setActiveTab('config');
// TODO: move data fetching to config container.
this.props.data.refetch();
break;
}
}
handleShowProfile = () => this.props.setActiveTab('profile');
render () {
const {activeTab, logout, viewAllComments, commentId} = this.props;
const {asset: {totalCommentCount}} = this.props.root;
const {loggedIn, isAdmin, user} = this.props.auth;
const userBox = <UserBox user={user} onLogout={logout} onShowProfile={this.handleShowProfile}/>;
return (
<div>
<div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount}/></Tab>
<Tab>{lang.t('myProfile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{
commentId &&
<Button
cStyle='darkGrey'
style={{float: 'right'}}
onClick={viewAllComments}
>
{lang.t('showAllComments')}
</Button>
}
<TabContent show={activeTab === 'stream'}>
{ loggedIn ? userBox : null }
<Stream data={this.props.data} root={this.props.root} />
</TabContent>
<TabContent show={activeTab === 'profile'}>
<ProfileContainer />
</TabContent>
<TabContent show={activeTab === 'config'}>
<RestrictedContent restricted={!loggedIn}>
{ loggedIn ? userBox : null }
<ConfigureStreamContainer />
</RestrictedContent>
</TabContent>
</div>
</div>
);
}
}
Embed.propTypes = {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired,
};
@@ -1,7 +1,7 @@
import React, {PropTypes} from 'react';
import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments';
import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream';
import {Button} from 'coral-ui';
const lang = new I18n(translations);
@@ -3,9 +3,9 @@ import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from 'coral-framework/translations.json';
const lang = new I18n(translations);
const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, updateCountCache}) => (e) => {
const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, setCommentCountCache}) => (e) => {
e.preventDefault();
updateCountCache(assetId, commentCount);
setCommentCountCache(commentCount);
loadMore({
asset_id: assetId,
limit: 500,
@@ -15,11 +15,11 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd
};
const NewCount = (props) => {
const newComments = props.commentCount - props.countCache;
const newComments = props.commentCount - props.commentCountCache;
return <div className='coral-new-comments coral-load-more'>
{
props.countCache && newComments > 0 ?
props.commentCountCache && newComments > 0 ?
<button onClick={onLoadMoreClick(props)}>
{newComments === 1
? lang.t('newCount', newComments, lang.t('comment'))
@@ -32,7 +32,7 @@ const NewCount = (props) => {
NewCount.propTypes = {
commentCount: PropTypes.number.isRequired,
countCache: PropTypes.number,
commentCountCache: PropTypes.number,
loadMore: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired,
firstCommentDate: PropTypes.string.isRequired
@@ -0,0 +1,214 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import LoadMore from './LoadMore';
import NewCount from './NewCount';
import Comment from '../containers/Comment';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import {ModerationLink} from 'coral-plugin-moderation';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
class Stream extends React.Component {
setActiveReplyBox = reactKey => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
} else {
this.props.setActiveReplyBox(reactKey);
}
};
render() {
const {
root: {asset, asset: {comments}, comment, myIgnoredUsers},
postItem,
addNotification,
postFlag,
postLike,
postDontAgree,
loadMore,
deleteAction,
showSignInDialog,
addCommentTag,
removeCommentTag,
pluginProps,
ignoreUser,
auth: {loggedIn, isAdmin, user},
commentCountCache,
editName
} = this.props;
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
const highlightedComment = comment && comment.parent
? comment.parent
: comment;
const banned = user && user.status === 'BANNED';
const hasOlderComments = !!(asset &&
asset.lastComment &&
asset.lastComment.id !== asset.comments[asset.comments.length - 1].id);
// Find the created_at date of the first comment. If no comments exist, set the date to a week ago.
const firstCommentDate = asset.comments[0]
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
const commentIsIgnored = comment =>
myIgnoredUsers && myIgnoredUsers.includes(comment.user.id);
return (
<div id="stream">
{open
? <div id="commentBox">
<InfoBox
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<QuestionBox
content={asset.settings.questionBoxContent}
enable={asset.settings.questionBoxEnable}
/>
<RestrictedContent
restricted={banned}
restrictedComp={
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
/>
}
>
{user
? <CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
setCommentCountCache={this.props.setCommentCountCache}
commentCountCache={commentCountCache}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
authorId={user.id}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>
: null}
</RestrictedContent>
</div>
: <p>{asset.settings.closedMessage}</p>}
{!loggedIn &&
<Button
id="coralSignInButton"
onClick={this.props.showSignInDialog}
full
>
Sign in to comment
</Button>}
{loggedIn &&
user &&
<ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
? <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={this.props.postItem}
asset={asset}
currentUser={user}
highlighted={comment.id}
postLike={this.props.postLike}
postFlag={this.props.postFlag}
postDontAgree={this.props.postDontAgree}
loadMore={this.props.loadMore}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
key={highlightedComment.id}
commentIsIgnored={commentIsIgnored}
reactKey={highlightedComment.id}
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>
: <div>
<NewCount
commentCount={asset.commentCount}
commentCountCache={commentCountCache}
loadMore={this.props.loadMore}
firstCommentDate={firstCommentDate}
assetId={asset.id}
setCommentCountCache={this.props.setCommentCountCache}
/>
<div className="embed__stream">
{comments.map(
comment =>
(commentIsIgnored(comment)
? <IgnoredCommentTombstone key={comment.id} />
: <Comment
data={this.props.data}
root={this.props.root}
disableReply={!open}
setActiveReplyBox={this.setActiveReplyBox}
activeReplyBox={this.props.activeReplyBox}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={user}
postLike={postLike}
postFlag={postFlag}
postDontAgree={postDontAgree}
addCommentTag={addCommentTag}
removeCommentTag={removeCommentTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadMore}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
reactKey={comment.id}
comment={comment}
pluginProps={pluginProps}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
/>)
)}
</div>
<LoadMore
topLevel={true}
assetId={asset.id}
comments={asset.comments}
moreComments={hasOlderComments}
loadMore={this.props.loadMore}
/>
</div>}
</div>
);
}
}
Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addCommentTag: PropTypes.func,
// dispatch action to remove a tag from a comment
removeCommentTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func
};
export default Stream;
@@ -0,0 +1 @@
export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB';
@@ -0,0 +1,5 @@
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE';
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
@@ -0,0 +1,48 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
const pluginFragments = getSlotsFragments([
'streamQuestionArea',
'commentInputArea',
'commentInputDetailArea',
'commentInfoBar',
'commentActions',
'commentContent',
'commentReactions'
]);
export default withFragments({
root: gql`
fragment Comment_root on RootQuery {
__typename
${pluginFragments.spreads('root')}
}
${pluginFragments.definitions('root')}
`,
comment: gql`
fragment Comment_comment on Comment {
id
body
created_at
status
tags {
name
}
user {
id
name: username
}
action_summaries {
__typename
count
current_user {
id
}
}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
`
})(Comment);
@@ -0,0 +1,118 @@
import React from 'react';
import {compose, gql, graphql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import isEqual from 'lodash/isEqual';
import branch from 'recompose/branch';
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 Embed from '../components/Embed';
import {setCommentCountCache, viewAllComments} from '../actions/stream';
import {setActiveTab} from '../actions/embed';
import Stream from './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) {
// Refetch because on logout `excludeIgnored` becomes `false`.
// TODO: logout via mutation and obsolete this?
this.props.data.refetch();
}
const {fetchAssetSuccess} = this.props;
if(!isEqual(nextProps.root.asset, this.props.root.asset)) {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
const {setCommentCountCache, commentCountCache} = this.props;
const {asset} = nextProps.root;
if (commentCountCache === -1) {
setCommentCountCache(asset.commentCount);
}
}
}
componentDidUpdate(prevProps) {
if(!isEqual(prevProps.root.comment, this.props.root.comment)) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
}
}
render() {
if (!this.props.root.asset) {
return <Spinner />;
}
return <Embed {...this.props} />;
}
}
const EMBED_QUERY = gql`
query EmbedQuery($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
asset(id: $assetId, url: $assetUrl) {
totalCommentCount(excludeIgnored: $excludeIgnored)
}
me {
status
}
...${getDefinitionName(Stream.fragments.root)}
}
${Stream.fragments.root}
`;
export const withQuery = graphql(EMBED_QUERY, {
options: ({auth, commentId, assetId, assetUrl}) => ({
variables: {
assetId,
assetUrl,
commentId,
hasComment: commentId !== '',
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
},
}),
props: ({data}) => separateDataAndRoot(data),
});
const mapStateToProps = state => ({
auth: state.auth.toJS(),
commentCountCache: state.stream.commentCountCache,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({
fetchAssetSuccess,
checkLogin,
setCommentCountCache,
viewAllComments,
logout,
setActiveTab,
}, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
branch(
props => !props.auth.checkedInitialLogin,
renderComponent(Spinner),
),
withQuery,
)(EmbedContainer);
@@ -0,0 +1,249 @@
import React from 'react';
import {gql, compose} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
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, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} 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 {getDefinitionName} from 'coral-framework/utils';
const {showSignInDialog} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
getCounts = (variables) => {
return this.props.data.fetchMore({
query: LOAD_COMMENT_COUNTS_QUERY,
variables,
// Apollo requires this, even though we don't use it...
updateQuery: data => data,
});
};
// handle paginated requests for more Comments pertaining to the Asset
loadMore = ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit, // how many comments are we returning
cursor, // the date of the first/last comment depending on the sort order
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
asset_id, // the id of the asset we're currently on
sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => {
let updatedAsset;
if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment
let comment = {};
if (oldData.comment && oldData.comment.parent) {
// put comments (replies) onto the oldData.comment.parent object
// the initial comment permalinked was a reply
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id');
comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')};
} else if (oldData.comment) {
// put the comments (replies) directly onto oldData.comment
// the initial comment permalinked was a top-level comment
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id');
comment.replies = sortBy(uniqReplies, 'created_at');
}
updatedAsset = {
...oldData,
comment: {
...oldData.comment,
...comment
}
};
} else if (parent_id) { // If loading more replies
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map(comment => {
// since the dipslayed replies and the returned replies can overlap,
// pull out the unique ones.
const uniqueReplies = uniqBy([...new_top_level_comments, ...comment.replies], 'id');
// since we just gave the returned replies precedence, they're now out of order.
// resort according to date.
return comment.id === parent_id
? {...comment, replies: sortBy(uniqueReplies, 'created_at')}
: comment;
})
}
};
} else { // If loading more top-level comments
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments]
: [...oldData.asset.comments, ...new_top_level_comments]
}
};
}
return updatedAsset;
}
});
};
componentDidMount() {
if (this.props.previousTab) {
this.props.data.refetch();
}
this.countPoll = setInterval(() => {
this.getCounts(this.props.data.variables);
}, NEW_COMMENT_COUNT_POLL_INTERVAL);
}
componentWillUnmount() {
clearInterval(this.countPoll);
}
render() {
return <Stream {...this.props} loadMore={this.loadMore}/>;
}
}
const LOAD_COMMENT_COUNTS_QUERY = gql`
query LoadCommentCounts($assetUrl: String, $assetId: ID, $excludeIgnored: Boolean) {
asset(id: $assetId, url: $assetUrl) {
id
commentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10) {
id
replyCount(excludeIgnored: $excludeIgnored)
}
}
}
`;
const LOAD_MORE_QUERY = gql`
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3) {
...${getDefinitionName(Comment.fragments.comment)}
}
}
}
${Comment.fragments.comment}
`;
const fragments = {
root: gql`
fragment Stream_root on RootQuery {
comment(id: $commentId) @include(if: $hasComment) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
...${getDefinitionName(Comment.fragments.comment)}
}
parent {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies {
...${getDefinitionName(Comment.fragments.comment)}
}
}
}
asset(id: $assetId, url: $assetUrl) {
id
title
url
closedAt
created_at
settings {
moderation
infoBoxEnable
infoBoxContent
premodLinksEnable
questionBoxEnable
questionBoxContent
closeTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
lastComment {
id
}
commentCount(excludeIgnored: $excludeIgnored)
totalCommentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10, excludeIgnored: $excludeIgnored) {
...${getDefinitionName(Comment.fragments.comment)}
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
...${getDefinitionName(Comment.fragments.comment)}
}
}
}
myIgnoredUsers {
id,
username,
}
me {
status
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
${Comment.fragments.comment}
`,
};
const mapStateToProps = state => ({
auth: state.auth.toJS(),
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
previousTab: state.embed.previousTab,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({
showSignInDialog,
addNotification,
setActiveReplyBox,
editName,
setCommentCountCache,
}, dispatch);
export default compose(
withFragments(fragments),
connect(mapStateToProps, mapDispatchToProps),
postComment,
postFlag,
postLike,
postDontAgree,
addCommentTag,
removeCommentTag,
ignoreUser,
deleteAction,
)(StreamContainer);
+10 -1
View File
@@ -3,12 +3,21 @@ import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/services/client';
import localStore from 'coral-framework/services/store';
import {checkLogin} from 'coral-framework/actions/auth';
import reducers from './reducers';
import localStore, {injectReducers} from 'coral-framework/services/store';
import AppRouter from './AppRouter';
injectReducers(reducers);
const store = (window.opener && window.opener.coralStore) ? window.opener.coralStore : localStore;
// Don't run this in the popup.
if (store === localStore) {
store.dispatch(checkLogin());
}
render(
<ApolloProvider client={client} store={store}>
<AppRouter />
@@ -0,0 +1,19 @@
import * as actions from '../constants/embed';
const initialState = {
activeTab: 'stream',
previousTab: '',
};
export default function stream(state = initialState, action) {
switch (action.type) {
case actions.SET_ACTIVE_TAB:
return {
...state,
activeTab: action.tab,
previousTab: state.activeTab,
};
default:
return state;
}
}
@@ -0,0 +1,7 @@
import stream from './stream';
import embed from './embed';
export default {
stream,
embed,
};
@@ -0,0 +1,45 @@
import * as actions from '../constants/stream';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
let vars = query.split('&');
for (let i = 0; i < vars.length; i++) {
let pair = vars[i].split('=');
if (decodeURIComponent(pair[0]) === variable) {
return decodeURIComponent(pair[1]);
}
}
// If not found, return null.
return null;
}
const initialState = {
activeReplyBox: '',
commentCountCache: -1,
assetId: getQueryVariable('asset_id'),
assetUrl: getQueryVariable('asset_url'),
commentId: getQueryVariable('comment_id'),
};
export default function stream(state = initialState, action) {
switch (action.type) {
case actions.SET_ACTIVE_REPLY_BOX:
return {
...state,
activeReplyBox: action.id,
};
case actions.SET_COMMENT_COUNT_CACHE:
return {
...state,
commentCountCache: action.amount,
};
case actions.VIEW_ALL_COMMENTS:
return {
...state,
commentId: '',
};
default:
return state;
}
}
+1 -11
View File
@@ -352,17 +352,6 @@ button.comment__action-button[disabled],
/* Flag Styles */
.coral-plugin-flags-container {
position: relative;
}
.coral-plugin-flags-popup span {
min-width: 280px;
bottom: 36px;
position: absolute;
right: 10px;
}
.coral-plugin-flags-popup-form {
margin-bottom: 10px;
}
@@ -399,6 +388,7 @@ button.comment__action-button[disabled],
margin-top: 5px;
width: 75%;
font-size: 16px;
border: 1px solid #ccc;
}
/* Close comments */
+4
View File
@@ -57,6 +57,10 @@ function configurePymParent(pymParent) {
window.document.body.appendChild(snackbar);
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
pymParent.el.firstChild.style.width = '1px';
pymParent.el.firstChild.style.minWidth = '100%';
// Resize parent iframe height when child height changes
pymParent.onMessage('height', function(height) {
if (height !== cachedHeight) {
-35
View File
@@ -1,7 +1,6 @@
import * as actions from '../constants/asset';
import coralApi from '../helpers/response';
import {addNotification} from '../actions/notification';
import {pym} from 'coral-framework';
import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from './../translations';
@@ -39,7 +38,6 @@ export const updateOpenStream = closedBody => (dispatch, getState) => {
const openStream = () => ({type: actions.OPEN_COMMENTS});
const closeStream = () => ({type: actions.CLOSE_COMMENTS});
export const updateCountCache = (id, count) => ({type: actions.UPDATE_COUNT_CACHE, id, count});
export const updateOpenStatus = status => dispatch => {
if (status === 'open') {
@@ -51,36 +49,3 @@ export const updateOpenStatus = status => dispatch => {
}
};
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
let param;
let params_arr = [];
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
if (queryString !== '') {
params_arr = queryString.split('&');
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
param = params_arr[i].split('=')[0];
if (param === key) {
params_arr.splice(i, 1);
}
}
rtn = `${rtn}?${params_arr.join('&')}`;
}
return rtn;
}
export const viewAllComments = () => {
// remove the comment_id url param
const modifiedUrl = removeParam('comment_id', location.href);
try {
// "window" here refers to the embedded iframe
window.history.replaceState({}, document.title, modifiedUrl);
// also change the parent url
pym.sendMessage('coral-view-all-comments');
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_ALL_COMMENTS};
};
+21 -4
View File
@@ -39,10 +39,21 @@ export const showSignInDialog = () => dispatch => {
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
);
signInPopUp.onbeforeunload = () => {
dispatch(checkLogin());
fetchMe();
// Workaround odd behavior in older WebKit versions, where
// onunload is called twice. (Encountered in IOS 8.3)
let loaded = false;
signInPopUp.onload = () => {
loaded = true;
};
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
signInPopUp.onunload = () => {
if (loaded) {
dispatch(checkLogin());
fetchMe();
}
};
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
};
export const hideSignInDialog = () => dispatch => {
@@ -177,7 +188,13 @@ export const fetchSignUp = (formData, redirectUri) => (dispatch) => {
dispatch(signUpSuccess(user));
})
.catch(error => {
dispatch(signUpFailure(lang.t(`error.${error.message}`)));
let errorMessage = lang.t(`error.${error.message}`);
// if there is no translation defined, just show the error string
if (errorMessage === `error.${error.message}`) {
errorMessage = error.message;
}
dispatch(signUpFailure(errorMessage));
});
};
+9 -13
View File
@@ -1,20 +1,16 @@
import React, {Component} from 'react';
import {getSlotElements} from 'coral-framework/helpers/plugins';
import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {getSlotElements} from 'coral-framework/helpers/plugins';
class Slot extends Component {
render() {
const {fill, inline = false, ...rest} = this.props;
return (
<div className={inline ? styles.inline : ''}>
{getSlotElements(fill, rest)}
</div>
);
}
export default function Slot ({fill, inline = false, ...rest}) {
return (
<div className={cn({[styles.inline]: inline})}>
{getSlotElements(fill, rest)}
</div>
);
}
Slot.propTypes = {
fill: React.PropTypes.string
};
export default Slot;
@@ -8,6 +8,4 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE';
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
@@ -1,2 +0,0 @@
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
@@ -9,10 +9,6 @@ import REMOVE_COMMENT_TAG from './removeCommentTag.graphql';
import IGNORE_USER from './ignoreUser.graphql';
import STOP_IGNORING_USER from './stopIgnoringUser.graphql';
import MY_IGNORED_USERS from '../queries/myIgnoredUsers.graphql';
import STREAM_QUERY from '../queries/streamQuery.graphql';
import {variablesForStreamQuery} from '../queries';
import commentView from '../fragments/commentView.graphql';
export const postComment = graphql(POST_COMMENT, {
@@ -45,7 +41,7 @@ export const postComment = graphql(POST_COMMENT, {
}
},
updateQueries: {
AssetQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => {
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
return oldData;
@@ -61,7 +57,7 @@ export const postComment = graphql(POST_COMMENT, {
...oldData.asset,
comments: oldData.asset.comments.map((oldComment) => {
return oldComment.id === parent_id
? {...oldComment, replies: [...oldComment.replies, comment]}
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
: oldComment;
})
}
@@ -155,6 +151,7 @@ export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, {
}}),
});
// TODO: don't rely on refetching.
export const ignoreUser = graphql(IGNORE_USER, {
props: ({mutate}) => ({
ignoreUser: ({id}) => {
@@ -162,15 +159,16 @@ export const ignoreUser = graphql(IGNORE_USER, {
variables: {
id,
},
refetchQueries: [{
query: MY_IGNORED_USERS,
}]
refetchQueries: [
'EmbedQuery', 'myIgnoredUsers',
]
});
}}),
});
// TODO: don't rely on refetching.
export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
props: ({mutate, ownProps}) => {
props: ({mutate}) => {
return {
stopIgnoringUser: ({id}) => {
return mutate({
@@ -178,13 +176,7 @@ export const stopIgnoringUser = graphql(STOP_IGNORING_USER, {
id,
},
refetchQueries: [
{
query: MY_IGNORED_USERS,
},
{
query: STREAM_QUERY,
variables: variablesForStreamQuery(ownProps),
}
'EmbedQuery', 'myIgnoredUsers',
]
});
}
@@ -1,13 +0,0 @@
#import "../fragments/commentView.graphql"
query commentQuery($id: ID!) {
comment(id: $id) {
...commentView
parent {
...commentView
replies {
...commentView
}
}
}
}
@@ -1,10 +0,0 @@
query LoadCommentCounts($asset_id: ID, $limit: Int = 5, $sort: SORT_ORDER) {
asset(id: $asset_id) {
id
commentCount
comments(sort: $sort, limit: $limit) {
id
replyCount
}
}
}
@@ -1,153 +1,6 @@
import {graphql} from 'react-apollo';
import STREAM_QUERY from './streamQuery.graphql';
import LOAD_MORE from './loadMore.graphql';
import GET_COUNTS from './getCounts.graphql';
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
import MY_IGNORED_USERS from './myIgnoredUsers.graphql';
import uniqBy from 'lodash/uniqBy';
import sortBy from 'lodash/sortBy';
import isNil from 'lodash/isNil';
function getQueryVariable(variable) {
let query = window.location.search.substring(1);
let vars = query.split('&');
for (let i = 0; i < vars.length; i++) {
let pair = vars[i].split('=');
if (decodeURIComponent(pair[0]) === variable) {
return decodeURIComponent(pair[1]);
}
}
// If not found, return null.
return null;
}
// get the counts of the top-level comments
export const getCounts = (data) => ({asset_id, limit, sort}) => {
return data.fetchMore({
query: GET_COUNTS,
variables: {
asset_id,
limit,
sort,
excludeIgnored: data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{asset}}) => {
return {
...oldData,
asset: {
...oldData.asset,
commentCount: asset.commentCount
}
};
}
});
};
// handle paginated requests for more Comments pertaining to the Asset
export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => {
return data.fetchMore({
query: LOAD_MORE,
variables: {
limit, // how many comments are we returning
cursor, // the date of the first/last comment depending on the sort order
parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment
asset_id, // the id of the asset we're currently on
sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL
excludeIgnored: data.variables.excludeIgnored,
},
updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => {
let updatedAsset;
if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment
let comment = {};
if (oldData.comment && oldData.comment.parent) {
// put comments (replies) onto the oldData.comment.parent object
// the initial comment permalinked was a reply
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id');
comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')};
} else if (oldData.comment) {
// put the comments (replies) directly onto oldData.comment
// the initial comment permalinked was a top-level comment
const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id');
comment.replies = sortBy(uniqReplies, 'created_at');
}
updatedAsset = {
...oldData,
comment: {
...oldData.comment,
...comment
}
};
} else if (parent_id) { // If loading more replies
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: oldData.asset.comments.map(comment => {
// since the dipslayed replies and the returned replies can overlap,
// pull out the unique ones.
const uniqueReplies = uniqBy([...new_top_level_comments, ...comment.replies], 'id');
// since we just gave the returned replies precedence, they're now out of order.
// resort according to date.
return comment.id === parent_id
? {...comment, replies: sortBy(uniqueReplies, 'created_at')}
: comment;
})
}
};
} else { // If loading more top-level comments
updatedAsset = {
...oldData,
asset: {
...oldData.asset,
comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments]
: [...oldData.asset.comments, ...new_top_level_comments]
}
};
}
return updatedAsset;
}
});
};
export const variablesForStreamQuery = ({auth}) => {
// where the query string is from the embeded iframe url
let comment_id = getQueryVariable('comment_id');
let has_comment = comment_id != null;
return {
asset_id: getQueryVariable('asset_id'),
asset_url: getQueryVariable('asset_url'),
comment_id: has_comment ? comment_id : 'no-comment',
has_comment,
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
};
};
// load the comment stream.
export const queryStream = graphql(STREAM_QUERY, {
options: (props) => {
return {
variables: variablesForStreamQuery(props)
};
},
props: ({data}) => ({
data,
loadMore: loadMore(data),
getCounts: getCounts(data),
})
});
export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {});
@@ -1,11 +0,0 @@
#import "../fragments/commentView.graphql"
query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
...commentView
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3) {
...commentView
}
}
}
@@ -1,53 +0,0 @@
#import "../fragments/commentView.graphql"
query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!, $excludeIgnored: Boolean) {
# the comment here is for loading one comment and it's children, probably after following a permalink
# $has_comment is derived from the comment_id query param in the iframe url,
# which is in turn pulled from the host page url
comment(id: $comment_id) @include(if: $has_comment) {
...commentView
replyCount(excludeIgnored: $excludeIgnored)
replies {
...commentView
}
parent {
...commentView
replyCount(excludeIgnored: $excludeIgnored)
replies {
...commentView
}
}
}
asset(id: $asset_id, url: $asset_url) {
id
title
url
closedAt
created_at
settings {
moderation
infoBoxEnable
infoBoxContent
premodLinksEnable
questionBoxEnable
questionBoxContent
closeTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
lastComment {
id
}
commentCount(excludeIgnored: $excludeIgnored)
totalCommentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10, excludeIgnored: $excludeIgnored) {
...commentView
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
...commentView
}
}
}
}
+54
View File
@@ -1,7 +1,11 @@
import React from 'react';
import merge from 'lodash/merge';
import flatten from 'lodash/flatten';
import flattenDeep from 'lodash/flattenDeep';
import uniq from 'lodash/uniq';
import plugins from 'pluginsConfig';
import {gql} from 'react-apollo';
import {getDefinitionName} from 'coral-framework/utils';
export const pluginReducers = merge(
...plugins
@@ -19,3 +23,53 @@ export function getSlotElements(slot, props = {}) {
return components
.map((component, i) => React.createElement(component, {...props, key: i}));
}
function getComponentFragments(components) {
return 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 += `...${getDefinitionName(fragments[key])}\n`;
res[key].definitions = gql`${res[key].definitions}${fragments[key]}`;
});
return res;
}, {});
}
/**
* Returns an object that can be used to compose fragments or queries.
*
* Example:
* const pluginFragments = getSlotsFragments(['commentInfoBar', 'commentActions']);
* const rootFragment = gql`
* fragment Comment_root on RootQuery {
+ ${pluginFragments.spreads('root')}
* }
* ${pluginFragments.definitions('root')}
* `;
*/
export function getSlotsFragments(slots) {
if (!Array.isArray(slots)) {
slots = [slots];
}
const components = uniq(flattenDeep(slots.map(slot => {
return plugins
.filter(o => o.module.slots[slot])
.map(o => o.module.slots[slot]);
})));
const fragments = getComponentFragments(components);
return {
spreads(key) {
return (fragments[key] && fragments[key].spreads) || '';
},
definitions(key) {
return (fragments[key] && fragments[key].definitions) || '';
},
};
}
@@ -0,0 +1,18 @@
import React from 'react';
// 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() {
return <WrappedComponent {...this.props} />;
}
}
WithFragments.fragments = fragments;
WithFragments.displayName = `WithFragments(${getDisplayName(WrappedComponent)})`;
return WithFragments;
};
-3
View File
@@ -19,9 +19,6 @@ export default function asset (state = initialState, action) {
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return state
.setIn(['settings'], action.settings);
case actions.UPDATE_COUNT_CACHE:
return state
.setIn(['countCache', action.id], action.count);
default:
return state;
}
+8 -1
View File
@@ -8,6 +8,7 @@ const initialState = Map({
user: null,
showSignInDialog: false,
showCreateUsernameDialog: false,
checkedInitialLogin: false,
view: 'SIGNIN',
error: '',
passwordRequestSuccess: null,
@@ -71,10 +72,12 @@ export default function auth (state = initialState, action) {
.set('isLoading', true);
case actions.CHECK_LOGIN_FAILURE:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', false)
.set('user', null);
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', true)
.set('isAdmin', action.isAdmin)
.set('user', purge(action.user));
@@ -114,7 +117,11 @@ export default function auth (state = initialState, action) {
.set('isLoading', false)
.set('successSignUp', true);
case actions.LOGOUT_SUCCESS:
return initialState;
return state
.set('user', null)
.set('isLoading', false)
.set('loggedIn', false)
.set('isAdmin', false);
case actions.INVALID_FORM:
return state
.set('error', action.error);
+13 -1
View File
@@ -1,6 +1,18 @@
import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
// TODO: replace absolute reference with something loaded from the store/page.
// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', {
// reconnect: true
// });
// const networkInterface = addGraphQLSubscriptions(
// getNetworkInterface(),
// wsClient,
// );
const networkInterface = getNetworkInterface();
export const client = new ApolloClient({
connectToDevTools: true,
queryTransformer: addTypename,
@@ -10,7 +22,7 @@ export const client = new ApolloClient({
}
return null;
},
networkInterface: getNetworkInterface()
networkInterface
});
export default client;
+13 -5
View File
@@ -24,14 +24,22 @@ if (window.devToolsExtension) {
middlewares.push(window.devToolsExtension());
}
const store = createStore(
combineReducers({
...mainReducer,
apollo: client.reducer()
}),
let storeReducers = {
...mainReducer,
apollo: client.reducer()
};
export const store = createStore(
combineReducers(storeReducers),
{},
compose(...middlewares)
);
export default store;
export function injectReducers(reducers) {
storeReducers = {...storeReducers, ...reducers};
store.replaceReducer(combineReducers(storeReducers));
}
window.coralStore = store;
+32
View File
@@ -29,3 +29,35 @@ export const getMyActionSummary = (type, comment) => {
export const getActionSummary = (type, comment) => {
return comment.action_summaries.filter(a => a.__typename === type);
};
/**
* Get name of first (or $pos-th) definition
*/
export function getDefinitionName(doc, pos = 0) {
return doc.definitions[pos].name.value;
}
/**
* Separate apollo `data` props into `data` and `root`.
* `data` will contain props like `loading`, `fetchMore`...
* while `root` contains the actual query data.
*/
export function separateDataAndRoot(
{
fetchMore,
loading,
networkStatus,
refetch,
startPolling,
stopPolling,
subscribeToMore,
updateQuery,
variables,
...root,
}) {
return {
data: {fetchMore, loading, networkStatus, refetch, startPolling,
stopPolling, subscribeToMore, updateQuery, variables},
root,
};
}
+17 -15
View File
@@ -1,13 +1,13 @@
import React, {Component, PropTypes} from 'react';
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 {Button} from 'coral-ui';
import Slot from 'coral-framework/components/Slot';
import {connect} from 'react-redux';
const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
class CommentBox extends React.Component {
constructor(props) {
super(props);
@@ -24,14 +24,14 @@ class CommentBox extends Component {
postComment = () => {
const {
commentPostedHandler,
postItem,
setCommentCountCache,
commentCountCache,
isReply,
assetId,
parentId,
postItem,
countCache,
addNotification,
updateCountCache,
commentPostedHandler
} = this.props;
let comment = {
@@ -41,7 +41,7 @@ class CommentBox extends Component {
...this.props.commentBox
};
!isReply && updateCountCache(assetId, countCache + 1);
!isReply && setCommentCountCache(commentCountCache + 1);
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach(hook => hook());
@@ -55,18 +55,20 @@ class CommentBox extends Component {
if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
!isReply && updateCountCache(assetId, countCache);
!isReply && setCommentCountCache(commentCountCache);
} else if (postedComment.status === 'PREMOD') {
addNotification('success', lang.t('comment-post-notif-premod'));
!isReply && updateCountCache(assetId, countCache);
!isReply && setCommentCountCache(commentCountCache);
}
if (commentPostedHandler) {
commentPostedHandler();
}
})
.catch((err) => console.error(err));
.catch((err) => {
console.error(err);
!isReply && setCommentCountCache(commentCountCache);
});
this.setState({body: ''});
}
@@ -149,13 +151,14 @@ class CommentBox extends Component {
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="commentBoxDetail"
fill="commentInputDetailArea"
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
inline
@@ -196,7 +199,6 @@ CommentBox.propTypes = {
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
currentUser: PropTypes.object
};
const mapStateToProps = ({commentBox}) => ({commentBox});
@@ -3,7 +3,7 @@
"post": "Post",
"cancel": "Cancel",
"reply": "Reply",
"comment": "Post a Comment",
"comment": "Post a comment",
"name": "Name",
"comment-post-notif": "Your comment has been posted.",
"comment-post-notif-premod": "Thank you for posting. Our moderation team will review your comment shortly.",
@@ -14,7 +14,7 @@
"post": "Publicar",
"cancel": "Cancelar",
"reply": "Responder",
"comment": "Publicar un Comentario",
"comment": "Publicar un comentario",
"name": "Nombre",
"comment-post-notif": "Tu comentario ha sido publicado.",
"comment-post-notif-premod": "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto.",
+11 -2
View File
@@ -19,6 +19,12 @@ class FlagButton extends Component {
localDelete: false
}
componentDidUpdate () {
if (this.popup) { // this will be defined when the reporting popup is opened
this.popup.firstChild.style.top = `${this.flagButton.offsetTop - this.popup.firstChild.clientHeight - 15}px`;
}
}
// When the "report" button is clicked expand the menu
onReportClick = () => {
const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props;
@@ -135,7 +141,10 @@ class FlagButton extends Component {
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
<button onClick={!this.props.banned ? this.onReportClick : null} className={`${name}-button`}>
<button
ref={ref => this.flagButton = ref}
onClick={!this.props.banned ? this.onReportClick : null}
className={`${name}-button`}>
{
flagged
? <span className={`${name}-button-text`}>{lang.t('reported')}</span>
@@ -147,7 +156,7 @@ class FlagButton extends Component {
</button>
{
this.state.showMenu &&
<div className={`${name}-popup`}>
<div className={`${name}-popup`} ref={ref => this.popup = ref}>
<PopupMenu>
<div className={`${name}-popup-header`}>{popupMenu.header}</div>
{
@@ -1,5 +1,6 @@
import React from 'react';
const packagename = 'coral-plugin-questionbox';
import Slot from 'coral-framework/components/Slot';
const QuestionBox = ({enable, content}) =>
<div className={`${packagename}-info ${enable ? null : 'hidden'}` }>
@@ -10,6 +11,7 @@ const QuestionBox = ({enable, content}) =>
<div className={`${packagename}-content`}>
{content}
</div>
<Slot fill="streamQuestionArea" />
</div>;
export default QuestionBox;
@@ -12,7 +12,6 @@ import NotLoggedIn from '../components/NotLoggedIn';
import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'coral-plugin-history/CommentHistory';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
const lang = new I18n();
@@ -34,14 +33,14 @@ class ProfileContainer extends Component {
}
render() {
const {loggedIn, asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props;
const {asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props;
const {me} = this.props.data;
if (data.loading) {
return <Spinner/>;
}
if (!loggedIn || !me) {
if (!me) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
@@ -50,7 +49,7 @@ class ProfileContainer extends Component {
return (
<div>
<h2>{this.props.userData.username}</h2>
<h2>{this.props.user.username}</h2>
{ emailAddress
? <p>{ emailAddress }</p>
: null
@@ -1,5 +1,5 @@
import React from 'react';
import styles from 'coral-embed-stream/src/Comment.css';
import styles from 'coral-embed-stream/src/components/Comment.css';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import Content from 'coral-plugin-commentcontent/CommentContent';
+3 -3
View File
@@ -4,11 +4,11 @@ import I18n from 'coral-i18n/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const UserBox = ({className, user, logout, changeTab}) => (
const UserBox = ({className, user, onLogout, onShowProfile}) => (
<div className={`${styles.userBox} ${className ? className : ''}`}>
{lang.t('signIn.loggedInAs')}
<a onClick={() => changeTab(1)}>{user.username}</a>. {lang.t('signIn.notYou')}
<a className={styles.logout} onClick={logout} id='logout'>{lang.t('signIn.logout')}</a>
<a onClick={onShowProfile}>{user.username}</a>. {lang.t('signIn.notYou')}
<a className={styles.logout} onClick={onLogout} id='logout'>{lang.t('signIn.logout')}</a>
</div>
);
+1 -1
View File
@@ -149,7 +149,7 @@
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
width: 128px;
&:hover {
&:hover {
color: white;
background-color: #D03235;
box-shadow: none;
+5 -1
View File
@@ -1,8 +1,12 @@
import React from 'react';
import React, {PropTypes} from 'react';
import {Icon as IconMDL} from 'react-mdl';
const Icon = ({className = '', name}) => (
<IconMDL className={className} name={name} />
);
Icon.propTypes = {
name: PropTypes.string.isRequired
};
export default Icon;
+7 -3
View File
@@ -1,13 +1,17 @@
.popupMenu {
display: inline-block;
width: inherit;
white-space: normal;
display: block;
position: absolute;
max-width: 98%;
min-width: 50%;
border: solid 1px #999;
box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3);
box-sizing: border-box;
background: white;
border-radius: 3px;
padding: 20px 10px;
z-index: 3;
z-index: 300;
right: 1%;
}
.popupMenu:before{
+1 -1
View File
@@ -2,5 +2,5 @@ import React from 'react';
import styles from './PopupMenu.css';
export default ({children}) => (
<span className={styles.popupMenu}>{children}</span>
<div className={styles.popupMenu}>{children}</div>
);
+1 -1
View File
@@ -106,7 +106,7 @@ class ErrAuthentication extends APIError {
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new APIError('Suspected profanity. If you think this in error, please let us know!', {
const ErrContainsProfanity = new APIError('This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.', {
translation_key: 'PROFANITY_ERROR',
status: 400
});
+8 -1
View File
@@ -1,5 +1,6 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const uuid = require('uuid');
const plugins = require('../services/plugins');
const debug = require('debug')('talk:graph:context');
@@ -31,7 +32,10 @@ const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduc
* Stores the request context.
*/
class Context {
constructor({user = null}) {
constructor({user = null}, pubsub) {
// Generate a new context id for the request.
this.id = uuid.v4();
// Load the current logged in user to `user`, otherwise this'll be null.
if (user) {
@@ -46,6 +50,9 @@ class Context {
// Decorate the plugin context.
this.plugins = decorateContextPlugins(this, contextPlugins);
// Bind the publish/subscribe to the context.
this.pubsub = pubsub;
}
}
+5 -2
View File
@@ -1,5 +1,7 @@
const schema = require('./schema');
const Context = require('./context');
const pubsub = require('./pubsub');
const {createSubscriptionManager} = require('./subscriptions');
module.exports = {
createGraphOptions: (req) => ({
@@ -9,6 +11,7 @@ module.exports = {
// Load in the new context here, this'll create the loaders + mutators for
// the lifespan of this request.
context: new Context(req)
})
context: new Context(req, pubsub)
}),
createSubscriptionManager
};
+7 -1
View File
@@ -16,7 +16,7 @@ const Wordlist = require('../../services/wordlist');
* @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => {
const createComment = ({user, loaders: {Comments}, pubsub}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => {
// Building array of tags
tags = tags.map(tag => ({name: tag}));
@@ -47,6 +47,12 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
Comments.parentCountByAssetID.incr(asset_id);
}
Comments.countByAssetID.incr(asset_id);
if (pubsub) {
// Publish the newly added comment via the subscription.
pubsub.publish('commentAdded', comment);
}
}
return comment;
+5
View File
@@ -0,0 +1,5 @@
const {RedisPubSub} = require('graphql-redis-subscriptions');
const {connectionOptions} = require('../services/redis');
module.exports = new RedisPubSub(connectionOptions);
+4
View File
@@ -5,6 +5,10 @@ module.exports = new GraphQLScalarType({
name: 'Date',
description: 'Date represented as an ISO8601 string',
serialize(value) {
if (typeof value === 'string') {
return value;
}
return value.toISOString();
},
parseValue(value) {
+2
View File
@@ -16,6 +16,7 @@ const LikeAction = require('./like_action');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
const Subscription = require('./subscription');
const UserError = require('./user_error');
const User = require('./user');
const ValidationUserError = require('./validation_user_error');
@@ -39,6 +40,7 @@ let resolvers = {
RootMutation,
RootQuery,
Settings,
Subscription,
UserError,
User,
ValidationUserError,
+7
View File
@@ -0,0 +1,7 @@
const Subscription = {
commentAdded(comment) {
return comment;
}
};
module.exports = Subscription;
+60
View File
@@ -0,0 +1,60 @@
const {SubscriptionManager} = require('graphql-subscriptions');
const {SubscriptionServer} = require('subscriptions-transport-ws');
const _ = require('lodash');
const pubsub = require('./pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
// Core setup functions
let setupFunctions = {
commentAdded: (options, args) => ({
commentAdded: {
filter: (comment) => comment.asset_id === args.asset_id
},
}),
};
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
* as provide new ones.
*/
setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => {
return _.merge(acc, setupFunctions);
}, setupFunctions);
/**
* This creates a new subscription manager.
*/
const createSubscriptionManager = (server) => new SubscriptionServer({
subscriptionManager: new SubscriptionManager({
schema,
pubsub,
setupFunctions,
}),
onSubscribe: (parsedMessage, baseParams, connection) => {
// Attach the context per request.
baseParams.context = () => deserializeUser(connection.upgradeReq)
.then((req) => new Context(req, pubsub))
.catch((err) => {
console.error(err);
return new Context({}, pubsub);
});
return baseParams;
}
}, {
server,
path: '/api/v1/live'
});
module.exports = {
createSubscriptionManager
};
+9
View File
@@ -820,6 +820,14 @@ type RootMutation {
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
}
################################################################################
## Subscriptions
################################################################################
type Subscription {
commentAdded(asset_id: ID!): Comment
}
################################################################################
## Schema
################################################################################
@@ -827,4 +835,5 @@ type RootMutation {
schema {
query: RootQuery
mutation: RootMutation
subscription: Subscription
}
+7 -1
View File
@@ -123,7 +123,13 @@ const UserSchema = new mongoose.Schema({
// user id of another user
type: String,
}]
}],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object
}
}, {
// This will ensure that we have proper timestamps available on this model.
View File
+17 -9
View File
@@ -5,8 +5,8 @@
"main": "app.js",
"scripts": {
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"start": "./bin/cli serve --jobs",
"dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"",
"start": "./bin/cli serve -j -w",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"./bin/cli -c .env serve -j -w\"",
"build": "NODE_ENV=production webpack -p --config webpack.config.js --bail",
"build-watch": "NODE_ENV=development webpack --progress --config webpack.config.js --watch",
"lint": "eslint bin/* .",
@@ -68,10 +68,12 @@
"express-session": "^1.15.1",
"form-data": "^2.1.2",
"gql-merge": "^0.0.4",
"graphql": "^0.8.2",
"graphql": "^0.9.1",
"graphql-errors": "^2.1.0",
"graphql-server-express": "^0.5.0",
"graphql-tools": "^0.9.0",
"graphql-redis-subscriptions": "^1.1.5",
"graphql-server-express": "^0.6.0",
"graphql-subscriptions": "^0.3.1",
"graphql-tools": "^0.10.1",
"helmet": "^3.5.0",
"inquirer": "^3.0.6",
"joi": "^10.4.1",
@@ -84,24 +86,29 @@
"minimist": "^1.2.0",
"mongoose": "^4.9.1",
"morgan": "^1.8.1",
"natural": "^0.4.0",
"natural": "^0.5.0",
"node-emoji": "^1.5.1",
"node-fetch": "^1.6.3",
"nodemailer": "^2.6.4",
"parse-duration": "^0.1.1",
"passport": "^0.3.2",
"passport-local": "^1.0.0",
"react-apollo": "^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",
"uuid": "^3.0.1",
"simplemde": "^1.11.2",
"subscriptions-transport-ws": "^0.5.5-alpha.0",
"resolve": "^1.3.2",
"semver": "^5.3.0",
"simplemde": "^1.11.2",
"uuid": "^2.0.3",
"uuid": "^3.0.1",
"yamljs": "^0.2.10"
},
"devDependencies": {
"apollo-client": "^1.0.0",
"apollo-client": "^1.0.4",
"autoprefixer": "^6.5.2",
"babel-cli": "^6.24.0",
"babel-core": "^6.24.0",
@@ -178,6 +185,7 @@
"regenerator": "^0.8.46",
"selenium-standalone": "^5.11.2",
"style-loader": "^0.16.0",
"subscriptions-transport-ws": "^0.5.5-alpha.0",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
"webpack": "^2.3.1"
@@ -3,7 +3,7 @@ import OffTopicTag from './components/OffTopicTag';
export default {
slots: {
commentBoxDetail: [OffTopicCheckbox],
commentInputDetailArea: [OffTopicCheckbox],
commentInfoBar: [OffTopicTag]
}
};
@@ -12,8 +12,8 @@ const lang = new I18n(translations);
class RespectButton extends Component {
handleClick = () => {
const {postRespect, showSignInDialog, deleteAction, commentId} = this.props;
const {me, comment} = this.props.data;
const {postRespect, showSignInDialog, deleteAction} = this.props;
const {root: {me}, comment} = this.props;
const myRespectActionSummary = getMyActionSummary('RespectActionSummary', comment);
@@ -29,17 +29,17 @@ class RespectButton extends Component {
}
if (myRespectActionSummary) {
deleteAction(myRespectActionSummary.current_user.id);
deleteAction(myRespectActionSummary.current_user.id, comment.id);
} else {
postRespect({
item_id: commentId,
item_id: comment.id,
item_type: 'COMMENTS'
});
}
}
render() {
const {comment} = this.props.data;
const {comment} = this.props;
if (!comment) {
return null;
@@ -2,37 +2,25 @@ import {compose, gql, graphql} 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 {showSignInDialog} from 'coral-framework/actions/auth';
import RespectButton from '../components/RespectButton';
// TODO: use `update` instead of `updateQueries` for optimistic mutations.
// See https://dev-blog.apollodata.com/apollo-clients-new-imperative-store-api-6cb69318a1e3
// and https://github.com/apollographql/apollo-client/issues/1224
const isRespectAction = (a) => a.__typename === 'RespectActionSummary';
export const RESPECT_QUERY = gql`
query RespectQuery($commentId: ID!) {
comment(id: $commentId) {
id
action_summaries {
... on RespectActionSummary {
count
current_user {
id
}
const COMMENT_FRAGMENT = gql`
fragment RespectButton_updateFragment on Comment {
action_summaries {
... on RespectActionSummary {
count
current_user {
id
}
}
}
me {
status
}
}
`;
const withQuery = graphql(RESPECT_QUERY);
const withDeleteAction = graphql(gql`
mutation deleteAction($id: ID!) {
deleteAction(id:$id) {
@@ -43,7 +31,7 @@ const withDeleteAction = graphql(gql`
}
`, {
props: ({mutate}) => ({
deleteAction: (id) => {
deleteAction: (id, commentId) => {
return mutate({
variables: {id},
optimisticResponse: {
@@ -52,27 +40,26 @@ const withDeleteAction = graphql(gql`
errors: null,
}
},
updateQueries: {
RespectQuery: (prev) => {
const action_summaries = prev.comment.action_summaries;
const idx = action_summaries.findIndex(isRespectAction);
if (idx < 0 || get(action_summaries[idx], 'current_user.id') !== id) {
return prev;
}
const next = {
...prev,
comment: {
...prev.comment,
action_summaries: action_summaries.map(
(a, i) => i !== idx ? a : ({
...a,
count: a.count - 1,
current_user: null,
})),
}
};
return next;
},
update: (proxy) => {
const fragmentId = `Comment_${commentId}`;
// Read the data from our cache for this query.
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
// Check whether we respected this comment.
const idx = data.action_summaries.findIndex(isRespectAction);
if (idx < 0 || get(data.action_summaries[idx], 'current_user.id') !== 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});
},
});
},
@@ -105,46 +92,39 @@ const withPostRespect = graphql(gql`
},
}
},
updateQueries: {
RespectQuery: (prev, {mutationResult, queryVariables}) => {
if (queryVariables.commentId !== respect.item_id) {
return prev;
}
update: (proxy, mutationResult) => {
const fragmentId = `Comment_${respect.item_id}`;
let action_summaries = prev.comment.action_summaries;
let idx = action_summaries.findIndex(isRespectAction);
// Read the data from our cache for this query.
const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId});
// Check whether we already respected this comment.
if (idx >= 0 && action_summaries[idx].current_user) {
return prev;
}
// Add our comment from the mutation to the end.
let idx = data.action_summaries.findIndex(isRespectAction);
if (idx < 0) {
// Check whether we already respected this comment.
if (idx >= 0 && data.action_summaries[idx].current_user) {
return;
}
// Add initial action when it doesn't exist.
action_summaries = action_summaries.concat([{
__typename: 'RespectActionSummary',
count: 0,
current_user: null,
}]);
idx = action_summaries.length - 1;
}
if (idx < 0) {
const respectAction = mutationResult.data.createRespect.respect;
const next = {
...prev,
comment: {
...prev.comment,
action_summaries: action_summaries.map(
(a, i) => i !== idx ? a : ({
...a,
count: a.count + 1,
current_user: respectAction,
})),
}
};
return next;
},
// Add initial action when it doesn't exist.
data.action_summaries.push({
__typename: 'RespectActionSummary',
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.createRespect.respect,
};
// Write our data back to the cache.
proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data});
},
});
},
@@ -155,10 +135,29 @@ const mapDispatchToProps = dispatch =>
bindActionCreators({showSignInDialog}, dispatch);
const enhance = compose(
withFragments({
root: gql`
fragment RespectButton_root on RootQuery {
me {
status
}
}
`,
comment: gql`
fragment RespectButton_comment on Comment {
action_summaries {
... on RespectActionSummary {
count
current_user {
id
}
}
}
}`,
}),
connect(null, mapDispatchToProps),
withDeleteAction,
withPostRespect,
withQuery,
);
export default enhance(RespectButton);
+1 -1
View File
@@ -2,6 +2,6 @@ import RespectButton from './containers/RespectButton';
export default {
slots: {
commentDetail: [RespectButton],
commentActions: [RespectButton],
}
};
+27 -23
View File
@@ -2,31 +2,35 @@ const redis = require('redis');
const debug = require('debug')('talk:redis');
const url = process.env.TALK_REDIS_URL || 'redis://localhost';
const connectionOptions = {
url,
retry_strategy: function(options) {
if (options.error && options.error.code === 'ECONNREFUSED') {
// End reconnecting on a specific error and flush all commands with a individual error
return new Error('The server refused the connection');
}
if (options.total_retry_time > 1000 * 60 * 60) {
// End reconnecting after a specific timeout and flush all commands with a individual error
return new Error('Retry time exhausted');
}
if (options.times_connected > 10) {
// End reconnecting with built in error
return undefined;
}
// reconnect after
return Math.max(options.attempt * 100, 3000);
}
};
module.exports = {
connectionOptions,
createClient() {
let client = redis.createClient(url, {
retry_strategy: function(options) {
if (options.error && options.error.code === 'ECONNREFUSED') {
// End reconnecting on a specific error and flush all commands with a individual error
return new Error('The server refused the connection');
}
if (options.total_retry_time > 1000 * 60 * 60) {
// End reconnecting after a specific timeout and flush all commands with a individual error
return new Error('Retry time exhausted');
}
if (options.times_connected > 10) {
// End reconnecting with built in error
return undefined;
}
// reconnect after
return Math.max(options.attempt * 100, 3000);
}
});
let client = redis.createClient(connectionOptions);
client.ping((err) => {
if (err) {
+36
View File
@@ -0,0 +1,36 @@
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('./redis');
//==============================================================================
// SESSION MIDDLEWARE
//==============================================================================
const session_opts = {
secret: process.env.TALK_SESSION_SECRET,
httpOnly: true,
rolling: true,
saveUninitialized: true,
resave: true,
unset: 'destroy',
name: 'talk.sid',
cookie: {
secure: false,
maxAge: 8.64e+7, // 24 hours for session token expiry
},
store: new RedisStore({
client: redis.createClient(),
})
};
if (process.env.NODE_ENV === 'production') {
// Enable the secure cookie when we are in production mode.
session_opts.cookie.secure = true;
} else if (process.env.NODE_ENV === 'test') {
// Add in the secret during tests.
session_opts.secret = 'keyboard cat';
}
module.exports = session(session_opts);
+36
View File
@@ -0,0 +1,36 @@
const session = require('./session');
const passport = require('./passport');
// Session data does not automatically attach to websocket req objects.
// This middleware code looks for a user in the session and, if it exists,
// attaches it to the graph req.
const deserializeUser = (req) => {
return new Promise((resolve, reject) => {
session(req, {}, () => {
if ('session' in req && 'passport' in req.session && 'user' in req.session.passport) {
passport.deserializeUser(req.session.passport.user, (err, user) => {
if (err) {
return reject(err);
}
req.user = user;
return resolve(req);
});
}
// Remove the user from the request (if there was one)
if (req.user) {
delete req.user;
}
// Resolve with the request (user removed possibly).
return resolve(req);
});
});
};
module.exports = {
deserializeUser
};

Some files were not shown because too many files have changed in this diff Show More