mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 22:13:31 +08:00
Merge branch 'master' into i18n-refactor
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,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 = (
|
||||
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
};
|
||||
+21
-4
@@ -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
-1
@@ -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);
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const {RedisPubSub} = require('graphql-redis-subscriptions');
|
||||
|
||||
const {connectionOptions} = require('../services/redis');
|
||||
|
||||
module.exports = new RedisPubSub(connectionOptions);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const Subscription = {
|
||||
commentAdded(comment) {
|
||||
return comment;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Subscription;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+17
-9
@@ -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);
|
||||
|
||||
@@ -2,6 +2,6 @@ import RespectButton from './containers/RespectButton';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
commentDetail: [RespectButton],
|
||||
commentActions: [RespectButton],
|
||||
}
|
||||
};
|
||||
|
||||
+27
-23
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user