Merge branch 'master' into feature/heroku-deploy

This commit is contained in:
Jeff Nelson
2017-08-25 09:56:55 -04:00
174 changed files with 3281 additions and 1854 deletions
+14 -2
View File
@@ -5,9 +5,21 @@ Online comments are broken. Our open-source Talk tool rethinks how moderation, c
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
## Documentation
## Important Links
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/).
- Developer Documentation & Setup Guides: https://coralproject.github.io/talk/
- Pivotal Tracker Backlog & Release Schedule: https://www.pivotaltracker.com/n/projects/1863625
## Learn More about Coral
- Community Forums: https://community.coralproject.net/
- Website: https://coralproject.net
- Blog: https://blog.coralproject.net
- Community Guides for Journalism: https://guides.coralproject.net/
## License
+26 -160
View File
@@ -3,71 +3,42 @@ const bodyParser = require('body-parser');
const morgan = require('morgan');
const path = require('path');
const helmet = require('helmet');
const authentication = require('./middleware/authentication');
const {passport} = require('./services/passport');
const plugins = require('./services/plugins');
const pubsub = require('./services/pubsub');
const i18n = require('./services/i18n');
const enabled = require('debug').enabled;
const errors = require('./errors');
const {createGraphOptions} = require('./graph');
const apollo = require('graphql-server-express');
const accepts = require('accepts');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const {ROOT_URL} = require('./config');
const {BASE_URL, BASE_PATH, MOUNT_PATH} = require('./url');
const routes = require('./routes');
const debug = require('debug')('talk:app');
const app = express();
// Middleware declarations.
//==============================================================================
// APPLICATION WIDE MIDDLEWARE
//==============================================================================
// Add the logging middleware only if we aren't testing.
if (app.get('env') !== 'test') {
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
//==============================================================================
// APP MIDDLEWARE
//==============================================================================
// Trust the first proxy in front of us, this will enable us to trust the fact
// that SSL was terminated correctly.
app.set('trust proxy', 1);
// We disable frameward on helmet to allow crossdomain injection of the embed
// Enable a suite of security good practices through helmet. We disable
// frameguard to allow crossdomain injection of the embed.
app.use(helmet({
frameguard: false
frameguard: false,
}));
// Compress the responses if appropriate.
app.use(compression());
// Parse the cookies on the request.
app.use(cookieParser());
// Parse the body json if it's there.
app.use(bodyParser.json());
//==============================================================================
// STATIC FILES
//==============================================================================
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
app.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjsut the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to decalre support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
app.use('/client', express.static(path.join(__dirname, 'dist')));
app.use('/public', express.static(path.join(__dirname, 'public')));
//==============================================================================
// VIEW CONFIGURATION
//==============================================================================
@@ -75,124 +46,19 @@ app.use('/public', express.static(path.join(__dirname, 'public')));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Set the BASE_URL as the ROOT_URL.
app.locals.BASE_URL = ROOT_URL;
if (app.locals.BASE_URL[app.locals.BASE_URL.length - 1] !== '/') {
app.locals.BASE_URL += '/';
}
//==============================================================================
// PASSPORT MIDDLEWARE
//==============================================================================
const passportDebug = require('debug')('talk:passport');
// Install the passport plugins.
plugins.get('server', 'passport').forEach((plugin) => {
passportDebug(`added plugin '${plugin.plugin.name}'`);
// Pass the passport.js instance to the plugin to allow it to inject it's
// functionality.
plugin.passport(passport);
});
// Setup the PassportJS Middleware.
app.use(passport.initialize());
// Attach the authentication middleware, this will be responsible for decoding
// (if present) the JWT on the request.
app.use('/api', authentication);
const pubsubClient = pubsub.createClientFactory();
// To handle dependancy injection safer, we inject the pubsub handle onto the
// request object.
app.use('/api', (req, res, next) => {
// Attach the pubsub handle to the requests.
req.pubsub = pubsubClient();
// Forward on the request.
next();
});
//==============================================================================
// GraphQL Router
//==============================================================================
// GraphQL endpoint.
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') {
// Interactive graphiql interface.
app.use('/api/v1/graph/iql', (req, res) => {
res.render('graphiql', {
endpointURL: '/api/v1/graph/ql'
});
});
// GraphQL documention.
app.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
}
//==============================================================================
// ROUTES
//==============================================================================
app.use('/', require('./routes'));
// Apply the BASE_PATH, BASE_URL, and MOUNT_PATH on the app.locals, which will
// make them available on the templates and the routers.
app.locals.BASE_URL = BASE_URL;
app.locals.BASE_PATH = BASE_PATH;
app.locals.MOUNT_PATH = MOUNT_PATH;
//==============================================================================
// ERROR HANDLING
//==============================================================================
debug(`mounting routes on the ${MOUNT_PATH} path`);
// Catch 404 and forward to error handler.
app.use((req, res, next) => {
next(errors.ErrNotFound);
});
// General error handler. Respond with the message and error if we have it while
// returning a status code that makes sense.
app.use('/api', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
if (app.get('env') !== 'test' || enabled('talk:errors')) {
console.error(err);
}
}
if (err instanceof errors.APIError) {
res.status(err.status).json({
message: err.message,
error: err
});
} else {
res.status(500).json({});
}
});
app.use('/', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
console.error(err);
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
message: err.message,
error: app.get('env') === 'development' ? err : {}
});
} else {
res.render('error', {
message: err.message,
error: app.get('env') === 'development' ? err : {}
});
}
});
// Actually apply the routes.
app.use(MOUNT_PATH, routes);
module.exports = app;
+32 -6
View File
@@ -94,7 +94,7 @@ const performSetup = async () => {
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
},
]);
// Update the settings that were changed.
@@ -104,6 +104,32 @@ const performSetup = async () => {
}
});
answers = await inquirer.prompt([
{
type: 'confirm',
name: 'inputWhitelistedDomains',
default: true,
message: 'Would you like to specify a whitelisted domain'
},
{
type: 'input',
name: 'whitelistedDomain',
message: 'Whitelisted Domain',
when: ({inputWhitelistedDomains}) => inputWhitelistedDomains,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Whitelisted Domain cannot be empty.';
}
}
]);
if (answers.inputWhitelistedDomains) {
settings.domains.whitelist = [answers.whitelistedDomain];
}
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
let user = await inquirer.prompt([
@@ -147,7 +173,11 @@ const performSetup = async () => {
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
filter: (confirmPassword, {password}) => {
if (password !== confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
@@ -157,10 +187,6 @@ const performSetup = async () => {
},
]);
if (user.password !== user.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
let {user: newUser} = await SetupService.setup({
settings: settings.toObject(),
user: {
+4 -20
View File
@@ -37,27 +37,11 @@ const routes = (
<Route path='moderate' component={ModerationLayout}>
<IndexRoute components={Moderation} />
<Route path='all' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='new' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='approved' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='premod' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='rejected' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='reported' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path=':id' components={Moderation} />
<Route path=':tabOrId' components={Moderation} />
<Route path=':tab' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
</Route>
</Route>
</div>
+1 -1
View File
@@ -35,7 +35,7 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
+3 -3
View File
@@ -93,21 +93,21 @@ const validation = (formData, dispatch, next) => {
};
export const submitSettings = () => (dispatch, getState) => {
const settingsFormData = getState().install.toJS().data.settings;
const settingsFormData = getState().install.data.settings;
validation(settingsFormData, dispatch, function() {
dispatch(nextStep());
});
};
export const submitUser = () => (dispatch, getState) => {
const userFormData = getState().install.toJS().data.user;
const userFormData = getState().install.data.user;
validation(userFormData, dispatch, function() {
dispatch(nextStep());
});
};
export const finishInstall = () => (dispatch, getState) => {
const data = getState().install.toJS().data;
const data = getState().install.data;
dispatch(installRequest());
return coralApi('/setup', {method: 'POST', body: data})
.then(() => {
+1 -1
View File
@@ -42,7 +42,7 @@ export const updateDomainlist = (listName, list) => {
};
export const saveSettingsToServer = () => (dispatch, getState) => {
let settings = getState().settings.toJS();
let settings = getState().settings;
if (settings.charCount) {
settings.charCount = parseInt(settings.charCount);
}
@@ -8,18 +8,16 @@ export default ({suspectWords, bannedWords, body, ...rest}) => {
const links = linkify.getMatches(body);
const linkText = links ? links.map((link) => link.raw) : [];
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(body);
})
.concat(linkText);
const searchWords = [
...suspectWords,
...bannedWords,
...linkText
];
return (
<Highlighter
{...rest}
autoEscape={true}
searchWords={searchWords}
textToHighlight={body}
/>
@@ -3,11 +3,12 @@
color: white;
background: grey;
box-sizing: border-box;
padding: 2px 8px;
border-radius: 2px;
padding: 2px 5px;
font-size: 12px;
height: 28px;
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
> i {
font-size: 14px;
vertical-align: text-top;
@@ -1,6 +1,6 @@
.flagBox {
border-top: 1px solid rgba(66, 66, 66, 0.12);
margin-top: 10px;
.container {
padding: 0 14px;
}
@@ -0,0 +1,21 @@
.loadMoreContainer {
display: flex;
justify-content: center;
width: 100%;
}
.loadMore {
width: 100%;
text-align: center;
color: #FFF;
max-width: 660px;
margin-bottom: 30px;
background-color: #2376D8;
cursor: pointer;
}
.loadMore:hover {
background-color: #4399FF;
}
@@ -1,9 +1,10 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import styles from './styles.css';
import styles from './LoadMore.css';
import cn from 'classnames';
const LoadMore = ({loadMore, showLoadMore}) =>
<div className={styles.loadMoreContainer}>
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
{
showLoadMore && <Button
className={styles.loadMore}
@@ -186,7 +186,6 @@
.actionButton {
transform: scale(.8);
margin: 0;
width: 140px;
}
.minimal {
@@ -0,0 +1,11 @@
import React from 'react';
import {Badge} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const ReplyBadge = () => (
<Badge icon="reply">
{t('modqueue.reply')}
</Badge>
);
export default ReplyBadge;
@@ -1,6 +1,78 @@
.copyButton {
float: right;
top: -10px;
background-color: white;
border: solid 1px;
padding: 2px 6px;
height: auto;
line-height: initial;
min-width: auto;
letter-spacing: normal;
font-size: 0.9em;
margin-left: 10px;
}
.userDetailList {
list-style: none;
padding: 0;
margin: 0;
}
.userDetailItem {
margin: 0 5px;
font-weight: 500;
}
.stats {
display: flex;
list-style: none;
padding: 0;
margin: 0;
text-align: center;
margin: 15px 0 5px;
color: #595959;
}
.stat {
margin-right: 20px;
}
.stat:last-child {
margin-right: 0px;
}
.statItem, .statReportResult {
padding: 3px 5px;
background-color: #D8D8D8;
border-radius: 3px;
font-weight: 500;
display: block;
font-size: 0.9em;
line-height: normal;
letter-spacing: 0.4px;
min-width: 60px;
}
.statResult {
font-size: 1.5em;
padding: 5px 0;
display: inline-block;
}
.statReportResult {
color: white;
margin: 5px 0;
font-weight: 400;
}
.statReportResult.reliable {
background-color: #749C48;
}
.statReportResult.neutral {
background-color: #616161;
}
.statReportResult.unreliable {
background-color: #F44336;
}
.memberSince {
@@ -8,27 +80,9 @@
}
.small {
color: #aaa;
}
.stats {
display: flex;
.stat {
margin: 0 4px 10px 0px;
}
.stat:last-child {
margin-right: 0;
}
p {
margin: 0;
}
.stat p:first-child {
font-weight: bold;
}
color: #888888;
font-size: 0.9em;
letter-spacing: 0.4px;
}
.profileEmail {
@@ -79,3 +133,12 @@
margin-left: -10px;
}
}
.loadMore > button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
+54 -30
View File
@@ -1,11 +1,16 @@
import React, {PropTypes} from 'react';
import Comment from './UserDetailComment';
import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import styles from './UserDetail.css';
import {Button, Drawer, Spinner} from 'coral-ui';
import {Icon, Button, Drawer, Spinner} from 'coral-ui';
import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import {actionsMap} from '../utils/moderationQueueActionsMap';
import ClickOutside from 'coral-framework/components/ClickOutside';
import LoadMore from '../components/LoadMore';
import cn from 'classnames';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
export default class UserDetail extends React.Component {
@@ -55,11 +60,12 @@ export default class UserDetail extends React.Component {
renderLoaded() {
const {
root,
root: {
user,
totalComments,
rejectedComments,
comments: {nodes}
comments: {nodes, hasNextPage}
},
activeTab,
selectedCommentIds,
@@ -70,15 +76,9 @@ export default class UserDetail extends React.Component {
bulkReject,
hideUserDetail,
viewUserDetail,
loadMore,
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
if (localProfile) {
profile = localProfile.id;
}
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
@@ -92,32 +92,51 @@ export default class UserDetail extends React.Component {
<h3>{user.username}</h3>
<div>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
<ul className={styles.userDetailList}>
<li>
<Icon name="assignment_ind"/>
<span className={styles.userDetailItem}>Member Since:</span>
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({id}) =>
<li key={id}>
<Icon name="email"/>
<span className={styles.userDetailItem}>Email:</span>
{id} <ButtonCopyToClipboard className={styles.copyButton} icon="content_copy" copyText={id} />
</li>
)}
</ul>
<ul className={styles.stats}>
<li className={styles.stat}>
<span className={styles.statItem}> Total Comments </span>
<spam className={styles.statResult}> {totalComments} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reject Rate </spam>
<spam className={styles.statResult}> {`${(rejectedPercent).toFixed(1)}%`} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reports </spam>
<spam className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
{capitalize(getReliability(user.reliable.flagger))}
</spam>
</li>
</ul>
<p className={styles.small}>
Data represents the last six months of activity
</p>
</div>
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
queryData={root, user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
selectedCommentIds.length === 0
? (
@@ -167,6 +186,11 @@ export default class UserDetail extends React.Component {
})
}
</div>
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
</Drawer>
</ClickOutside>
);
@@ -8,6 +8,10 @@
min-height: 0;
}
.root:last-child {
border: 0;
}
.rootSelected {
background-color: #ecf4ff;
}
@@ -51,7 +55,7 @@
position: relative;
}
.commentType {
.badgeBar {
position: absolute;
right: 0px;
}
@@ -3,6 +3,7 @@ import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import ReplyBadge from './ReplyBadge';
import styles from './UserDetailComment.css';
import CommentType from './CommentType';
import {getActionSummary} from 'coral-framework/utils';
@@ -56,7 +57,11 @@ class UserDetailComment extends React.Component {
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
<CommentType type={commentType} className={styles.commentType}/>
<div className={styles.badgeBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType}/>
</div>
</div>
<div className={styles.story}>
Story: {comment.asset.title}
@@ -10,16 +10,18 @@
.logo span {
display: inline-block;
margin-left: 10px;
font-size: 18px;
font-size: 26px;
vertical-align: middle;
font-weight: 500;
color: white;
}
.logo {
background: #E5E5E5;
background: #696969;
height: 100%;
width: 128px;
z-index: 10;
border-right: 1px #757575 solid;
}
.base {
+2 -4
View File
@@ -74,10 +74,8 @@ class LayoutContainer extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
TALK_RECAPTCHA_PUBLIC: state.config
.get('data')
.get('TALK_RECAPTCHA_PUBLIC', null)
auth: state.auth,
TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC,
});
const mapDispatchToProps = (dispatch) => ({
@@ -14,6 +14,7 @@ import {
} from 'coral-admin/src/actions/userDetail';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import update from 'immutability-helper';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
@@ -32,6 +33,7 @@ const slots = [
];
class UserDetailContainer extends React.Component {
isLoadingMore = false;
// status can be 'ACCEPTED' or 'REJECTED'
bulkSetCommentStatus = (status) => {
@@ -40,7 +42,6 @@ class UserDetailContainer extends React.Component {
});
Promise.all(changes).then(() => {
this.props.data.refetch(); // some comments may have moved out of this tab
this.props.clearUserDetailSelections(); // un-select everything
});
}
@@ -61,12 +62,53 @@ class UserDetailContainer extends React.Component {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
loadMore = () => {
if (this.isLoadingMore) {
return;
}
this.isLoadingMore = true;
const variables = {
limit: 10,
cursor: this.props.root.comments.endCursor,
author_id: this.props.data.variables.author_id,
statuses: this.props.data.variables.statuses,
};
this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
return update(prev, {
comments: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
})
.then(() => {
this.isLoadingMore = false;
})
.catch((err) => {
this.isLoadingMore = false;
throw err;
});
};
componentWillReceiveProps(next) {
if (this.props.userId === null && next.userId) {
next.data.refetch();
}
}
render () {
if (!this.props.userId) {
return null;
}
const loading = !('user' in this.props.root) || this.props.root.user.id !== this.props.userId;
const loading = this.props.data.loading;
return <UserDetail
bulkReject={this.bulkReject}
@@ -76,10 +118,20 @@ class UserDetailContainer extends React.Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
loading={loading}
loadMore={this.loadMore}
{...this.props} />;
}
}
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
}
}
${commentConnectionFragment}
`;
export const withUserDetailQuery = withQuery(gql`
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
user(id: $author_id) {
@@ -90,6 +142,9 @@ export const withUserDetailQuery = withQuery(gql`
id
provider
}
reliable {
flagger
}
${getSlotFragmentSpreads(slots, 'user')}
}
totalComments: commentCount(query: {author_id: $author_id})
@@ -117,8 +172,8 @@ const mapStateToProps = (state) => ({
selectedCommentIds: state.userDetail.selectedCommentIds,
statuses: state.userDetail.statuses,
activeTab: state.userDetail.activeTab,
bannedWords: state.settings.toJS().wordlist.banned,
suspectWords: state.settings.toJS().wordlist.suspect,
bannedWords: state.settings.wordlist.banned,
suspectWords: state.settings.wordlist.suspect,
});
const mapDispatchToProps = (dispatch) => ({
@@ -9,6 +9,7 @@ export default withFragments({
body
created_at
status
hasParent
asset {
id
title
+28 -23
View File
@@ -1,35 +1,40 @@
import {Map, List, fromJS} from 'immutable';
import * as actions from '../constants/assets';
import update from 'immutability-helper';
const initialState = Map({
byId: Map(),
ids: List(),
assets: List()
});
const initialState = {
byId: {},
ids: [],
assets: []
};
export default function assets (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSETS_SUCCESS:
return replaceAssets(action, state);
case actions.FETCH_ASSETS_SUCCESS: {
const assets = action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {});
return update(state, {
byId: {$set: assets},
count: {$set: action.count},
ids: {$set: Object.keys(assets)},
});
}
case actions.UPDATE_ASSET_STATE_REQUEST:
return state
.setIn(['byId', action.id, 'closedAt'], action.closedAt);
return update(state, {
byId: {
[action.id]: {
closedAt: {$set: action.closedAt},
},
},
});
case actions.UPDATE_ASSETS:
return state
.set('assets', List(action.assets));
return update(state, {
assets: {$set: action.assets},
});
default:
return state;
}
}
const replaceAssets = (action, state) => {
const assets = fromJS(action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {}));
return state
.set('byId', assets)
.set('count', action.count)
.set('ids', List(assets.keys()));
};
+40 -20
View File
@@ -1,43 +1,63 @@
import {Map} from 'immutable';
import * as actions from '../constants/auth';
const initialState = Map({
const initialState = {
loggedIn: false,
user: null,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
});
};
export default function auth (state = initialState, action) {
switch (action.type) {
case actions.CHECK_LOGIN_REQUEST:
return state
.set('loadingUser', true);
return {
...state,
loadingUser: true,
};
case actions.CHECK_LOGIN_FAILURE:
return state
.set('loggedIn', false)
.set('loadingUser', false)
.set('user', null);
return {
...state,
loggedIn: false,
loadingUser: false,
user: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('loggedIn', true)
.set('loadingUser', false)
.set('user', action.user);
return {
...state,
loggedIn: true,
loadingUser: false,
user: action.user,
};
case actions.LOGOUT:
return initialState;
case actions.LOGIN_SUCCESS:
return state.set('loginMaxExceeded', false).set('loginError', null);
return {
...state,
loginMaxExceeded: false,
loginError: null,
};
case actions.LOGIN_FAILURE:
return state.set('loginError', action.message);
return {
...state,
loginError: action.message,
};
case actions.FETCH_FORGOT_PASSWORD_REQUEST:
return state.set('passwordRequestSuccess', null);
return {
...state,
passwordRequestSuccess: null,
};
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email.');
return {
...state,
passwordRequestSuccess: 'If you have a registered account, a password reset link was sent to that email.',
};
case actions.LOGIN_MAXIMUM_EXCEEDED:
return state
.set('loginMaxExceeded', true)
.set('loginError', action.message);
return {
...state,
loginMaxExceeded: true,
loginError: action.message,
};
default :
return state;
}
+57 -44
View File
@@ -1,5 +1,3 @@
import {Map} from 'immutable';
import {
FETCH_COMMENTERS_REQUEST,
FETCH_COMMENTERS_FAILURE,
@@ -13,8 +11,8 @@ import {
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
const initialState = Map({
community: Map(),
const initialState = {
community: {},
isFetchingPeople: false,
errorPeople: '',
accounts: [],
@@ -22,72 +20,87 @@ const initialState = Map({
ascPeople: false,
totalPagesPeople: 0,
pagePeople: 0,
user: Map({}),
user: {},
banDialog: false,
rejectUsernameDialog: false
});
};
export default function community (state = initialState, action) {
switch (action.type) {
case FETCH_COMMENTERS_REQUEST :
return state
.set('isFetchingPeople', true);
return {
...state,
isFetchingPeople: true,
};
case FETCH_COMMENTERS_FAILURE :
return state
.set('isFetchingPeople', false)
.set('errorPeople', action.error);
return {
...state,
isFetchingPeople: false,
errorPeople: action.error,
};
case FETCH_COMMENTERS_SUCCESS : {
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
return state
.merge({
isFetchingPeople: false,
errorPeople: '',
pagePeople: page,
countPeople: count,
limitPeople: limit,
totalPagesPeople: totalPages,
...rest
})
.set('accounts', accounts); // Sets to normal array
return {
...state,
isFetchingPeople: false,
errorPeople: '',
pagePeople: page,
countPeople: count,
limitPeople: limit,
totalPagesPeople: totalPages,
...rest,
accounts, // Sets to normal array
};
}
case SET_ROLE : {
const commenters = state.get('accounts');
const commenters = state.accounts;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].roles[0] = action.role;
return state.set('accounts', commenters.map((id) => id));
return {
...state,
accounts: commenters.map((id) => id),
};
}
case SET_COMMENTER_STATUS: {
const commenters = state.get('accounts');
const commenters = state.accounts;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].status = action.status;
return state.set('accounts', commenters.map((id) => id));
return {
...state,
accounts: commenters.map((id) => id),
};
}
case SORT_UPDATE :
return state
.set('fieldPeople', action.sort.field)
.set('ascPeople', !state.get('ascPeople'));
return {
...state,
fieldPeople: action.sort.field,
ascPeople: !state.ascPeople,
};
case HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false);
return {
...state,
banDialog: false,
};
case SHOW_BANUSER_DIALOG:
return state
.merge({
user: Map(action.user),
banDialog: true
});
return {
...state,
user: action.user,
banDialog: true,
};
case HIDE_REJECT_USERNAME_DIALOG:
return state
.set('rejectUsernameDialog', false);
return {
...state,
rejectUsernameDialog: false,
};
case SHOW_REJECT_USERNAME_DIALOG:
return state
.merge({
user: Map(action.user),
rejectUsernameDialog: true
});
return {
...state,
user: action.user,
rejectUsernameDialog: true
};
default :
return state;
}
+7 -6
View File
@@ -1,15 +1,16 @@
import {Map} from 'immutable';
import * as actions from '../actions/config';
const initialState = Map({
data: Map({})
});
const initialState = {
data: {}
};
export default function config (state = initialState, action) {
switch (action.type) {
case actions.CONFIG_UPDATED:
return state.set('data', Map(action.data));
return {
...state,
data: action.data,
};
default:
return state;
}
+82 -49
View File
@@ -1,30 +1,29 @@
import {Map, List} from 'immutable';
import * as actions from '../constants/install';
import update from 'immutability-helper';
const initialState = Map({
const initialState = {
isLoading: false,
data: Map({
settings: Map({
data: {
settings: {
organizationName: '',
domains: Map({
whitelist: List()
})
}),
user: Map({
domains: {
whitelist: [],
}
},
user: {
username: '',
email: '',
password: '',
confirmPassword: ''
})
}),
errors: Map({
}
},
errors: {
organizationName: '',
username: '',
email: '',
password: '',
confirmPassword: ''
}),
},
showErrors: false,
hasError: false,
error: null,
@@ -44,57 +43,91 @@ const initialState = Map({
installRequest: null,
installRequestError: null,
alreadyInstalled: false
});
};
export default function install (state = initialState, action) {
switch (action.type) {
case actions.NEXT_STEP:
return state
.set('step', state.get('step') + 1);
return {
...state,
step: state.step + 1,
};
case actions.PREVIOUS_STEP:
return state
.set('step', state.get('step') - 1);
return {
...state,
step: state.step - 1,
};
case actions.GO_TO_STEP:
return state
.set('step', action.step);
return {
...state,
step: action.step,
};
case actions.UPDATE_PERMITTED_DOMAINS_SETTINGS:
return state
.setIn(['data', 'settings', 'domains', 'whitelist'], action.value);
return update(state, {
data: {
settings: {
domains: {
whitelist: {$set: action.value},
},
},
},
});
case actions.UPDATE_FORMDATA_SETTINGS:
return state
.setIn(['data', 'settings', action.name], action.value);
return update(state, {
data: {
settings: {
[action.name]: {$set: action.value},
},
},
});
case actions.UPDATE_FORMDATA_USER:
return state
.setIn(['data', 'user', action.name], action.value);
return update(state, {
data: {
user: {
[action.name]: {$set: action.value},
},
},
});
case actions.HAS_ERROR:
return state
.merge({
hasError: true,
showErrors: true
});
return {
...state,
hasError: true,
showErrors: true,
};
case actions.ADD_ERROR:
return state
.setIn(['errors', action.name], action.error);
return update(state, {
errors: {
[action.name]: {$set: action.error},
},
});
case actions.CLEAR_ERRORS:
return state
.set('errors', Map());
return {
...state,
errors: {},
};
case actions.INSTALL_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.INSTALL_SUCCESS:
return state
.set('isLoading', false)
.set('installRequest', 'SUCCESS');
return {
...state,
isLoading: false,
installRequest: 'SUCCESS',
};
case actions.INSTALL_FAILURE:
return state
.merge({
isLoading: false,
installRequest: 'FAILURE',
installRequestError: action.error
});
return {
...state,
isLoading: false,
installRequest: 'FAILURE',
installRequestError: action.error
};
case actions.CHECK_INSTALL_SUCCESS:
return state
.set('alreadyInstalled', action.installed);
return {
...state,
alreadyInstalled: action.installed,
};
default :
return state;
}
+31 -14
View File
@@ -1,37 +1,54 @@
import {fromJS} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
const initialState = {
singleView: false,
modalOpen: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
});
};
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.TOGGLE_MODAL:
return state
.set('modalOpen', action.open);
return {
...state,
modalOpen: action.open,
};
case actions.SINGLE_VIEW:
return state
.set('singleView', !state.get('singleView'));
return {
...state,
singleView: !state.singleView,
};
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
return {
...state,
shortcutsNoteVisible: 'hide',
};
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
return {
...state,
storySearchVisible: true,
};
case actions.HIDE_STORY_SEARCH:
return state.set('storySearchVisible', false);
return {
...state,
storySearchVisible: false,
};
case actions.STORY_SEARCH_CHANGE_VALUE:
return state.set('storySearchString', action.value);
return {
...state,
storySearchString: action.value,
};
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
return {
...state,
sortOrder: action.order,
};
default:
return state;
}
}
+51 -32
View File
@@ -1,5 +1,5 @@
import {Map, List} from 'immutable';
import * as actions from '../actions/settings';
import update from 'immutability-helper';
// this is initialized here because
// currently you have to reload the dashboard to get new stats
@@ -10,63 +10,82 @@ const DASHBOARD_WINDOW_MINUTES = 5;
let then = new Date();
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
const initialState = Map({
wordlist: Map({
banned: List(),
suspect: List()
}),
const initialState = {
wordlist: {
banned: [],
suspect: []
},
dashboardWindowStart: then.toISOString(),
dashboardWindowEnd: new Date().toISOString(),
domains: Map({
whitelist: List()
}),
domains: {
whitelist: []
},
saveSettingsError: null,
fetchSettingsError: null,
fetchingSettings: false
});
};
export default function settings (state = initialState, action) {
switch (action.type) {
case actions.SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('fetchSettingsError', null);
return {
...state,
fetchingSettings: true,
fetchSettingsError: null,
};
case actions.SETTINGS_RECEIVED:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SETTINGS_FETCH_ERROR:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.SETTINGS_UPDATED:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SAVE_SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('saveSettingsError', null);
return {
...state,
fetchingSettings: true,
saveSettingsError: null,
};
case actions.SAVE_SETTINGS_SUCCESS:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SAVE_SETTINGS_FAILED:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.WORDLIST_UPDATED:
return state
.setIn(['wordlist', action.listName], action.list);
return update(state, {
wordlist: {
[action.listName]: {
$set: action.list
}
}
});
case actions.DOMAINLIST_UPDATED:
return state
.setIn(['domains', action.listName], action.list);
return update(state, {
domains: {
[action.listName]: {$set: action.list},
}
});
default:
return state;
}
@@ -87,8 +87,8 @@ export const withCommunityQuery = withQuery(gql`
});
const mapStateToProps = (state) => ({
community: state.community.toJS(),
currentUser: state.auth.toJS().user,
community: state.community,
currentUser: state.auth.user,
});
const mapDispatchToProps = (dispatch) =>
@@ -23,7 +23,7 @@ class TableContainer extends Component {
}
const mapStateToProps = (state) => ({
commenters: state.community.get('accounts'),
commenters: state.community.accounts,
});
const mapDispatchToProps = (dispatch) =>
@@ -23,8 +23,8 @@ class ConfigureContainer extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
settings: state.settings.toJS()
auth: state.auth,
settings: state.settings
});
const mapDispatchToProps = (dispatch) =>
@@ -17,11 +17,11 @@ const ActivityWidget = ({assets}) => {
? assets.map((asset) => {
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{asset.commentCount}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -24,11 +24,11 @@ const FlagWidget = ({assets}) => {
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/reported/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -19,11 +19,11 @@ const LikeWidget = ({assets}) => {
const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary');
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -54,8 +54,8 @@ export const witDashboardQuery = withQuery(gql`
const mapStateToProps = (state) => {
return {
settings: state.settings.toJS(),
moderation: state.moderation.toJS()
settings: state.settings,
moderation: state.moderation
};
};
@@ -35,7 +35,7 @@ InstallContainer.contextTypes = {
};
const mapStateToProps = (state) => ({
install: state.install.toJS()
install: state.install
});
const mapDispatchToProps = (dispatch) =>
@@ -2,6 +2,7 @@ import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import ReplyBadge from 'coral-admin/src/components/ReplyBadge';
import FlagBox from 'coral-admin/src/components/FlagBox';
import styles from './styles.css';
import CommentType from 'coral-admin/src/components/CommentType';
@@ -20,6 +21,31 @@ import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
showBanUserDialog = () => {
const {comment, showBanUserDialog} = this.props;
return showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
render() {
const {
actions = [],
@@ -29,7 +55,12 @@ class Comment extends React.Component {
bannedWords,
selected,
className,
...props
data,
root,
currentUserId,
currentAsset,
acceptComment,
rejectComment,
} = this.props;
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
@@ -38,19 +69,7 @@ class Comment extends React.Component {
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const showBanUserDialog = () => props.showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const queryData = {root, comment, asset: comment.asset};
return (
<li
@@ -62,7 +81,7 @@ class Comment extends React.Component {
<div className={styles.author}>
{
(
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
<span className={styles.username} onClick={this.viewUserDetail}>
{comment.user.username}
</span>
)
@@ -75,27 +94,26 @@ class Comment extends React.Component {
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showSuspenUserDialog}>
onClick={this.showSuspendUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showBanUserDialog}>
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
}
<div className={styles.adminCommentInfoBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType} className={styles.commentType}/>
<Slot
data={props.data}
root={props.root}
comment={comment}
asset={comment.asset}
fill="adminCommentInfoBar"
data={data}
queryData={queryData}
/>
</div>
</div>
@@ -103,7 +121,7 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CommentAnimatedEdit body={comment.body}>
@@ -124,10 +142,9 @@ class Comment extends React.Component {
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
data={data}
queryData={queryData}
/>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
@@ -150,30 +167,28 @@ class Comment extends React.Component {
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
: acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
: rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
data={data}
queryData={queryData}
/>
</div>
</div>
</CommentAnimatedEdit>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
data={data}
queryData={queryData}
/>
{flagActions && flagActions.length
? <FlagBox
@@ -1,16 +1,16 @@
.count {
display: inline-block;
background: #989797;
background: #616161;
margin: 2px;
vertical-align: middle;
padding: 1px 7px;
padding: 1px 5px;
border-radius: 2px;
margin-left: 2px;
line-height: 20px;
line-height: 18px;
box-sizing: border-box;
height: 21px;
height: 18px;
right: 0;
margin-top: -2px;
margin-top: 0px;
font-size: 12px;
color: white;
}
@@ -101,33 +101,19 @@ export default class Moderation extends Component {
}
render () {
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, premodEnabled, ...props} = this.props;
const assetId = this.props.params.id;
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
const comments = root[activeTab];
let activeTabCount;
switch(activeTab) {
case 'all':
activeTabCount = root.allCount;
break;
case 'new':
activeTabCount = root.newCount;
break;
case 'approved':
activeTabCount = root.approvedCount;
break;
case 'premod':
activeTabCount = root.premodCount;
break;
case 'reported':
activeTabCount = root.reportedCount;
break;
case 'rejected':
activeTabCount = root.rejectedCount;
break;
}
const activeTabCount = root[`${activeTab}Count`];
const menuItems = Object.keys(queueConfig).map((queue) => ({
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
}));
return (
<div>
@@ -139,16 +125,10 @@ export default class Moderation extends Component {
/>
<ModerationMenu
asset={asset}
allCount={root.allCount}
newCount={root.newCount}
getModPath={getModPath}
approvedCount={root.approvedCount}
premodCount={root.premodCount}
rejectedCount={root.rejectedCount}
reportedCount={root.reportedCount}
items={menuItems}
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
premodEnabled={premodEnabled}
activeTab={activeTab}
/>
<ModerationQueue
@@ -188,9 +168,9 @@ export default class Moderation extends Component {
<Slot
data={data}
root={root}
assset={asset}
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
/>
</div>
@@ -9,16 +9,10 @@ import cn from 'classnames';
import t from 'coral-framework/services/i18n';
const ModerationMenu = ({
asset = {},
allCount,
approvedCount,
premodCount,
newCount,
rejectedCount,
reportedCount,
asset = {},
items,
selectSort,
sort,
premodEnabled,
getModPath,
activeTab
}) => {
@@ -27,49 +21,15 @@ const ModerationMenu = ({
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div className={styles.tabBarPadding} />
<div>
{
premodEnabled ? (
<Link
to={getModPath('premod', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'premod'})}
activeClassName={styles.active}>
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
) : (
<Link
to={getModPath('new', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'new'})}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.new')} <CommentCount count={newCount} />
</Link>
)
}
<Link
to={getModPath('reported', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'reported'})}
activeClassName={styles.active}>
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.reported')} <CommentCount count={reportedCount} />
</Link>
<Link
to={getModPath('approved', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'approved'})}
activeClassName={styles.active}>
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={approvedCount} />
</Link>
<Link
to={getModPath('rejected', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'rejected'})}
activeClassName={styles.active}>
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
<Link
to={getModPath('all', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'all'})}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.all')} <CommentCount count={allCount} />
</Link>
{items.map((queue) =>
<Link
key={queue.key}
to={getModPath(queue.key, asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === queue.key})}
activeClassName={styles.active}>
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CommentCount count={queue.count} />
</Link>
)}
</div>
<SelectField
className={styles.selectField}
@@ -85,10 +45,7 @@ const ModerationMenu = ({
};
ModerationMenu.propTypes = {
allCount: PropTypes.number.isRequired,
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
reportedCount: PropTypes.number.isRequired,
items: PropTypes.array.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
@@ -4,7 +4,7 @@ import Comment from '../containers/Comment';
import styles from './styles.css';
import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../../../utils/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import LoadMore from '../../../components/LoadMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
@@ -18,7 +18,7 @@
.tab {
flex: 1;
color: #C0C0C0;
color: #BDBDBD;
text-transform: capitalize;
font-weight: 100;
font-size: 14px;
@@ -29,7 +29,7 @@
margin-right: 20px;
&:hover {
color: white;
border-bottom: solid 2px #F36451;
/*border-bottom: solid 2px #F36451;*/
box-sizing: border-box;
}
}
@@ -111,7 +111,7 @@ span {
color: white;
text-transform: capitalize;
font-weight: 400;
font-size: 15px;
font-size: 20px;
letter-spacing: 1px;
transition: background-color 200ms;
opacity: 1;
@@ -173,7 +173,7 @@ span {
border-bottom: 1px solid #e0e0e0;
font-size: 18px;
width: 100%;
max-width: 700px;
max-width: 650px;
min-width: 400px;
margin: 0 auto;
position: relative;
@@ -397,26 +397,6 @@ span {
}
}
.loadMoreContainer {
display: flex;
justify-content: center;
width: 100%;
};
.loadMore {
width: 100%;
text-align: center;
color: #FFF;
max-width: 660px;
margin-bottom: 30px;
background-color: #2376D8;
cursor: pointer;
}
.loadMore:hover {
background-color: #4399FF;
}
.tabIcon {
position: relative;
top: 3px;
@@ -490,7 +470,7 @@ span {
.searchTrigger {
position: relative;
top: .3em;
top: .2em;
}
.adminCommentInfoBar {
@@ -499,4 +479,4 @@ span {
right: 0px;
top: 0px;
text-align: right;
}
}
@@ -37,6 +37,7 @@ export default withFragments({
count
... on FlagActionSummary {
reason
__typename
}
}
actions {
@@ -48,11 +49,14 @@ export default withFragments({
id
username
}
__typename
}
__typename
}
editing {
edited
}
hasParent
${getSlotFragmentSpreads(slots, 'comment')}
}
`
@@ -11,7 +11,7 @@ import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../../../graphql/utils';
import {handleCommentChange} from '../graphql';
import {fetchSettings} from 'actions/settings';
import {showBanUserDialog} from 'actions/banUserDialog';
@@ -30,23 +30,44 @@ import {
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
import queueConfig from '../queueConfig';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
function getAssetId(props) {
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
return props.params.tabOrId;
}
return props.params.id || null;
}
function getTab(props) {
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
return props.params.tabOrId;
}
return props.params.tab || null;
}
class ModerationContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment, notify) => {
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
};
get activeTab() {
const {root: {asset, settings}, router, route} = this.props;
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings
const premod = !router.params.id ? settings.moderation : asset.settings.moderation;
const premod = !id ? settings.moderation : asset.settings.moderation;
const queue = isPremod(premod) ? 'premod' : 'new';
const activeTab = route.path && route.path !== ':id' ? route.path : queue;
const activeTab = tab ? tab : queue;
return activeTab;
}
@@ -57,15 +78,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -74,15 +90,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -90,13 +101,8 @@ class ModerationContainer extends Component {
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -105,13 +111,8 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
anyQueue: true,
};
return handleCommentChange(prev, comment, sort, notify);
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -160,28 +161,9 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
asset_id: this.props.data.variables.asset_id,
statuses: queueConfig[tab].statuses,
action_type: queueConfig[tab].action_type,
};
switch(tab) {
case 'all':
variables.statuses = null;
break;
case 'new':
variables.statuses = ['NONE', 'PREMOD'];
break;
case 'approved':
variables.statuses = ['ACCEPTED'];
break;
case 'premod':
variables.statuses = ['PREMOD'];
break;
case 'reported':
variables.statuses = ['NONE', 'PREMOD'];
variables.action_type = 'FLAG';
break;
case 'rejected':
variables.statuses = ['REJECTED'];
break;
}
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
@@ -199,7 +181,8 @@ class ModerationContainer extends Component {
};
render () {
const {root, root: {asset, settings}, data, params: {id: assetId}} = this.props;
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
if (data.error) {
return <div>Error</div>;
@@ -222,6 +205,14 @@ class ModerationContainer extends Component {
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, queueConfig);
if (premodEnabled) {
delete currentQueueConfig.new;
} else {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
@@ -229,7 +220,8 @@ class ModerationContainer extends Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
premodEnabled={assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation)}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
/>;
}
}
@@ -314,49 +306,25 @@ const commentConnectionFragment = gql`
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
new: comments(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
approved: comments(query: {
statuses: [ACCEPTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
premod: comments(query: {
statuses: [PREMOD],
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
reported: comments(query: {
action_type: FLAG,
}) {
...CoralAdmin_Moderation_CommentConnection
}
`)}
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
statuses: [NONE, PREMOD],
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
rejected: comments(query: {
statuses: [REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
})
`)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -365,30 +333,6 @@ const withModQueueQuery = withQuery(gql`
moderation
}
}
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
})
rejectedCount: commentCount(query: {
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
settings {
organizationName
moderation
@@ -396,11 +340,12 @@ const withModQueueQuery = withQuery(gql`
}
${commentConnectionFragment}
`, {
options: ({params: {id = null}, moderation: {sortOrder}}) => {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sort: sortOrder,
sort: props.moderation.sortOrder,
allAssets: id === null
}
};
@@ -409,33 +354,18 @@ const withModQueueQuery = withQuery(gql`
const withQueueCountPolling = withQuery(gql`
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
})
rejectedCount: commentCount(query: {
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
})
`)}
}
`, {
options: ({params: {id = null}}) => {
options: (props) => {
const id = getAssetId(props);
return {
pollInterval: 5000,
variables: {
@@ -446,9 +376,9 @@ const withQueueCountPolling = withQuery(gql`
});
const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
moderation: state.moderation,
settings: state.settings,
auth: state.auth,
});
const mapDispatchToProps = (dispatch) => ({
@@ -1,7 +1,6 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const queues = ['all', 'premod', 'reported', 'approved', 'rejected', 'new'];
const limit = 10;
const ascending = (a, b) => {
@@ -67,32 +66,24 @@ function addCommentToQueue(root, queue, comment, sort) {
/**
* getCommentQueues determines in which queues a comment should be placed.
*/
function getCommentQueues(comment) {
const queues = ['all'];
const isFlagged = comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction');
switch(comment.status) {
case 'ACCEPTED':
queues.push('approved');
break;
case 'REJECTED':
queues.push('rejected');
break;
case 'PREMOD':
queues.push('premod');
queues.push('new');
if (isFlagged) {
queues.push('reported');
function getCommentQueues(comment, queueConfig) {
const queues = [];
Object.keys(queueConfig).forEach((key) => {
const {action_type, statuses, tags} = queueConfig[key];
let addToQueues = false;
if (statuses && statuses.indexOf(comment.status) >= 0) {
addToQueues = true;
}
break;
case 'NONE':
queues.push('new');
if (isFlagged) {
queues.push('reported');
if (tags && comment.tags && comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0)) {
addToQueues = true;
}
break;
}
if (action_type && comment.actions && comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type}action`)) {
addToQueues = true;
}
if (addToQueues) {
queues.push(key);
}
});
return queues;
}
@@ -106,42 +97,42 @@ function getCommentQueues(comment) {
* @param {string} notify.text notification text to show
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
* in the current active queue besides the 'all' queue.
* @param {Object} queueConfig queue configuration
* @return {Object} next state of the store
*/
export function handleCommentChange(root, comment, sort, notify) {
export function handleCommentChange(root, comment, sort, notify, queueConfig, activeQueue) {
let next = root;
const nextQueues = getCommentQueues(comment);
const nextQueues = getCommentQueues(comment, queueConfig);
let notificationShown = false;
const showNotificationOnce = () => {
if (notificationShown) {
return;
}
notification.info(notify.text);
notification.info(notify);
notificationShown = true;
};
queues.forEach((queue) => {
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
showNotificationOnce(comment);
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && notify.activeQueue === queue) {
if (notify && activeQueue === queue) {
showNotificationOnce(comment);
}
}
if (
notify
&& (queue === 'all' || notify.anyQueue)
&& queueHasComment(next, queue, comment.id)
&& notify.activeQueue === queue
&& activeQueue === queue
) {
showNotificationOnce(comment);
}
@@ -0,0 +1,37 @@
import t from 'coral-framework/services/i18n';
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
export default {
premod: {
statuses: ['PREMOD'],
icon: 'access_time',
name: t('modqueue.premod'),
},
new: {
statuses: ['NONE', 'PREMOD'],
icon: 'question_answer',
name: t('modqueue.new'),
},
reported: {
action_type: 'FLAG',
statuses: ['NONE', 'PREMOD'],
icon: 'flag',
name: t('modqueue.reported'),
},
approved: {
statuses: ['ACCEPTED'],
icon: 'check',
name: t('modqueue.approved'),
},
rejected: {
statuses: ['REJECTED'],
icon: 'close',
name: t('modqueue.rejected'),
},
all: {
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
icon: 'question_answer',
name: t('modqueue.all'),
},
...getModQueueConfigs(),
};
@@ -12,7 +12,7 @@ class StoriesContainer extends Component {
}
const mapStateToProps = (state) => ({
assets: state.assets.toJS()
assets: state.assets
});
const mapDispatchToProps = (dispatch) =>
@@ -15,7 +15,7 @@ class ConfigureStreamContainer extends Component {
this.state = {
changed: false,
dirtySettings: props.asset.settings,
dirtySettings: {...props.asset.settings},
closedAt: !props.asset.isClosed ? 'open' : 'closed'
};
@@ -48,26 +48,28 @@ class ConfigureStreamContainer extends Component {
changed: false
});
}, 300);
// this.props.loadAsset(this.props.data.asset);
}
}
handleChange (e) {
const changes = {};
// TODO: Dont directly manipulate state and make state change immutable.
if (e.target && e.target.id === 'qboxenable') {
this.state.dirtySettings.questionBoxEnable = e.target.checked;
changes.questionBoxEnable = e.target.checked;
}
if (e.target && e.target.id === 'qboxcontent') {
this.state.dirtySettings.questionBoxContent = e.target.value;
changes.questionBoxContent = e.target.value;
}
if (e.target && e.target.id === 'plinksenable') {
this.state.dirtySettings.premodLinksEnable = e.target.value;
changes.premodLinksEnable = e.target.value;
}
this.setState({
changed: true
changed: true,
dirtySettings: {
...this.state.dirtySettings,
...changes,
},
});
}
@@ -119,7 +121,7 @@ class ConfigureStreamContainer extends Component {
}
const mapStateToProps = (state) => ({
asset: state.asset.toJS()
asset: state.asset
});
const mapDispatchToProps = (dispatch) => ({
@@ -5,7 +5,7 @@ import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
import {forEachError} from 'coral-framework/utils';
import Comment from '../components/Comment';
import Comment from '../containers/Comment';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
@@ -93,6 +93,7 @@ class AllCommentsPane extends React.Component {
viewNewComments = () => {
this.setState(resetCursors);
this.props.emit('ui.AllCommentsPane.viewNewComments');
};
// getVisibileComments returns a list containing comments
@@ -142,6 +143,7 @@ class AllCommentsPane extends React.Component {
charCountEnable,
maxCharCount,
editComment,
emit,
} = this.props;
const {loadingState} = this.state;
@@ -181,6 +183,7 @@ class AllCommentsPane extends React.Component {
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
editComment={editComment}
emit={emit}
/>;
})}
</TransitionGroup>
@@ -163,6 +163,7 @@
.header {
display: flex;
align-items: center;
margin: 10px 0;
}
.content {
@@ -171,3 +172,7 @@
.footer {
min-height: 10px;
}
.username {
margin-right: 5px;
}
@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import AuthorName from 'talk-plugin-author-name/AuthorName';
import TagLabel from 'talk-plugin-tag-label/TagLabel';
import PubDate from 'talk-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'talk-plugin-replies';
@@ -16,14 +15,17 @@ import mapValues from 'lodash/mapValues';
import LoadMore from './LoadMore';
import {getEditableUntilDate} from './util';
import {findCommentWithId} from '../graphql/utils';
import {TopRightMenu} from './TopRightMenu';
import CommentContent from './CommentContent';
import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import InactiveCommentLabel from './InactiveCommentLabel';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive} from 'coral-framework/utils';
import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive, getShallowChanges} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
import CommentContainer from '../containers/Comment';
import {CommentAuthorName} from 'coral-framework/components';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
@@ -72,6 +74,17 @@ const ActionButton = ({children}) => {
);
};
// Determine whether the comment with id is in the part of the comments tree.
function containsCommentId(props, id) {
if (props.comment.id === id) {
return true;
}
if (props.comment.replies) {
return findCommentWithId(props.comment.replies.nodes, id);
}
return false;
}
export default class Comment extends React.Component {
constructor(props) {
@@ -86,7 +99,6 @@ export default class Comment extends React.Component {
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
isEditing: false,
replyBoxVisible: false,
animateEnter: false,
loadingState: '',
...resetCursors({}, props),
};
@@ -112,20 +124,21 @@ export default class Comment extends React.Component {
}
}
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
shouldComponentUpdate(nextProps, nextState) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
// Specifically handle `activeReplyBox` if it is the only change.
const changes = [...getShallowChanges(this.props, nextProps), ...getShallowChanges(this.state, nextState)];
if (changes.length === 1 && changes[0] === 'activeReplyBox') {
if (
!containsCommentId(this.props, this.props.activeReplyBox) &&
!containsCommentId(nextProps, nextProps.activeReplyBox)
) {
return false;
}
}
this.setState({animateEnter: true});
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
static propTypes = {
@@ -180,6 +193,9 @@ export default class Comment extends React.Component {
// edit a comment, passed (id, asset_id, { body })
editComment: PropTypes.func,
// emit custom events
emit: PropTypes.func.isRequired,
}
editComment = (...args) => {
@@ -207,7 +223,7 @@ export default class Comment extends React.Component {
}
loadNewReplies = () => {
const {replies, replyCount, id} = this.props.comment;
const {comment: {replies, replyCount, id}, emit} = this.props;
if (replyCount > replies.nodes.length) {
this.setState({loadingState: 'loading'});
this.props.loadMore(id)
@@ -221,9 +237,11 @@ export default class Comment extends React.Component {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
emit('ui.Comment.showMoreReplies', {id});
return;
}
this.setState(resetCursors);
emit('ui.Comment.showMoreReplies', {id});
};
showReplyBox = () => {
@@ -239,6 +257,10 @@ export default class Comment extends React.Component {
return;
}
commentPostedHandler = () => {
this.props.setActiveReplyBox('');
}
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
@@ -314,6 +336,8 @@ export default class Comment extends React.Component {
showSignInDialog,
liveUpdates,
commentIsIgnored,
animateEnter,
emit,
commentClassNames = []
} = this.props;
@@ -366,7 +390,7 @@ export default class Comment extends React.Component {
styles[`rootLevel${depth}`],
{
...conditionalClassNames,
[styles.enter]: this.state.animateEnter,
[styles.enter]: animateEnter,
},
);
@@ -386,10 +410,13 @@ export default class Comment extends React.Component {
// props that are passed down the slots.
const slotProps = {
data,
depth,
};
const queryData = {
root,
asset,
comment,
depth,
};
return (
@@ -400,16 +427,24 @@ export default class Comment extends React.Component {
<div className={commentClassName}>
<Slot
className={styles.commentAvatar}
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
inline
/>
<div className={styles.commentContainer}>
<div className={cn(styles.commentContainer, 'talk-stream-comment-container')}>
<div className={cn(styles.header, 'talk-stream-comment-header')}>
<Slot
className={cn(styles.username, 'talk-stream-comment-user-name')}
fill="commentAuthorName"
defaultComponent={CommentAuthorName}
queryData={queryData}
{...slotProps}
/>
<div className={styles.header}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
@@ -425,6 +460,7 @@ export default class Comment extends React.Component {
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
/>
{ isActive && (currentUser && (comment.user.id === currentUser.id)) &&
@@ -471,6 +507,7 @@ export default class Comment extends React.Component {
fill="commentContent"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
/>
</div>
}
@@ -482,6 +519,7 @@ export default class Comment extends React.Component {
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
{!disableReply &&
@@ -498,6 +536,7 @@ export default class Comment extends React.Component {
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
inline
/>
<ActionButton>
@@ -523,9 +562,7 @@ export default class Comment extends React.Component {
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
commentPostedHandler={this.commentPostedHandler}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
@@ -541,7 +578,7 @@ export default class Comment extends React.Component {
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
: <CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
@@ -567,6 +604,7 @@ export default class Comment extends React.Component {
reactKey={reply.id}
key={reply.id}
comment={reply}
emit={emit}
/>;
})}
</TransitionGroup>
@@ -28,7 +28,7 @@ export default class Embed extends React.Component {
};
render() {
const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
const {user} = this.props.auth;
const hasHighlightedComment = !!commentId;
@@ -64,14 +64,18 @@ export default class Embed extends React.Component {
</Tab>
}
</TabBar>
<Slot fill="embed" />
<Slot
data={data}
queryData={{root}}
fill="embed"
/>
<TabContent
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'}>
<Stream data={this.props.data} root={this.props.root} />
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'}>
<ProfileContainer />
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {StreamError} from './StreamError';
import Comment from '../components/Comment';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
@@ -10,16 +10,16 @@ import {ModerationLink} from 'talk-plugin-moderation';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import t, {timeago} from 'coral-framework/services/i18n';
import {getSlotComponents} from 'coral-framework/helpers/plugins';
import CommentBox from 'talk-plugin-commentbox/CommentBox';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import StreamTabPanel from '../containers/StreamTabPanel';
import styles from './Stream.css';
@@ -35,46 +35,20 @@ class Stream extends React.Component {
componentWillReceiveProps(next) {
// Keep comment box when user was live suspended, banned, ...
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
if (!this.props.userIsDegraged && next.userIsDegraged) {
this.setState({keepCommentBox: true});
}
this.fallbackAllTab(next);
}
componentDidMount() {
this.fallbackAllTab();
}
fallbackAllTab(props = this.props) {
if (props.activeStreamTab !== 'all') {
const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName);
if (slotPlugins.indexOf(props.activeStreamTab) === -1) {
props.setActiveStreamTab('all');
}
}
}
getSlotProps({data, root, root: {asset}} = this.props) {
return {data, root, asset};
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, this.getSlotProps(props));
}
setActiveReplyBox = (id) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
} else {
this.props.setActiveReplyBox(id);
}
commentIsIgnored = (comment) => {
const me = this.props.root.me;
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
const {
data,
@@ -83,7 +57,7 @@ class Stream extends React.Component {
setActiveReplyBox,
appendItemArray,
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}, me},
root: {asset, asset: {comment, comments, totalCommentCount}},
postComment,
addNotification,
editComment,
@@ -99,7 +73,8 @@ class Stream extends React.Component {
loadMoreComments,
viewAllComments,
auth: {loggedIn, user},
editName
editName,
emit,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
@@ -124,16 +99,9 @@ class Stream extends React.Component {
user.suspension.until &&
new Date(user.suspension.until) > new Date();
const commentIsIgnored = (comment) => {
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const slotProps = this.getSlotProps();
const slotProps = {data};
const slotQueryData = {root, asset};
if (!comment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
@@ -195,6 +163,7 @@ class Stream extends React.Component {
<Slot
fill="stream"
queryData={slotQueryData}
{...slotProps}
/>
@@ -229,11 +198,12 @@ class Stream extends React.Component {
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={highlightedComment.id}
commentIsIgnored={commentIsIgnored}
commentIsIgnored={this.commentIsIgnored}
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
@@ -244,57 +214,54 @@ class Stream extends React.Component {
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
active={activeStreamTab === PluginComponent.talkPluginName}
/>
<StreamTabPanel
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
slotProps={slotProps}
queryData={slotQueryData}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
))}
<Tab tabId={'all'}>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
</TabBar>
<TabContent activeTab={activeStreamTab} sub>
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={this.commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
))}
<TabPane tabId={'all'}>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
/>
</TabPane>
</TabContent>
}
sub
/>
</div>
}
</div>
@@ -0,0 +1,37 @@
import React from 'react';
import {TabBar, TabContent} from 'coral-ui';
import PropTypes from 'prop-types';
class StreamTabPanel extends React.Component {
render() {
const {activeTab, setActiveTab, tabs, tabPanes, sub} = this.props;
return (
<div>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
<TabContent activeTab={activeTab} sub={sub}>
{tabPanes}
</TabContent>
</div>
);
}
}
StreamTabPanel.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
tabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
tabPanes: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
className: PropTypes.string,
sub: PropTypes.bool,
};
export default StreamTabPanel;
@@ -19,7 +19,9 @@ export default class Toggleable extends React.Component {
}
close = () => {
this.setState({isOpen: false});
if (this.state.isOpen) {
this.setState({isOpen: false});
}
}
render() {
@@ -1,7 +1,11 @@
import {gql} from 'react-apollo';
import {gql, compose} from 'react-apollo';
import React from 'react';
import Comment from '../components/Comment';
import {withFragments} from 'coral-framework/hocs';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {THREADING_LEVEL} from '../constants/stream';
import hoistStatics from 'recompose/hoistStatics';
import {nest} from '../graphql/utils';
const slots = [
'streamQuestionArea',
@@ -11,10 +15,79 @@ const slots = [
'commentActions',
'commentContent',
'commentReactions',
'commentAvatar'
'commentAvatar',
'commentAuthorName'
];
export default withFragments({
/**
* withAnimateEnter is a HOC that passes a property `animateEnter` to the
* underlying BaseComponent. It must be a direct child of a `TransitionGroup`
* from https://github.com/reactjs/react-transition-group and as such must
* be the uppermost HOC applied to the BaseComponent.
*/
const withAnimateEnter = hoistStatics((BaseComponent) => {
class WithAnimateEnter extends React.Component {
state = {
animateEnter: false,
};
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
}
}
this.setState({animateEnter: true});
}
render() {
return <BaseComponent
{...this.props}
animateEnter={this.state.animateEnter}
/>;
}
}
return WithAnimateEnter;
});
const singleCommentFragment = gql`
fragment CoralEmbedStream_Comment_SingleComment on Comment {
id
body
created_at
status
replyCount
tags {
tag {
name
}
}
user {
id
username
}
action_summaries {
__typename
count
current_user {
id
}
}
editing {
edited
editableUntil
}
}
`;
const withCommentFragments = withFragments({
root: gql`
fragment CoralEmbedStream_Comment_root on RootQuery {
__typename
@@ -24,37 +97,33 @@ export default withFragments({
asset: gql`
fragment CoralEmbedStream_Comment_asset on Asset {
__typename
id
${getSlotFragmentSpreads(slots, 'asset')}
}
`,
comment: gql`
fragment CoralEmbedStream_Comment_comment on Comment {
id
body
created_at
status
replyCount
tags {
tag {
name
...CoralEmbedStream_Comment_SingleComment
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
...CoralEmbedStream_Comment_SingleComment
...nest
}
hasNextPage
startCursor
endCursor
}
}
user {
id
username
}
action_summaries {
__typename
count
current_user {
id
}
}
editing {
edited
editableUntil
}
`, THREADING_LEVEL)}
${getSlotFragmentSpreads(slots, 'comment')}
}
${singleCommentFragment}
`
})(Comment);
});
const enhance = compose(
withAnimateEnter,
withCommentFragments,
);
export default enhance(Comment);
@@ -11,7 +11,7 @@ import {Spinner} from 'coral-ui';
import * as authActions from 'coral-framework/actions/auth';
import * as assetActions from 'coral-framework/actions/asset';
import pym from 'coral-framework/services/pym';
import {getDefinitionName} from 'coral-framework/utils';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
@@ -146,12 +146,17 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
}
`;
const slots = [
'embed',
];
const EMBED_QUERY = gql`
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
me {
id
status
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(Stream.fragments.root)}
}
${Stream.fragments.root}
@@ -170,7 +175,7 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
});
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
auth: state.auth,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
@@ -10,13 +10,13 @@ import {
import * as authActions from 'coral-framework/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {editName} from 'coral-framework/actions/user';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
import {withFragments, withEmit} from 'coral-framework/hocs';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {Spinner} from 'coral-ui';
import {can} from 'coral-framework/services/perms';
import {
findCommentInEmbedQuery,
insertCommentIntoEmbedQuery,
@@ -24,9 +24,8 @@ import {
insertFetchedCommentsIntoEmbedQuery,
nest,
} from '../graphql/utils';
import omit from 'lodash/omit';
const {showSignInDialog} = authActions;
const {showSignInDialog, editName} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
@@ -141,8 +140,16 @@ class StreamContainer extends React.Component {
clearInterval(this.countPoll);
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
if (this.props.refetching) {
if (this.props.refetching
|| !this.props.root.asset
|| !this.props.root.asset.comment
&& !this.props.root.asset.comments
) {
return <Spinner />;
}
return <Stream
@@ -150,6 +157,7 @@ class StreamContainer extends React.Component {
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
/>;
}
}
@@ -157,19 +165,11 @@ class StreamContainer extends React.Component {
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
status
user {
id
}
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
}
${Comment.fragments.comment}
`;
@@ -206,27 +206,14 @@ const LOAD_MORE_QUERY = gql`
query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
...CoralEmbedStream_Stream_comment
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
${commentFragment}
`;
const slots = [
@@ -298,7 +285,7 @@ const fragments = {
};
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
auth: state.auth,
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
@@ -311,7 +298,6 @@ const mapStateToProps = (state) => ({
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
reduxState: omit(state, 'apollo'),
});
const mapDispatchToProps = (dispatch) =>
@@ -326,6 +312,7 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
withFragments(fragments),
withEmit,
connect(mapStateToProps, mapDispatchToProps),
withPostComment,
withPostFlag,
@@ -0,0 +1,108 @@
import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import {Tab, TabPane} from 'coral-ui';
import {getShallowChanges} from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
class StreamTabPanelContainer extends React.Component {
componentDidMount() {
this.fallbackAllTab();
}
componentWillReceiveProps(next) {
this.fallbackAllTab(next);
}
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevUuid = this.getSlotComponents(this.props.tabSlot, this.props).map((cmp) => cmp.talkUuid);
const nextUuid = this.getSlotComponents(next.tabSlot, next).map((cmp) => cmp.talkUuid);
return !isEqual(prevUuid, nextUuid);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
fallbackAllTab(props = this.props) {
if (props.activeTab !== props.fallbackTab) {
const slotPlugins = this.getSlotComponents(props.tabSlot, props).map((c) => c.talkPluginName);
if (slotPlugins.indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
}
}
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
}
getPluginTabElements(props = this.props) {
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
));
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
/>
</TabPane>
));
}
render() {
return (
<StreamTabPanel
className={this.props.className}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
tabs={this.getPluginTabElements().concat(this.props.appendTabs)}
tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)}
sub={this.props.sub}
/>
);
}
}
StreamTabPanelContainer.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
appendTabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
appendTabPanes: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
fallbackTab: PropTypes.string.isRequired,
tabSlot: PropTypes.string.isRequired,
tabPaneSlot: PropTypes.string.isRequired,
slotProps: PropTypes.object.isRequired,
queryData: PropTypes.object,
className: PropTypes.string,
sub: PropTypes.bool,
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
export default connect(mapStateToProps, null)(StreamTabPanelContainer);
+24 -6
View File
@@ -73,6 +73,11 @@ const extension = {
created_at
status
replyCount
asset {
id
title
url
}
tags {
tag {
name
@@ -142,13 +147,11 @@ const extension = {
__typename: 'Comment',
user: {
__typename: 'User',
id: auth.toJS().user.id,
username: auth.toJS().user.username
id: auth.user.id,
username: auth.user.username
},
created_at: new Date().toISOString(),
body,
parent_id,
asset_id,
action_summaries: [],
tags: tags.map((tag) => ({
tag: {
@@ -157,15 +160,21 @@ const extension = {
__typename: 'Tag'
},
assigned_by: {
id: auth.toJS().user.id,
id: auth.user.id,
__typename: 'User'
},
__typename: 'TagLink'
})),
status: 'NONE',
replyCount: 0,
asset: {
__typename: 'Asset',
id: asset_id,
title: '',
url: '',
},
parent: parent_id
? {id: parent_id}
? {__typename: 'Comment', id: parent_id}
: null,
replies: {
__typename: 'CommentConnection',
@@ -190,6 +199,15 @@ const extension = {
}
return insertCommentIntoEmbedQuery(prev, comment);
},
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
return update(prev, {
me: {
comments: {
nodes: {$unshift: [comment]},
},
},
});
},
}
}),
EditComment: () => ({
@@ -117,7 +117,7 @@ export function getTopLevelParent(comment) {
return comment;
}
function findComment(nodes, callback) {
export function findComment(nodes, callback) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (callback(node)) {
@@ -133,6 +133,10 @@ function findComment(nodes, callback) {
return false;
}
export function findCommentWithId(nodes, id) {
return findComment(nodes, (node) => node.id === id);
}
export function findCommentInEmbedQuery(root, callbackOrId) {
let callback = callbackOrId;
if (typeof callbackOrId === 'string') {
+1 -11
View File
@@ -13,7 +13,7 @@ body {
width: 100%;
font-size: 14px;
margin: 0px;
padding: 0px 0px 50px 0px;
padding: 0px 0px 100px 0px;
height: auto !important;
}
@@ -238,16 +238,6 @@ body {
line-height: 1.3;
}
.talk-plugin-author-name-text {
display: inline-block;
margin: 10px 5px 10px 0;
font-weight: bold;
}
.talk-plugin-author-name-bio-flag {
float: right;
}
/* Tag Labels */
.talk-plugin-tag-label {
+2 -2
View File
@@ -13,7 +13,7 @@ const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SE
const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error});
export const updateConfiguration = (newConfig) => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
@@ -27,7 +27,7 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => {
};
export const updateOpenStream = (closedBody) => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
+24 -3
View File
@@ -4,6 +4,7 @@ import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import pym from '../services/pym';
import {addNotification} from '../actions/notification';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
@@ -220,7 +221,7 @@ const signUpSuccess = (user) => ({type: actions.FETCH_SIGNUP_SUCCESS, user});
const signUpFailure = (error) => ({type: actions.FETCH_SIGNUP_FAILURE, error});
export const fetchSignUp = (formData) => (dispatch, getState) => {
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
dispatch(signUpRequest());
coralApi('/users', {
@@ -257,7 +258,7 @@ const forgotPasswordFailure = (error) => ({
export const fetchForgotPassword = (email) => (dispatch, getState) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
coralApi('/account/password/reset', {
method: 'POST',
body: {email, loc: redirectUri}
@@ -351,7 +352,7 @@ const verifyEmailFailure = () => ({
});
export const requestConfirmEmail = (email) => (dispatch, getState) => {
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
dispatch(verifyEmailRequest());
return coralApi('/users/resend-verify', {
method: 'POST',
@@ -378,3 +379,23 @@ export const setRedirectUri = (uri) => ({
type: actions.SET_REDIRECT_URI,
uri,
});
//==============================================================================
// Edit Username
//==============================================================================
const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error});
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(editUsernameFailure(errorMessage));
});
};
-21
View File
@@ -1,21 +0,0 @@
import {addNotification} from '../actions/notification';
import coralApi from '../helpers/request';
import * as actions from '../constants/auth';
import t from 'coral-framework/services/i18n';
const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error});
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(editUsernameFailure(errorMessage));
});
};
@@ -0,0 +1,3 @@
.authorName {
font-weight: bold;
}
@@ -0,0 +1,9 @@
import React from 'react';
import styles from './CommentAuthorName.css';
const CommentAuthorName = ({comment}) =>
<span className={styles.authorName}>
{comment.user.username}
</span>;
export default CommentAuthorName;
@@ -3,13 +3,36 @@ import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
class IfSlotIsEmpty extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
}
render() {
const {className, component: Component = 'div', children} = this.props;
return (
<Component className={className}>
{this.isSlotEmpty() ? children : null}
</Component>
);
}
}
IfSlotIsEmpty.propTypes = {
@@ -3,13 +3,36 @@ import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{!isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
class IfSlotIsNotEmpty extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
}
render() {
const {className, component: Component = 'div', children} = this.props;
return (
<Component className={className}>
{this.isSlotEmpty() ? null : children}
</Component>
);
}
}
IfSlotIsNotEmpty.propTypes = {
+45 -12
View File
@@ -2,25 +2,58 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements} from 'coral-framework/helpers/plugins';
import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
import isEqual from 'lodash/isEqual';
import {getShallowChanges} from 'coral-framework/utils';
function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) {
let children = getSlotElements(fill, reduxState, rest);
const pluginConfig = reduxState.config.pluginConfig || {};
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...rest} />;
const emptyConfig = {};
class Slot extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevChildrenUuid = this.getChildren(this.props).map((child) => child.type.talkUuid);
const nextChildrenUuid = this.getChildren(next).map((child) => child.type.talkUuid);
return !isEqual(prevChildrenUuid, nextChildrenUuid);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) {
return rest;
}
getChildren(props = this.props) {
return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
}
render() {
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData)} />;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
}
}
Slot.propTypes = {
fill: React.PropTypes.string
fill: React.PropTypes.string.isRequired,
// props coming from graphql must be passed through this property.
queryData: React.PropTypes.object,
};
const mapStateToProps = (state) => ({
@@ -1 +1,2 @@
export {default as Slot} from './Slot';
export {default as CommentAuthorName} from './CommentAuthorName';
@@ -1,3 +0,0 @@
export const MULTIPLE_ASSETS_REQUEST = 'MULTIPLE_ASSETS_REQUEST';
export const MULTIPLE_ASSETS_SUCCESS = 'MULTIPLE_ASSETS_SUCCESS';
export const MULTIPLE_ASSSETS_FAILURE = 'MULTIPLE_ASSSETS_FAILURE';
-10
View File
@@ -1,10 +0,0 @@
export const EDIT_NAME_REQUEST = 'EDIT_NAME_REQUEST';
export const EDIT_NAME_SUCCESS = 'EDIT_NAME_SUCCESS';
export const EDIT_NAME_FAILURE = 'EDIT_NAME_FAILURE';
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const UPDATE_USERNAME = 'UPDATE_USERNAME';
export const IGNORE_USER_SUCCESS = 'IGNORE_USER_SUCCESS';
export const STOP_IGNORING_USER_SUCCESS = 'STOP_IGNORING_USER_SUCCESS';
+81 -10
View File
@@ -3,14 +3,21 @@ import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from './camelize';
import plugins from 'pluginsConfig';
import uuid from 'uuid/v4';
export function getSlotComponents(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.pluginConfig || {};
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(plugins
// Filter out components that have slots and have been disabled in `plugin_config`
@@ -23,7 +30,7 @@ export function getSlotComponents(slot, reduxState, props = {}) {
if(!component.isExcluded) {
return true;
}
let resolvedProps = {...props, config: pluginConfig};
let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
@@ -31,17 +38,70 @@ export function getSlotComponents(slot, reduxState, props = {}) {
});
}
export function isSlotEmpty(slot, reduxState, props) {
return getSlotComponents(slot, reduxState, props).length === 0;
export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Only care about the components defined in the plugins.
if (component.talkPluginName) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
export function getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.pluginConfig || {};
return getSlotComponents(slot, reduxState, props)
.map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig}));
export function getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)});
});
}
export function getSlotFragments(slot, part) {
@@ -64,7 +124,13 @@ export function getSlotFragments(slot, part) {
export function getGraphQLExtensions() {
return plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => o);
.filter((o) => !isEmpty(o));
}
export function getModQueueConfigs() {
return merge(...plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
function getTranslations() {
@@ -93,7 +159,12 @@ function addMetaDataToSlotComponents() {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
@@ -1,8 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Clipboard from 'clipboard';
import hoistStatics from 'recompose/hoistStatics';
export default (WrappedComponent) => {
export default hoistStatics((WrappedComponent) => {
class WithCopyToClipboard extends React.Component {
componentDidMount() {
const clipboard = new Clipboard(ReactDOM.findDOMNode(this));
@@ -26,4 +27,4 @@ export default (WrappedComponent) => {
}
return WithCopyToClipboard;
};
});
+4 -3
View File
@@ -1,11 +1,12 @@
import React from 'react';
const PropTypes = require('prop-types');
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithEmit provides a property `emit: (eventName, value)`
* to the wrapped component.
*/
export default (WrappedComponent) => {
export default hoistStatics((WrappedComponent) => {
class WithEmit extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
@@ -24,4 +25,4 @@ export default (WrappedComponent) => {
}
return WithEmit;
};
});
+109 -5
View File
@@ -1,5 +1,109 @@
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
export default (fragments) => (BaseComponent) => {
BaseComponent.fragments = fragments;
return BaseComponent;
};
import React from 'react';
import graphql from 'graphql-anywhere';
import {resolveFragments} from 'coral-framework/services/graphqlRegistry';
import mapValues from 'lodash/mapValues';
import hoistStatics from 'recompose/hoistStatics';
import {getShallowChanges} from 'coral-framework/utils';
import union from 'lodash/union';
// TODO: Should not depend on `props.data`
// Currently necessary because of this https://github.com/apollographql/graphql-anywhere/issues/38
function filter(doc, data, variables) {
const resolver = (
fieldName,
root,
args,
context,
info,
) => {
return root[info.resultKey];
};
return graphql(resolver, doc, data, null, variables);
}
// filterProps returns only the property as defined in the fragments.
// TODO: Should not depend on `props.data`
function filterProps(props, fragments) {
const filtered = {};
Object.keys(fragments).forEach((key) => {
if (!(key in props)) {
return;
}
filtered[key] = props.data
? filter(fragments[key], props[key], props.data.variables)
: props[key];
});
return filtered;
}
// hasEqualLeaves compares two different apollo query result for equality.
function hasEqualLeaves(a, b, path = '') {
for (const key of union(Object.keys(a), Object.keys(b))) {
if (!(key in a) || !(key in b)) {
return false;
}
if (typeof a[key] === 'object' && a[key] && b[key]) {
if (Array.isArray(a[key])) {
if (a[key].length !== b[key].length) {
return false;
}
}
if (!hasEqualLeaves(a[key], b[key], `${path}.${key}`)) {
return false;
}
continue;
}
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
fragments = mapValues(fragments, (val) => resolveFragments(val));
fragmentKeys = Object.keys(fragments).sort();
// Cache variables between lifecycles to speed up render.
filteredProps = filterProps(this.props, this.fragments)
queryDataHasChanged = false;
shallowChanges = null;
componentWillReceiveProps(next) {
this.shallowChanges = getShallowChanges(this.props, next);
if (this.fragmentKeys.some((key) => this.shallowChanges.indexOf(key) >= 0)) {
const nextFilteredProps = filterProps(next, this.fragments);
this.queryDataHasChanged = !hasEqualLeaves(this.filteredProps, nextFilteredProps);
if (this.queryDataHasChanged) {
// Only changed props when query data has changed.
this.filteredProps = filterProps(next, this.fragments);
}
}
}
shouldComponentUpdate(next) {
const onlyQueryDataChanges = this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0);
if (onlyQueryDataChanges) {
return this.queryDataHasChanged;
}
return this.shallowChanges.length !== 0;
}
render() {
const queryProps = this.filteredProps;
return <BaseComponent
{...this.props}
{...queryProps}
/>;
}
}
WithFragments.fragments = fragments;
return WithFragments;
});
+37 -7
View File
@@ -8,6 +8,8 @@ import {getMutationOptions, resolveFragments} from 'coral-framework/services/gra
import {getDefinitionName, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import hoistStatics from 'recompose/hoistStatics';
import union from 'lodash/union';
class ResponseErrors extends Error {
constructor(errors) {
@@ -30,7 +32,7 @@ class ResponseError {
* Exports a HOC with the same signature as `graphql`, that will
* apply mutation options registered in the graphRegistry.
*/
export default (document, config = {}) => (WrappedComponent) => {
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
config = {
...config,
options: config.options || {},
@@ -46,7 +48,13 @@ export default (document, config = {}) => (WrappedComponent) => {
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
wrappedProps = (data) => {
// Props as we would pass to the BaseComponent without optimizations.
dynamicProps = {};
// Props that are optimized by keeping the identity of function callbacks.
staticProps = {};
propsWrapper = (data) => {
const name = getDefinitionName(document);
const callbacks = getMutationOptions(name);
const mutate = (base) => {
@@ -92,13 +100,13 @@ export default (document, config = {}) => (WrappedComponent) => {
// Do not run updates when we have mutation errors.
return prev;
}
return map[key](prev, result);
return map[key](prev, result) || prev;
};
} else {
const existing = res[key];
res[key] = (prev, result) => {
const next = existing(prev, result);
return map[key](next, result);
return map[key](next, result) || next;
};
}
});
@@ -132,12 +140,34 @@ export default (document, config = {}) => (WrappedComponent) => {
throw error;
});
};
return config.props({...data, mutate});
// Save current props to `dynamicProps`
this.dynamicProps = config.props({...data, mutate});
// Sync props to `staticProps`.
// `staticProps` ultimately contains the same props as `dynamicProps` but all callbacks
// keep their identity.
union(Object.keys(this.dynamicProps), Object.keys(this.staticProps)).forEach((key) => {
if (!(key in this.dynamicProps)) {
delete this.staticProps[key];
return;
}
if (typeof this.dynamicProps[key] !== 'function') {
this.staticProps[key] = this.dynamicProps[key];
return;
}
if (!(key in this.staticProps)) {
this.staticProps[key] = (...args) => this.dynamicProps[key](...args);
return;
}
});
return this.staticProps;
};
getWrapped = () => {
if (!this.memoized) {
this.memoized = graphql(resolveFragments(document), {...config, props: this.wrappedProps})(WrappedComponent);
this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent);
}
return this.memoized;
};
@@ -147,4 +177,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+89 -51
View File
@@ -3,6 +3,7 @@ import {graphql} from 'react-apollo';
import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import hoistStatics from 'recompose/hoistStatics';
const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) {
@@ -35,7 +36,7 @@ function networkStatusToString(networkStatus) {
* Exports a HOC with the same signature as `graphql`, that will
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => (WrappedComponent) => {
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const name = getDefinitionName(document);
return class WithQuery extends React.Component {
@@ -46,6 +47,7 @@ export default (document, config = {}) => (WrappedComponent) => {
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
lastNetworkStatus = null;
data = null;
emitWhenNeeded(data) {
const {variables, networkStatus} = data;
@@ -60,59 +62,93 @@ export default (document, config = {}) => (WrappedComponent) => {
this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, data: root});
}
nextData(data) {
this.emitWhenNeeded(data);
// If data was previously set, we update it in a immutable way.
if (this.data) {
if (this.data.networkStatus !== data.networkStatus ||
this.data.loading !== data.loading ||
this.data.error !== data.error ||
this.data.variables !== data.variables) {
this.data = {
...this.data,
error: data.error,
networkStatus: data.networkStatus,
loading: data.loading,
variables: data.variables,
};
}
}
else {
// Set data for the first time.
this.data = {
error: data.error,
variables: data.variables,
networkStatus: data.networkStatus,
loading: data.loading,
startPolling: data.startPolling,
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMore: (stmArgs) => {
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
}
throw err;
},
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
})
.then((res) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.success`,
{variables: lmArgs.variables, data: res.data});
return Promise.resolve(res);
})
.catch((err) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.error`,
{variables: lmArgs.variables, error: err});
throw err;
});
},
};
}
return this.data;
}
wrappedConfig = {
...config,
options: config.options || {},
props: (args) => {
this.emitWhenNeeded(args.data);
const nextData = this.nextData(args.data);
const {root} = separateDataAndRoot(args.data);
if (config.props) {
const wrappedArgs = {
...args,
data: {
...args.data,
subscribeToMore: (stmArgs) => {
// Custom props, in this case we just pass the wrapped args to it.
return config.props({...args, data: {...args.data, ...nextData}});
}
// Resolve document fragments before passing it to `apollo-client`.
return args.data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
}
throw err;
},
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return args.data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
})
.then((res) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.success`,
{variables: lmArgs.variables, data: res.data});
return Promise.resolve(res);
})
.catch((err) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.error`,
{variables: lmArgs.variables, error: err});
throw err;
});
},
},
};
return config.props
? config.props(wrappedArgs)
: separateDataAndRoot(wrappedArgs.data);
// Return our wrapped data with a separated root.
return {...args, data: nextData, root};
},
};
@@ -128,8 +164,10 @@ export default (document, config = {}) => (WrappedComponent) => {
const reducer = withSkipOnErrors(
reducerCallbacks.reduce(
(a, b) => (prev, ...rest) =>
b(a(prev, ...rest), ...rest),
(a, b) => (prev, ...rest) => {
const next = a(prev, ...rest);
return b(next, ...rest) || next;
}
));
return {
@@ -153,4 +191,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+11 -8
View File
@@ -1,24 +1,27 @@
import {Map} from 'immutable';
import * as actions from '../constants/asset';
const initialState = Map({
const initialState = {
closedAt: null,
settings: null,
title: null,
url: null,
features: Map({}),
features: {},
status: 'open',
moderation: null
});
};
export default function asset (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS:
return state
.merge(action.asset);
return {
...state,
...action.asset,
};
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return state
.setIn(['settings'], action.settings);
return {
...state,
settings: action.settings,
};
default:
return state;
}
+180 -101
View File
@@ -1,8 +1,7 @@
import {Map, fromJS} from 'immutable';
import * as actions from '../constants/auth';
import pym from 'coral-framework/services/pym';
const initialState = Map({
const initialState = {
isLoading: false,
loggedIn: false,
user: null,
@@ -21,27 +20,34 @@ const initialState = Map({
fromSignUp: false,
requireEmailConfirmation: false,
redirectUri: pym.parentUrl || location.href,
});
};
const purge = (user) => {
const {settings, profiles, ...userData} = user; // eslint-disable-line
return fromJS(userData);
const {settings, ...userData} = user; // eslint-disable-line
return userData;
};
export default function auth (state = initialState, action) {
switch (action.type) {
case actions.FOCUS_SIGNIN_DIALOG:
return state
.set('signInDialogFocus', true);
return {
...state,
signInDialogFocus: true,
};
case actions.BLUR_SIGNIN_DIALOG:
return state
.set('signInDialogFocus', false);
case actions.SHOW_SIGNIN_DIALOG :
return state
.set('showSignInDialog', true)
.set('signInDialogFocus', true);
return {
...state,
signInDialogFocus: false,
};
case actions.SHOW_SIGNIN_DIALOG:
return {
...state,
showSignInDialog: true,
signInDialogFocus: true,
};
case actions.HIDE_SIGNIN_DIALOG :
return state.merge(Map({
return {
...state,
isLoading: false,
showSignInDialog: false,
signInDialogFocus: false,
@@ -53,125 +59,198 @@ export default function auth (state = initialState, action) {
emailVerificationSuccess: false,
emailVerificationLoading: false,
successSignUp: false
}));
case actions.SHOW_CREATEUSERNAME_DIALOG :
return state
.set('showCreateUsernameDialog', true);
case actions.HIDE_CREATEUSERNAME_DIALOG :
return state.merge(Map({
showCreateUsernameDialog: false
}));
case actions.CREATE_USERNAME_SUCCESS :
return state.merge(Map({
};
case actions.SHOW_CREATEUSERNAME_DIALOG:
return {
...state,
showCreateUsernameDialog: true,
};
case actions.HIDE_CREATEUSERNAME_DIALOG:
return {
...state,
showCreateUsernameDialog: false,
error: ''
}));
case actions.CREATE_USERNAME_FAILURE :
return state
.set('error', action.error);
case actions.CHANGE_VIEW :
return state
.set('error', '')
.set('view', action.view);
};
case actions.CREATE_USERNAME_SUCCESS:
return {
...state,
showCreateUsernameDialog: false,
error: '',
};
case actions.CREATE_USERNAME_FAILURE:
return {
...state,
error: action.error,
};
case actions.CHANGE_VIEW:
return {
...state,
error: action.error,
view: action.view,
};
case actions.CLEAN_STATE:
return initialState;
case actions.FETCH_SIGNIN_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.CHECK_LOGIN_FAILURE:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', false)
.set('user', null);
return {
...state,
checkedInitialLogin: true,
loggedIn: false,
user: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', true)
.set('user', purge(action.user));
return {
...state,
checkedInitialLogin: true,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_SUCCESS:
return state
.set('loggedIn', true)
.set('user', purge(action.user));
return {
...state,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_FAILURE:
return state
.set('isLoading', false)
.set('error', action.error)
.set('user', null);
return {
...state,
isLoading: false,
error: action.error,
user: null,
};
case actions.FETCH_SIGNUP_FACEBOOK_REQUEST:
return state
.set('fromSignUp', true);
return {
...state,
fromSignUp: true,
};
case actions.FETCH_SIGNIN_FACEBOOK_REQUEST:
return state
.set('fromSignUp', false);
return {
...state,
fromSignUp: false,
};
case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS:
return state
.set('user', purge(action.user))
.set('loggedIn', true);
return {
...state,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_FACEBOOK_FAILURE:
return state
.set('error', action.error)
.set('user', null);
return {
...state,
error: action.error,
user: null,
};
case actions.FETCH_SIGNUP_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.FETCH_SIGNUP_FAILURE:
return state
.set('error', action.error)
.set('isLoading', false);
return {
...state,
error: action.error,
isLoading: false,
};
case actions.FETCH_SIGNUP_SUCCESS:
return state
.set('isLoading', false)
.set('successSignUp', true);
return {
...state,
isLoading: false,
successSignUp: true,
};
case actions.LOGOUT:
return state
.set('user', null)
.set('isLoading', false)
.set('loggedIn', false);
return {
...state,
user: null,
isLoading: false,
loggedIn: false,
};
case actions.INVALID_FORM:
return state
.set('error', action.error);
return {
...state,
error: action.error,
};
case actions.VALID_FORM:
return state
.set('error', '');
return {
...state,
error: '',
};
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state
.set('passwordRequestFailure', null)
.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email');
return {
...state,
passwordRequestFailure: null,
passwordRequestSuccess: 'If you have a registered account, a password reset link was sent to that email',
};
case actions.FETCH_FORGOT_PASSWORD_FAILURE:
return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
return {
...state,
passwordRequestFailure: 'There was an error sending your password reset email. Please try again soon!',
passwordRequestSuccess: null,
};
case actions.UPDATE_USERNAME:
return state
.setIn(['user', 'username'], action.username);
return {
...state,
user: {
...state.user,
username: action.username,
}
};
case actions.VERIFY_EMAIL_FAILURE:
return state
.set('emailVerificationFailure', true)
.set('emailVerificationLoading', false);
return {
...state,
emailVerificationFailure: true,
emailVerificationLoading: false,
};
case actions.VERIFY_EMAIL_REQUEST:
return state.set('emailVerificationLoading', true);
return {
...state,
emailVerificationLoading: true,
};
case actions.VERIFY_EMAIL_SUCCESS:
return state
.set('emailVerificationSuccess', true)
.set('emailVerificationLoading', false);
return {
...state,
emailVerificationSuccess: true,
emailVerificationLoading: false,
};
case actions.SET_REQUIRE_EMAIL_VERIFICATION:
return state
.set('requireEmailConfirmation', action.required);
return {
...state,
requireEmailConfirmation: action.required,
};
case actions.SET_REDIRECT_URI:
return state
.set('redirectUri', action.uri);
return {
...state,
redirectUri: action.uri,
};
case 'APOLLO_SUBSCRIPTION_RESULT':
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userBanned);
return {
...state,
user: {
...state.user,
...action.result.data.userBanned,
},
};
}
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userSuspended);
return {
...state,
user: {
...state.user,
...action.result.data.userSuspended,
},
};
}
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.usernameRejected);
return {
...state,
user: {
...state.user,
...action.result.data.usernameRejected,
},
};
}
return state;
default :
-2
View File
@@ -1,11 +1,9 @@
import auth from './auth';
import user from './user';
import asset from './asset';
import {reducer as commentBox} from '../../talk-plugin-commentbox';
export default {
auth,
user,
asset,
commentBox,
};
-43
View File
@@ -1,43 +0,0 @@
import {Map} from 'immutable';
import * as authActions from '../constants/auth';
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
const initialState = Map({
username: '',
profiles: [],
settings: {},
myComments: [],
myAssets: [], // the assets from which myComments (above) originated
});
const purge = (user) => {
const {_id, created_at, updated_at, __v, roles, ...userData} = user; // eslint-disable-line
return userData;
};
export default function user (state = initialState, action) {
switch (action.type) {
case authActions.CHECK_LOGIN_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.CHECK_LOGIN_FAILURE:
return initialState;
case authActions.FETCH_SIGNIN_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.FETCH_SIGNIN_FAILURE:
return initialState;
case authActions.FETCH_SIGNIN_FACEBOOK_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.FETCH_SIGNIN_FACEBOOK_FAILURE:
return initialState;
case actions.SAVE_BIO_SUCCESS:
return state.set('settings', action.settings);
case actions.COMMENTS_BY_USER_SUCCESS:
return state.set('myComments', action.comments);
case assetActions.MULTIPLE_ASSETS_SUCCESS:
return state.set('myAssets', action.assets);
case actions.LOGOUT_SUCCESS:
return initialState;
}
return state;
}
+6
View File
@@ -1,5 +1,6 @@
import {gql} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import {capitalize} from 'coral-framework/helpers/strings';
export const getTotalActionCount = (type, comment) => {
@@ -184,3 +185,8 @@ export function getSlotFragmentSpreads(slots, resource) {
export function isCommentActive(commentStatus) {
return ['NONE', 'ACCEPTED'].indexOf(commentStatus) >= 0;
}
export function getShallowChanges(a, b) {
return union(Object.keys(a), Object.keys(b))
.filter((key) => a[key] !== b[key]);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* getReliability
* retrieves reliability value as string
*/
export const getReliability = (reliabilityValue) => {
if (reliabilityValue === null) {
return 'neutral';
} else if (reliabilityValue) {
return 'reliable';
} else {
return 'unreliable';
}
};
@@ -1,7 +1,8 @@
import {connect} from 'react-redux';
import {compose, graphql, gql} from 'react-apollo';
import {compose, gql} from 'react-apollo';
import React, {Component} from 'react';
import {bindActionCreators} from 'redux';
import {withQuery} from 'coral-framework/hocs';
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
@@ -11,18 +12,13 @@ import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'talk-plugin-history/CommentHistory';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
class ProfileContainer extends Component {
constructor() {
super();
this.state = {
activeTab: 0
};
}
componentWillReceiveProps(nextProps) {
if (!this.props.auth.loggedIn && nextProps.auth.loggedIn) {
@@ -31,32 +27,51 @@ class ProfileContainer extends Component {
}
}
handleTabChange = (tab) => {
this.setState({
activeTab: tab
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
const updated = update(previous, {
me: {
comments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
},
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
},
}
});
return updated;
},
});
};
render() {
const {auth, asset, data, showSignInDialog, stopIgnoringUser} = this.props;
const {me} = this.props.data;
const {auth, auth: {user}, showSignInDialog, stopIgnoringUser, root, data} = this.props;
const {me} = this.props.root;
const loading = this.props.data.loading;
if (!auth.loggedIn) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (!me || data.loading) {
if (loading) {
return <Spinner />;
}
const localProfile = this.props.user.profiles.find(
const localProfile = user.profiles.find(
(p) => p.provider === 'local'
);
const emailAddress = localProfile && localProfile.id;
return (
<div>
<h2>{this.props.user.username}</h2>
<h2>{user.username}</h2>
{emailAddress ? <p>{emailAddress}</p> : null}
{me.ignoredUsers && me.ignoredUsers.length
@@ -73,14 +88,47 @@ class ProfileContainer extends Component {
<h3>{t('framework.my_comments')}</h3>
{me.comments.nodes.length
? <CommentHistory comments={me.comments.nodes} asset={asset} link={link} />
? <CommentHistory data={data} root={root} comments={me.comments} link={link} loadMore={this.loadMore}/>
: <p>{t('user_no_comment')}</p>}
</div>
);
}
}
const withQuery = graphql(
// TODO: This Slot should be included in `talk-plugin-history` instead.
const slots = [
'commentContent',
];
const CommentFragment = gql`
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
nodes {
id
body
asset {
id
title
url
${getSlotFragmentSpreads(slots, 'asset')}
}
created_at
${getSlotFragmentSpreads(slots, 'comment')}
}
endCursor
hasNextPage
}
`;
const LOAD_MORE_QUERY = gql`
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) {
comments(query: {limit: $limit, cursor: $cursor}) {
...TalkSettings_CommentConnectionFragment
}
}
${CommentFragment}
`;
const withProfileQuery = withQuery(
gql`
query CoralEmbedStream_Profile {
me {
@@ -89,26 +137,17 @@ const withQuery = graphql(
id,
username,
}
comments {
nodes {
id
body
asset {
id
title
url
}
created_at
}
comments(query: {limit: 10}) {
...TalkSettings_CommentConnectionFragment
}
}
}`
);
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentFragment}
`);
const mapStateToProps = (state) => ({
user: state.user.toJS(),
asset: state.asset.toJS(),
auth: state.auth.toJS()
auth: state.auth
});
const mapDispatchToProps = (dispatch) =>
@@ -117,5 +156,5 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withStopIgnoringUser,
withQuery
withProfileQuery
)(ProfileContainer);
+20
View File
@@ -0,0 +1,20 @@
.badge {
display: inline-block;
color: white;
background: grey;
box-sizing: border-box;
padding: 2px 5px;
font-size: 12px;
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
background-color: #3D73D5;
margin-right: 4px;
}
.icon {
font-size: 14px;
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import styles from './Badge.css';
import Icon from './Icon';
import cn from 'classnames';
const Badge = ({className, children, icon, props}) => (
<span className={cn(styles.badge, className)} {...props}>
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</span>
);
export default Badge;
+4 -3
View File
@@ -27,9 +27,10 @@
}
.icon {
margin-right: 13px;
margin-right: 5px;
font-size: 18px;
vertical-align: middle;
margin-top: -3px;
}
.type--black {
@@ -143,7 +144,7 @@
border-radius: 3px;
text-transform: capitalize;
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;
width: 129px;
&:hover {
box-shadow: none;
@@ -166,7 +167,7 @@
border-radius: 3px;
text-transform: capitalize;
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;
width: 129px;
&:hover {
color: white;
+1 -1
View File
@@ -3,7 +3,7 @@
min-width: 550px;
position: fixed;
top: 0;
right: -17px;
right: 0px;
bottom: 0;
background-color: white;
transition: transform 500ms ease-in-out;
+2
View File
@@ -1,3 +1,5 @@
.root {
vertical-align: middle;
font-size: inherit;
}
+1
View File
@@ -26,3 +26,4 @@ export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';
export {default as Badge} from './components/Badge';
@@ -1,31 +0,0 @@
import React, {Component} from 'react';
const packagename = 'talk-plugin-author-name';
export default class AuthorName extends Component {
state = {showTooltip: false}
handleClick = () => {
this.setState((state) => ({
showTooltip: !state.showTooltip
}));
}
handleMouseLeave = () => {
setTimeout(() => {
this.setState({
showTooltip: false
});
}, 500);
}
render () {
const {author} = this.props;
return (
<div
className={`${packagename}-text`}>
{author && author.username}
</div>
);
}
}
-34
View File
@@ -1,34 +0,0 @@
.authorName {
color: black;
display: inline-block;
margin: 10px 8px 10px 0;
}
.hasBio {
&:hover {
cursor: pointer;
}
}
.arrowDown {
top: 0;
width: 0;
height: 0;
margin-top: -2px;
margin-left: 2px;
display: inline-block;
vertical-align: middle;
border-bottom: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 3px solid #000000;
}
.arrowUp {
width: 0;
height: 0;
border-top: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-bottom: 3px solid black;
}
@@ -133,7 +133,9 @@ export default class FlagButton extends Component {
}
handleClickOutside = () => {
this.closeMenu();
if (this.state.showMenu) {
this.closeMenu();
}
}
render () {
+41 -40
View File
@@ -7,54 +7,55 @@ import CommentContent from '../coral-embed-stream/src/components/CommentContent'
import t from 'coral-framework/services/i18n';
const Comment = (props) => {
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
comment={props.comment}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={props.link(`${props.asset.url}`)}>
Story: {props.asset.title ? props.asset.title : props.asset.url}
</a>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
class Comment extends React.Component {
render() {
const {comment, link, data, root} = this.props;
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
data={data}
queryData={{root, comment, asset: comment.asset}}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={link(`${comment.asset.url}`)}>
Story: {comment.asset.title ? comment.asset.title : comment.asset.url}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={props.comment.created_at}
/>
</li>
</ul>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={link(`${comment.asset.url}?commentId=${comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={comment.created_at}
/>
</li>
</ul>
</div>
</div>
</div>
);
};
);
}
}
Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string
}).isRequired,
asset: PropTypes.shape({
url: PropTypes.string,
title: PropTypes.string
}).isRequired
};
export default Comment;
+44 -15
View File
@@ -1,25 +1,54 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
import styles from './CommentHistory.css';
import LoadMore from './LoadMore';
import {forEachError} from 'plugin-api/beta/client/utils';
const CommentHistory = (props) => {
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{props.comments.map((comment, i) => {
return <Comment
key={i}
comment={comment}
link={props.link}
asset={comment.asset} />;
})}
class CommentHistory extends React.Component {
state = {
loadingState: '',
};
loadMore = () => {
this.setState({loadingState: 'loading'});
this.props.loadMore()
.then(() => {
this.setState({loadingState: 'success'});
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
}
render() {
const {link, comments, data, root} = this.props;
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{comments.nodes.map((comment, i) => {
return <Comment
key={i}
data={data}
root={root}
comment={comment}
link={link}
/>;
})}
</div>
{comments.hasNextPage &&
<LoadMore
loadMore={this.loadMore}
loadingState={this.state.loadingState}
/>
}
</div>
</div>
);
};
);
}
}
CommentHistory.propTypes = {
comments: PropTypes.array.isRequired
comments: PropTypes.object.isRequired
};
export default CommentHistory;
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Button} from 'coral-ui';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
class LoadMore extends React.Component {
render () {
const {loadingState, loadMore} = this.props;
const disabled = loadingState === 'loading';
return (
<div className='talk-load-more'>
<Button
onClick={loadMore}
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
disabled={disabled}
>
{t('framework.view_more_comments')}
</Button>
</div>
);
}
}
LoadMore.propTypes = {
loadMore: PropTypes.func.isRequired,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
};
export default LoadMore;
@@ -2,10 +2,11 @@ import React, {PropTypes} from 'react';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
import {BASE_PATH} from 'coral-framework/constants/url';
const ModerationLink = (props) => props.isAdmin ? (
<div className={styles.moderationLink}>
<a href={`/admin/moderate/${props.assetId}`} target="_blank">
<a href={`${BASE_PATH}admin/moderate/${props.assetId}`} target="_blank">
{t('moderate_this_stream')}
</a>
</div>

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